Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ffd811d679 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:13:51 -04:00
6a852f8852 fix(ui): correct the clipping-mask indicator orientation (#3037)
* fix(ui): correct the clipping-mask indicator orientation

* Drop the before/after screenshots from the repository

Docs screenshots stay out of the tree; the PR description keeps them.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:10:18 -04:00
Dan HiornsandClaude Opus 5.5 cb1c06fc57 GPU memory budget: read installed RAM on Windows (#3189)
physical_memory() had no Windows implementation, so the wgpu
compositor's budget capped layer textures at a quarter of an assumed
16 GB on every Windows machine: too little on a large one (4 GB of a
62 GB PC's 8 GB Memory Usage allowance) and too much on an 8 GB laptop
whose integrated GPU shares that RAM. Read it with GlobalMemoryStatusEx
through winsafe, the safe binding the desktop app already uses (same
pinned version, kernel feature only).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:43:06 -04:00
f14817a5d2 Animated GIF: every frame loads into video layers (#572) (#2999)
* feat(codecs): decode every frame of an animated GIF (#572)

decode_gif_frames returns each frame composited onto the logical screen
(disposal methods applied) with its delay in milliseconds. decode still
keeps the first frame only. A damaged later frame keeps the frames before
it and marks the first with DecodeWarning::Truncated; dimensions, the
total decoded bytes and a frame cap are checked against the limits.

* feat(io): import_gif_frames hands each GIF frame over as a document (#572)

A frame at a time becomes a single-layer document, so a caller can
flatten and drop it instead of holding every frame twice.

* feat(engine): animated GIFs load every frame into video layers (#572)

New Video Layer from File, Replace Footage, Reload Frame and Video Frames
to Layers take all frames of a GIF instead of the first. The timeline rate
comes from the GIF's delays (the most common one, delays under 20 ms shown
at 100 ms as browsers do); an explicit fps still wins.

* docs: animated GIF frames in video layers (#572)

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:26:37 -04:00
skylar taylor-barrick 61b6276c78 Security: harden document exports and automation inputs (#2269)
* chore: clear strict Clippy simplifications

* security: harden web exports and generated image assets

* security: reject redirected directory bundle writes

* security: quote paths in droplet shell launchers

* security: use private ephemeral print spool files

* security: bound automation file reads and MCP input

* security: bound MCP transport requests before dispatch

* chore: keep current sample checks Clippy clean

* security: preserve safe export roots and print semantics

* security: clear non-sending print state before replay

* security: forget print send overrides between jobs

* chore: keep macOS UI tests and Clippy clean

* Test web export paths portably on Windows
2026-10-11 23:18:52 -06:00
Sir Starch b4a949225d Support automatic monitor ICC profiles on Linux X11 (#2963)
* Linux X11: detect and apply monitor ICC profiles

* Test Linux monitor ICC profile handling

* Gate display detection test to supported platforms
2026-10-12 01:17:59 -04:00
Sir Starch e8c7656150 Use each XRandR output's ICC profile in the screen picker (#2962)
* Screen picker: use each XRandR output's ICC profile

* Test X11 screen picker ICC validation
2026-10-12 01:09:03 -04:00
Sir Starch 82c58fabab Use Fontconfig for Linux font discovery (#2330) (#2961)
* Fix #2330: discover installed fonts through Fontconfig

* Fix wasm cfg for Fontconfig fallback

* Add Linux font discovery regression tests

* Reject Fontconfig matches without CJK coverage
2026-10-12 01:05:56 -04:00
5e0a6d404d Tools: Shift key switches tools by default, as in Photoshop (#3525) (#3528)
* Tools: Shift key switches tools by default, as in Photoshop (#3525)

Use Shift Key for Tool Switch is on by default in Photoshop: T keeps the
current tool, Shift+T cycles. Saved settings keep their stored value.

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

* Tests: the M-key marquee cycling test sends Shift+M, the new default (#3296)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 00:54:41 -04:00
FinnNkandClaude Opus 5.5 962e0a2f98 feat(ui): Photoshop's New Guide: units and colour (#3553)
* feat(engine): accept Photoshop units for guide positions

view.newGuide and view.moveGuide take the position as before (a number
of pixels) or as text with a unit, as Photoshop's New Guide dialog does:
"50%", "2.5 cm", "1in", "12pt", "3pc", "100px". Percent is of the
document's width for a vertical guide and its height for a horizontal
one; points and picas follow Units & Rulers › Point Size. Fractional
positions are kept. Text that isn't a length, NaN and positions beyond
1,000,000 px are refused without recording a history step.

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

* feat(engine): guides can have their own colour

Photoshop's New Guide sets a colour per guide; PhotoCraft guides only had
the Preferences guide colour. Guides now keep optional colours, keyed by
orientation and position so code that drops or reorders guide positions
(crop, artboards, guide layouts) can never move a colour onto another
guide: an unmatched guide just uses the preference colour.

view.newGuide takes an optional "color": a name from New Guide's list
(lightBlue, lightRed, green, mediumBlue, yellow, magenta, cyan, lightGray,
black) or "#rrggbb". Without one the guide uses the preference colour, as
guides dragged from the rulers always have. Moving a guide carries its
colour; deleting and clearing drop it (Clear Guides, Clear Canvas Guides,
Clear Selected Artboard Guides and New Guide Layout's Clear Existing
Guides alike); undo restores it. Guides are drawn in their own colour
(while New Guide Layout previews, the rest show the colour it will set), and .pcraft files keep it (omitted when no guide has
one, so older readers and files are unaffected). PSD export keeps guide
positions only.

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

* feat(ui): Photoshop's New Guide dialog: units and colour

View › Guides › New Guide… was the generic schema dialog, whose position
field was whole pixels only and had no colour. It now has its own
dialog, as in Photoshop:

- Position opens in the ruler unit ("0 cm") and takes any unit
  Photoshop's field takes ("50%", "2.5 cm", "12 pt"); a bare number is
  in the ruler unit. The last guide reopens in whatever the ruler unit
  is then ("5.08 cm").
- Color lists Custom…, Light Blue, Light Red, Green, Medium Blue,
  Yellow, Magenta, Cyan (the default), Light Gray and Black. Custom…
  shows a swatch that opens the Color Picker. The guide stores the
  chosen colour itself, so a later change of the preference guide
  colour leaves it alone.
- A position the engine refuses reopens the dialog with what was typed
  and the engine's message, as Photoshop keeps New Guide open.

Light Blue, Light Red, Medium Blue and Light Gray are new to the
catalogues; they were translated for all 16 languages from the existing
colour terms in each and would benefit from native review.

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

* refactor(engine): one unit-suffix table for typed lengths

New Guide's position field (Unit::parse_length) and the Crop tool's size
fields (crop_size::unit_of) each kept the same table of unit suffixes.
Unit::from_suffix is now the one table: px, in or ", cm, mm, pt, pc or
pica, % and their long forms, in any case. The crop fields keep their own
number parsing (they accept arithmetic) and still refuse percent; they now
also accept "pc" for picas.

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

* feat(ui): New Guide and New Guide Layout share one colour list

New Guide Layout (#2930) offered five colours of its own; New Guide offers
Photoshop's nine. Both dialogs now list the same nine (GUIDE_COLORS), in
Photoshop's order and with the same translated names. Cyan (#4affff, the
default guide colour), Magenta (#ff00ff) and Yellow (#ffcc00) take New
Guide Layout's values; Light Blue, Light Red, Green, Medium Blue, Light
Gray and Black keep New Guide's. New Guide Layout's Blue (#378ef0) and
jade Green (#00a86b) give way to Photoshop's Light Blue and Green; a colour
outside the list still shows as Custom.

GUIDE_COLORS drops its unused label column (the dialogs translate the
names), and New Guide Layout reads "#rrggbb" with prefs::parse_hex instead
of its own copy (rulers::parse_guide_color).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 00:46:56 -04:00
F4LCON 973e7776ea History panel: snapshots (camera button, snapshot rows) (#3545)
Part of #2966. The History panel's camera button takes a named snapshot
of the document ("Snapshot N", not a history state). Snapshots are
listed under the opening state with a thumbnail and scroll with the
states; clicking one returns the document to it as one new, undoable
state named after it. A double-click or right-click › Rename… renames
it in place, and right-click › Delete removes it. Snapshots live for the
session only, as in Photoshop.

For agents: history.newSnapshot {name?, from?: fullDocument |
mergedLayers | currentLayer}, history.selectSnapshot, renameSnapshot and
deleteSnapshot, addressed by ids that are never reused, and doc.inspect
reports snapshots.
2026-10-12 00:35:56 -04:00
skylar taylor-barrick 2bf49f10a7 Security: verify AppImage build inputs (#2270)
* security: pin and verify release build inputs

* docs: explain verified offline AppImage inputs

* security: validate executable AppImage tooling offline

* security: preserve read-only verified AppImage caches

* test: cover verified runtime downloads and cache diagnostics

* test: assert verified runtime mode and cache diagnostics

* Fix shellcheck ordering in verified download test

* Use external chmod for cache test setup
2026-10-11 22:33:26 -06:00
Valentin KleinandClaude Opus 5.5 80d646fc17 Brush: draw the smoothing string from the brush to the pointer while drawing (#3479) (#3526)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 00:26:04 -04:00
Scott C Hughes ee05bc833b fix(io): a fresh Metrics type layer writes no empty Txt2 stub (#3514) (#3529)
PSD export always wrote a document-level Txt2 block, and for a fresh document its
document-resources dictionary (/0) was the empty `<< >>` Photoshop never writes. Photopea's
reader fails on it ("Cannot read properties of undefined (reading '_Resources')"), so a PSD
saved with a new type layer stopped opening there.

Write the block only when it carries something: the file already had one (round-trip its pen
positions and extras), or a layer uses the Optical auto-kern mode - the one mode EngineData's
AutoKerning cannot distinguish (#1348). A plain Metrics document now writes no block, as 0.3.0
did.

Tests: text needs_txt2_only_for_optical; io fresh_metrics_text_writes_no_txt2_block.
2026-10-12 00:18:14 -04:00
UnemarsandClaude Opus 5.5 6ecad20132 Content-Aware tools: faster, cancellable at once, same pixels (P21, P22, P49) (#3395)
* Content-Aware tools: faster, cancellable at once, same pixels

#2202 matched Photoshop's Content-Aware Fill but made the perf nightly slower (#2575): P21
Content-Aware Fill 10 MP 5x, P22 Spot Healing 2.4x, P49 cancel on 24 MP 4x and over its 200 ms
budget. This keeps every result bit for bit and takes the time back:

- The solver works over the hole's bounds grown by a patch: match field, costs, votes and the
  per-target tables are kept there (nothing outside changes or is matched), the vote computes
  each target's weight once and writes into a buffer instead of a Vec per pixel.
- Colour adaptation reuses each patch's own moments (target sums kept per EM step, source sums
  per level where they pay off), summed in the same order as before.
- Exact early outs: SSD per pixel instead of per row (callers only ask "better?"), and the onion
  peel's exhaustive search starts from where the filled neighbours took their sources and stops a
  source once it can only lose (first-of-equals kept).
- PatchMatch sweeps a band in wavefronts of blocks when there are fewer bands than threads: each
  cell sees the same neighbours and random numbers (SplitMix64 skip) as in raster order.
- Parallel pyramid, valid-source and target setup; no copy of the window when there are no
  variants (fill_owned / complete_masked_owned); the job reads the window in parallel bands and
  keeps only the selection's bounds for the apply step.
- Cancellation is checked per row everywhere, also in Auto sampling and in its max flow
  (maxflow_with / grid_cut_cancellable): a 24 MP job now stops in tens of milliseconds.
- algo::par: rayon on native, sequential on wasm.

Proof: the new `content_aware_bench` example hashes every tool's output (Fill with each sampling
and adaptation option, Edit > Fill, Delete and Fill, Spot Healing x3, Patch, Content-Aware Move,
8/16/32-bit, Gray/CMYK/Lab, and the benchmarks' 10 and 24 MP fills): 117 hashes identical to
main, also with 1, 2 and 4 threads. New tests compare the onion peel with a plain scan, full and
clipped patch distances and moments, the valid-source runs with the summed-area test, thread
counts 1/3/16, in-place and copied fills, and cancellation of Auto sampling and max flow.

perf_scenarios --cpu, Ryzen 7 5800X shared with other work (load 4-7), 3 interleaved rounds:
P21 p50 295-306 -> 167-171 ms (max 342-362 -> 180-184); P22 p50 67-68 -> 38-40 ms (max 69-71 ->
40-41); P49 max 378-389 -> 54-77 ms (p50 94-110 -> 47). retouch_bench: Content-Aware Move
-47..-64 %, Spot Healing Content-Aware -40..-61 %.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Content-Aware: faster Spot Healing dabs than main with #2805, same pixels

Against main with #2805 the branch was slower for small Spot Healing dabs (size 50:
+25-30 %, single-threaded +60 %): its onion peel walked all 49 offsets of every candidate patch,
checking each against the known pixels, and had no bound at the hole's edge.

- Onion peel: the target's known pixels are gathered once (#2805), the search starts from the
  filled neighbours' shifted sources or every 16th source at the hole's edge (#2805), the limit
  a candidate must stay under is a precomputed sum (no division per pixel), and candidates are
  summed four at a time in step: their sums are independent, so the processor overlaps them.
  Each is still given up only against the best from before the four, which can only improve.
- Vote: each target's weight and match centre are taken once; a pixel's patches are visited
  over the clipped offset range, with the channel count and the colour adaptation fixed at
  compile time. Same sums, same order.
- Valid sources: sliding counts per row and stacked runs per column instead of a 7-row check
  per pixel; the k-th source is found through per-row and per-64-column counts, so no level
  but the coarsest lists them all.
- `integral` stays out of line: inlined into `synthesize` it made Create Texture slower.

Same results: `content_aware_bench -- hash` (112 cases) and `-- hash-big` (the benchmarks' 10 and
24 MP fills) equal main's, also with 1 and 3 threads; the onion peel tests (#2805's and ours)
compare with the plain scan. `content_aware_bench bench spot` runs retouch_bench's Spot Healing
dabs; with `CAF_SPOT_TYPE` (comma-separated) other types, with page faults per dab.

Ryzen 7 5800X, shared (load 5-20), interleaved with main (3af4c46a), min of rounds,
retouch_bench, all threads / one thread:
Spot Healing Content-Aware 50: 49 -> 31 / 204 -> 117 ms; 100: 62 -> 35 / 164 -> 98 ms;
200: 118 -> 67 / 250 -> 158 ms; 300: 212 -> 119 / 411 -> 285 ms. Content-Aware Move -47..-62 %
(one thread -23..-45 %). Rows that don't run this code stay within noise, except Proximity
Match 200 after the Content-Aware dabs in the same process (+8..23 %): it takes ~5800 page
faults there against ~300 on main, because main's heavier dabs left more of the heap faulted
in; alone, or after Content-Aware dabs in `bench spot`, it is equal.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 00:11:10 -04:00
b14037083d Brush and Pencil: hold ~ to erase, and Clear mode (#3439) (#3465)
* Brush and Pencil: hold ~ to erase

Fixes #3439. Holding ~ (the backtick key) with the Brush or Pencil makes
the strokes started while it is held erase, with that tool's own brush,
as in Photoshop; releasing it paints again and the tool never changes.
The key is a Tools › Temporary binding (tools.temporary.erase), so it
can be rebound or removed in Keyboard Shortcuts.

* Brush and Pencil: Clear painting mode, used by ~

Photoshop's Clear joins the Brush's and Pencil's Mode menus, after
Dissolve: chosen there, every stroke erases with that tool's brush until
another mode is picked. Holding ~ now puts the stroke in Clear mode too,
and the Mode menu shows Clear while the key is held. History names the
tool rather than the Eraser. Agents get it as "mode": "clear" on
paint.stroke and paint.pencil.

* Keep the Smoothing doc comment on smoothing_field

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 00:02:42 -04:00
MrCloudy2andClaude Opus 5.5 7e5d2ce6da Warp presets: Photoshop's Bezier control grids instead of approximations (#3444)
The 15 warp presets (Edit > Transform > Warp, smart object warps, Warp
Text) were our own closed-form approximations, up to 70 px off
Photoshop's: Arc kept its centre fixed and pulled the ends in where
Photoshop keeps the inner edge's corners and raises the middle, Flag
waved the wrong way at a third of the height, Squeeze shrank instead of
pinching.

Measured on Photoshop 27.11 (scripted renders of a grid image whose
colours encode each pixel's position, 102 cases: every preset at bend
±50 and ±100, both distortions, vertical orientation, square, wide and
tall boxes), every preset is a single bicubic Bezier patch (fits within
0.5 px). Its control grid:
- Arc: each row a circular arc of b·π around one centre, the inner edge
  keeping its chord; Arch, Bulge, Arc Lower/Upper and Shell built from
  the same arcs. Circular in pixels on any box.
- Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist: control
  points moved linearly with the bend, in box units.
- Vertical distortion scales the top and bottom rows about their
  midpoints and blends the rows between; horizontal distortion scales
  each column. Vertical orientation swaps the axes and the two
  distortions.
All 102 cases land within 1 px (median) of Photoshop. On the corpus's
Photoshop-made warped text, warp-arc-50 now spans 340x85 at (-3, 3)
against Photoshop's 350x88 at (-7, 3), where it was 278x74 at (28, 62).

Presets also convert to custom meshes exactly now (to_mesh splits the
patch instead of fitting). Tests that pinned the old shapes now assert
Photoshop's; presets_match_photoshop checks measured points.

Still open: Warp Text warps over our line box, while Photoshop uses the
type layer's stored `bounds`; with those the five corpus warp files land
within a few px (arc, flag, bulge with distortion, twist, vertical rise).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:56:17 -04:00
David MannandClaude Opus 5.5 bea81c2f3d Save: show save progress as Saving NN% in the status bar and tab title (#3389) (#3485)
Background saves only ever reported 0 % ("Encoding", then "Writing"), because
photocraft_io::export produced all the bytes in one call: the status bar showed an
indeterminate bar and no percentage. Now:

- photocraft_io::export_with(doc, name, opts, &Interrupt) reports encoding progress
  (export keeps its signature and calls it with Interrupt::NONE, same bytes): PSD/PSB
  per layer record and per composite band, layered TIFF the same plus its encoder
  stages, .pcraft after its previews, flat formats once the composite is rendered.
  Cancelling stops the conversion early and returns IoError::Cancelled.
- photocraft_format::atomic_write_progress writes in 8 MiB chunks and reports the
  fraction written.
- The desktop save maps encoding to 0..85 % and writing to 85..99 %, 100 % once the
  file is flushed and renamed.
- The status bar reads "Saving 45%" for a save (file name on hover), and the
  document's tab title gets " - Saving 45%" in both tab strips, as in Photoshop.
  Percentages round down so 100 % only shows when done.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 23:49:53 -04:00
MrCloudy2andClaude Opus 5.5 3794ad471f Color Balance: Photoshop's per-channel levels and gamma curves (#3443)
Color Balance added a share of each slider weighted by the pixel's
lightness, then (Preserve Luminosity) rescaled to the old luma. On the
corpus's Photoshop-made file (photoshop/adjustments/rgb8/color-balance.psd,
all three tones moved, Preserve Luminosity on) that was up to 43 levels
off on 99% of the pixels.

Measured on Photoshop 27.11 (scripted: a gray ramp and 16 pure colours
through each slider at ±25, ±50 and ±100, with Preserve Luminosity on
and off, plus two and three tones and two sliders at once), every
slider is a levels-and-gamma curve on each channel:
- Preserve Luminosity off: midtones is gamma 2^-a on the slider's own
  channel; shadows gamma 2^(-a/2), after a black point at 0.39·|a| when
  lowered; highlights gamma 2^(-a/2), after a white point at 1 - 0.39·a
  when raised.
- Preserve Luminosity on: midtones gamma 2^(-a/2) on its channel and
  2^(a/2) on the other two; shadows and highlights go by the differences
  between a tone's sliders: a black point at 0.39·(max - own) and a
  white point at 1 - 0.39·(own - min) per channel.
- Tones apply highlights, shadows, then midtones.
All 88 measured cases match within 1 level, except midtones +100 below
about 28 of 255 (Photoshop flattens the steepest gamma near black, by
up to 9 levels). The corpus file is now within 4 levels (mean 1.4, was
20.9).

The GPU builds the same curves into per-channel tables and uses the
Levels/Curves kernel; the old Color Balance shader case is gone.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:45:58 -04:00
skylar taylor-barrick d380309338 CI: restore tests, catalogs, and web size gate (#3923)
* ci: restore test and Clippy gates

* ci: keep browser wasm below size gate

* CI: restore plural catalog and MCP resource test gates

Adapted the catalog, parser, and shared-cache test fixes from PhotoCraft PR #3821 by @bommerts. The source-key test, bundled-catalog test, and MCP resource test pass locally.

* CI: expect saved native document name in CLI test

The native save path intentionally writes the output filename into the reopened bundle. Keep the pre-save Run assertion and assert r.pcraft after reopening.

* CI: compare orientation round trips without saved filenames

Native saves encode the output filename in document name. Check each expected name, then compare the remaining document fields and pixels.
2026-10-11 21:37:09 -06:00
MrCloudy2andClaude Opus 5.5 9237a8bdde PSD gradients: read the interpolation method Photoshop writes; add Smooth (#3442)
Gradient fills and gradient effects read their interpolation method from
`gs99`, a key Photoshop doesn't write. Gradient fill layers saved by
Photoshop 27.11 store it as the enum `gradientsInterpolationMethod`
(Perc, Lnr , Smoo, Gcls), so every Perceptual, Linear or Smooth gradient
opened as Classic. It shows once the fill is re-rendered (any edit):
measured against Photoshop's own composite of the saved file, a
black→white Linear fill was up to 74 levels off and Perceptual 31; both
are now within 1. `gs99` stays as a fallback.

Smooth, Photoshop's default method since 2024 (also for Gradient Map),
had no model. Measured on Photoshop 27.11 (scripted gradients on three
colour pairs), it runs straight across each segment in Oklab with its
own easing, the same curve for every pair, and Smoothness doesn't
change it. The curve fits no simple formula, so it is a 17-point
measured table. Two-stop Smooth gradients are within 2 levels (Classic
was 31 to 83 off); with three or four stops within 11 to 25 (Classic 48
to 106), as Smooth also smooths across stops in a way not modelled yet.

Corpus: no file changes status; psd-tools effect-enums.psd (Smoo
gradient effects) goes from 7.10% to 6.13% differing pixels.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:33:46 -04:00
David MannandClaude Opus 5.5 39aa849b47 Raw: open monochrome LinearRaw DNGs (1 sample per pixel) (#3497) (#3503)
A one-sample LinearRaw DNG (VueScan film scans, Leica Monochrom) was
rejected as "LinearRaw data with 1 samples per pixel". The DNG decoder
now accepts it and develop renders it as neutral grey: the one sample
fills all three channels, white balance, colour matrices and camera
looks do not apply, and the output is R = G = B in 16-bit ProPhoto.
io skips the preview colour fit for such files.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 23:33:40 -04:00
Valentin KleinandClaude Opus 5.5 c20b81b251 Autosave: best-effort cleanup after commit; sweep orphaned recovery bundles (#3489) (#3502)
save_dir removed unreferenced objects with `remove_file(..)?` after the
manifest commit, so a vanished or locked file made a completed save
(autosave) report failure. The cleanup is now best-effort.

RecoveryStore::new sweeps recovery data nothing could load or clean up:
bundles without a sidecar (crash during a first autosave) and
*.pcsave.tmp files of other processes, only once untouched for 24 hours
so another instance's in-progress save is never touched.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:31:23 -04:00
4030096482 perf: Edit > Clear works tile by tile on large selections (#3463) (#3482)
Clear (and the background-colour fill it does on a Background layer)
read the whole selection bounds into one float buffer, looked the
selection up in its tile map once per pixel and wrote every tile back,
single-threaded. On an 8000x6000 layer that took about 2 s (3 s on the
Background). It now edits the encoded tiles in parallel, drops fully
cleared tiles, shares one solid tile for fully covered opaque fills and
copies a tile only when a pixel in it changes. Results match the old
pass exactly; 20-110 ms on the same documents.

Fixes #3463

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:29:25 -04:00
siraxeandJJ 80b8deab0f Brush cursor: shaped tips trace their outline (split from #2516) (#3467)
Add the brush-outline half of #2516 on its own, so review and merge stay
small; the last-brush persistence half follows separately.

- paint: new `outline` module traces the tip shape as closed rings in
  radius units -- a computed ellipse for round tips (rotated/flattened),
  marching squares over the bitmap for sampled/stored tips, at the full
  tip or the 50 % alpha contour. Traced rings stay finite and bounded for
  hostile sizes; tested for the half-coverage contour, blank tips and
  hostile input.
- ui-egui: new `brush_cursor` picks the traced outline when the tip has a
  shape and falls back to the plain circle otherwise; `tool_cursor` gained
  the `Outline` shape (bitmap rasterisation by segment splatting, painter
  fallback on non-Windows).
- The Normal Brush Tip circle now goes through `half_coverage_radius`
  (#2744) so outline and circle agree on the real 50 % contour.

Review fixes from #2516:
- Merged main; docs/scorecard.md is main's version and was verified with
  `cargo xtask scorecard --check` (regeneration changes nothing).
- Dropped the scratch benchmark `crates/paint/examples/tmp_outline_bench.rs`
  and the two `#[ignore = "temp bench"]` tests in tool_cursor/tests.rs.

Co-authored-by: JJ <[email protected]>
2026-10-11 23:23:29 -04:00
Fabian GhenofandClaude Opus 5.5 17855ab3d1 Commands check an explicit "layer" instead of whichever layer is active (#3466)
A command whose params document "layer":id acts on that layer, but its
availability check looked at the active layer. With another layer selected,
`layer.smartObjects.replaceContents {"layer": N, "path": …}` failed with "the
active layer is a Group layer, not a smart object", although layer N is a
smart object; likewise Rasterize, Export Contents, Merge Down and the rest.
Scripts, recorded actions and MCP agents hit this whenever the selection
differs from the layer they name.

Two allow-lists covered part of it: #466 (smart filters, Edit Contents,
Convert to Layers) and #2457 (hide, show, rename with nothing selected). The
#466 check now applies to every command that documents "layer":id (210
today; the old list is a subset): the precondition runs with the named layer
as the active one, then the active layer is restored. A name that isn't a
layer of the active document falls back to the usual check, so bad ids still
fail as before. It moves from smart_cmds to Session, as it isn't specific to
smart objects any more.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:17:45 -04:00
Yousuf 'Jay' Philips afac33f8d7 Fix GIMP hole heading name and location (#3517) 2026-10-11 23:12:04 -04:00
DomiLuebbenandClaude Sonnet 5.5 5d8e5c138b Copy takes every selected layer, Paste recreates them (#2478) (#3394)
* fix(clipboard): Copy takes every selected layer, Paste recreates them (#2478)

With several layers selected and no pixel selection, Edit > Copy only
copied the active layer's pixels, so Paste made one layer. Photoshop
copies all selected layers and pastes them back as layers.

Copy now puts the selected layers (those no selected group already
carries, bottom-to-top) on the session clipboard as whole layers, which
Paste already knew how to recreate (paste_layers). The OS clipboard still
gets the merged pixels. A pixel selection keeps the old behaviour (the
active layer's selected pixels), and the Background in the set pastes as
an ordinary, unlocked layer, as Duplicate Layer makes it.

Tests: several layers round-trip in one undo step, a pixel selection
still copies one layer, and the Background case.

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

* test(clipboard): several copied layers paste into another document (#2478)

Copy two layers in one document, paste them into a 16-bit and a 32-bit
document: both arrive in order, converted to the target depth with their
own pixels, as one undo step, and the source document is untouched.

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-11 23:08:12 -04:00
prekabrekiandprekabreki 9829d7ea37 Duplicate Layer from the menu opens a dialog for the copy's name and destination document (#3348) (#3440)
Co-authored-by: prekabreki <[email protected]>
2026-10-11 23:02:59 -04:00
f0322f74e8 fix(selection): scope Color Range to the active selection (#3313) (#3366)
* fix(selection): keep Color Range inside active selection and reject out-of-bounds samples

* fix(ui): scope Color Range dialog and canvas previews to the active selection

* test(selection): cover Color Range scoping, sampling, invert, Add and undo

* test(ui): ensure Color Range preview and pipette honor existing selection

* test(selection): avoid Debug bound when asserting prepared-query error

* test(selection): validate malformed Color Range sample orders without selection interference

* Color Range: format, and scope the OK undo test's prior selection (#3313)

The existing ok_applies_one_undo_step test picked a prior selection that
excluded the red it then expected Color Range to select; with Color Range
scoped to the active selection it now selects the red inside it.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 22:52:08 -04:00
kotulskyandkotulsky 51c671c5a0 fix(selection): load shape and path thumbnails with Ctrl-click (#3449)
Load Work Path, saved path and layer path thumbnails as selections on
Ctrl/Cmd-click. Support Shift to add, Alt to subtract and Shift+Alt to
intersect, without changing the selected path or treating the click as
Save Path. Clicking the row name keeps its existing selection behavior.

Render uncached shape transparency instead of selecting the entire canvas.
Add regression tests for thumbnail hit testing, active-layer preservation,
selection combinations, undo/redo and 8/16/32-bit uncached shapes.

Co-authored-by: kotulsky <[email protected]>
2026-10-11 22:51:42 -04:00
Mr-Pearce 8f102b0222 feat(api): add file path to document.inspect (#3398) 2026-10-11 22:34:03 -04:00
piero-83 23417a60b6 fix(engine): create new layer masks at the document depth (#3359)
* fix(engine): use document depth for new pixel masks

* test(engine): check mask depth and selection coverage

* test(engine): preserve mask edits across native and PSD round trips
2026-10-11 22:30:26 -04:00
Thomas Han fb36a3fdc4 fix(presets): never overwrite preset or actions files that failed to load (#3346)
* fix(presets): never overwrite a brush group file that failed to load

A group file skipped at load (corrupt, newer version, missing tips) was replaced on the next sync with only the new presets. Remember skipped files and leave them unchanged with a warning.

* fix(presets): never overwrite an actions.json that failed to load

A corrupt, oversized or newer-version actions.json loaded as an empty list, and the next action change rewrote it with only the new actions. Remember the skip and leave the file unchanged with a warning.
2026-10-11 22:25:31 -04:00
Valentin KleinandClaude Opus 5.5 60ef9d836f Format: reopening a .pcraft shares deduplicated tiles again (#3315) (#3339)
Saves store each distinct tile once (a solid fill shares one Arc<Tile>), but
the loader fetched, decompressed and charged max_total_bytes once per tile
reference and gave every coordinate its own copy. A solid canvas above
~65536² (8-bit) failed to reopen with "bundle expands beyond 17179869184
bytes", and smaller ones lost their sharing (a solid 30000² 32-bit document
went from about 1 MB to about 14 GB in memory).

The loader now keeps the decoded tiles of the current load by sample type and
content hash: each distinct tile is decoded, hash-checked and charged against
the budget once, and every further reference gets the same copy-on-write Arc.
The budget still bounds distinct decoded bytes; extra references cost a map
lookup and an Arc clone each, bounded by max_manifest_bytes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 22:21:45 -04:00
alexanderalber 980cf05be4 Group effects follow their children's effects, not just their pixels (#3274) (#3326)
A group's effect region and the empty-tile check were computed from
layer_bounds, which for a group is the union of its children's pixels.
A child's outside stroke reaching past that region was cut off on the
canvas (GPU) and in tiled CPU renders, and the group's drop shadow
followed the cut shape.

New photocraft_compose::effect_source_bounds: for a group, the children
grown by their own effect reach. Used by compose::effect_region,
compose::empty_in and gpu::bounds::effect_region.

generated with Claude Code
2026-10-11 22:20:23 -04:00
538c5020b2 New Document: a real Recent category of recently created documents (#3131) (#3135)
File › New › Recent was a hard-coded pair of presets plus Clipboard. Each
document created with the dialog now records its settings (size,
resolution, mode, depth, background, display units) through the new
document.recent.add command: newest first, equal entries collapsed, at most
20, persisted with the presets (a damaged entry is dropped on load, the rest
kept). The Recent tab lists Clipboard, those documents, then the defaults
they don't cover; a click restores every setting, a double-click creates,
and the grid scrolls past three rows. document.recent.list exposes the list
to agents; creation stays in file.new. Closes scorecard row UI-217-12.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 22:17:17 -04:00
KnottyDyesandClaude Opus 5.5 48c1721470 MCP: command_batch says it is not atomic, and the docs explain recovery (#3338)
Agents read the tool description, not docs/mcp.md, and the command_batch
description only said it stops at the first error. It now also says the
steps run in order and that earlier successful steps stay applied: there
is no rollback. docs/mcp.md gives the stop_on_error default and a recovery
recipe: inspect with doc_inspect, undo with edit.undo once per history
entry (undo does not reverse files written to disk), or experiment on a
copy made with image.duplicate or history.newDocument. A test checks the
listed description.


Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 22:10:12 -04:00
myangaa1 27222cf4b7 Let [ shrink the brush to 1 px (#3229) (#3332)
`[` steps the brush size down by 25 %, rounded: from 2 px that is 1.6,
which rounds back to 2, so the key never reached 1 px and pixel artists
had to type the size. `]` already grows by at least a pixel; `[` now
shrinks by at least a pixel the same way, still never below 1 px.
2026-10-11 22:05:50 -04:00
0e2c34af3e Vanishing Point: on-canvas plane editor with Create/Edit Plane, grid and paste (#3122) (#3133)
Filter > Vanishing Point now opens a dialog mode on the canvas instead of
the generic parameter dialog:

- Create Plane (C): four clicks, in any order, make a plane (corners are
  normalised clockwise from the top-left, the order paste expects).
- Edit Plane (V): drag corner nodes, or edge nodes to slide an edge along
  the plane's own perspective; click a plane to select it, Delete removes it.
- The plane grid is drawn at the Grid Size, in the accent colour when the
  plane is valid, warning yellow when its perspective is doubtful and red
  (outline only) when the quad isn't convex (VpPlane::check).
- With an image on the clipboard the selected plane previews it pasted in
  perspective; drag to move it, Width sets its size, New Layer renders it
  on a new layer. OK / Enter runs filter.vanishingPoint with the planes and
  the paste as one history step; the control channel drives the same
  session through {"ui": {...}}.

The engine now rejects a drawn plane whose corners don't form a convex
quad (bow-tie, dented) instead of warping along it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 22:02:04 -04:00
Valentin KleinandClaude Opus 5.5 638b5cc4e6 Clone Source: W/H link and overlay blend modes (#3298) (#3310)
The Clone Source panel gets Photoshop's W/H chain (Maintain aspect
ratio): with it on, a new W or H scales the other by the same factor.
cloneSource.set takes "linked" and cloneSource.list reports it.

The overlay's Darken, Lighten and Difference modes were stored but
never drawn. draw_clone_preview now blends the source with the
composite under the brush (clone_source::overlay_blend), so Difference
goes black where source and image line up.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 21:40:37 -04:00
7fd53f0e3f Puppet Warp: the mesh follows the object outline (#3105) (#3117)
The mesh was every square grid cell touching the layer's opaque pixels:
a staircase up to a cell outside the shape, and shapes closer than about
a cell shared cells, so pinning one dragged the other along.

build_mesh now traces the outline of the opaque region grown (or shrunk)
by Expansion from a signed distance field, simplifies it (Douglas-Peucker,
corners kept), splits its sides to the Density spacing, fills the inside
with a triangular lattice and triangulates the points (Bowyer-Watson
Delaunay, outline sides recovered by midpoint splits), keeping the
triangles inside the outline. Separate shapes get separate meshes, so an
unpinned one stays put, as in Photoshop. Vertices are numbered in reverse
Cuthill-McKee order to keep the solver's banded factor narrow, and the
density's spacing counts the grown region so a large Expansion on a small
layer no longer builds tens of thousands of vertices.

Scorecard TOOL-214-6 done; docs/gaps.md G7 updated.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 21:40:06 -04:00
9a9d23d258 Levels: black, gray and white point eyedroppers (#3116) (#3128)
* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)

The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.

Scorecard UI-217-15 is done.

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

* Levels: black, gray and white point eyedroppers (#3116)

Image > Adjustments > Levels and a Levels adjustment layer's Properties
panel get Photoshop's Set Black/Neutral Gray/White Point eyedroppers.
Black/white move each channel's input black/white to the sample, gray
sets each channel's midtone gamma so the sample turns neutral at its
luma (Grayscale: composite input, no gray). The engine takes the same
`eyedropper` param as Curves on image.adjustments.levels,
layer.newAdjustmentLayer.levels and layer.setAdjustment.

On a smart object, image.adjustments.levels/curves with `eyedropper`
now record the resolved adjustment as the smart filter instead of the
raw sample, which re-rendered as an identity (#3127).

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 21:27:08 -04:00
Nigel Ihza Firdausy 6f6d2ea467 fix(format): save the target file name in native bundle manifests (#3312) (#3319) 2026-10-11 21:26:13 -04:00
Valentin KleinandClaude Opus 5.5 6e9ba6511d Format: admit the declared .pcraft canvas before loading it (#2935) (#3295)
* Format: admit the declared .pcraft canvas before loading it (#2935)

A .pcraft manifest's canvas size was copied into the Document unchecked, so
a few bytes of JSON could declare a 300000x300000 (or u32::MAX) canvas that
the CPU reduction, thumbnails and display textures then work against in
proportion to its area.

The loader now refuses an empty canvas, a side above 300000 (Photoshop's PSB
limit, as for PSD and file.new) and an area above 4 gigapixels, right after
reading the manifest and before any tile or blob is decoded. Both limits are
LoadOptions fields so trusted callers can change them.

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

* Format: admit every canvas the app can create on .pcraft load (#2935)

The default pixel budget was 2^32 (65536 x 65536), but file.new, Canvas
Size, Image Size with Resample off and PSB import all reach 300000 x
300000, so a document a user could create and save would not reopen.
The default is now the full 300000^2 PSB canvas; the side limit still
refuses anything larger, and callers handling untrusted files can lower
either budget. Loading stays proportional to the file: layers keep only
the tiles the bundle holds, so a huge declared canvas allocates nothing
for its area.

A compile-time assert in document_preset_cmds ties file.new's
MAX_DIMENSION to the loader's defaults.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 21:22:11 -04:00
ee9c9ef159 Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110) (#3115)
* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)

The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.

Scorecard UI-217-15 is done.

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 21:16:28 -04:00
Valentin KleinandClaude Opus 5.5 59820ecafb Codecs: decode PNM straight into the budgeted output buffer (#2938) (#3291)
Limits::max_alloc budgets the decoded pixel buffer, but the PNM decoder
collected one u32 per sample first (4x an 8-bit image, plus a u16 copy
for 16-bit) and PFM built a Vec<f32> beside the output bytes. Samples are
now rescaled and written straight into the native-endian output buffer
(reserved fallibly), so the only header-sized allocation is the one
Limits::check already charged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 21:16:07 -04:00
Sir Starch 04e7e7d7f8 fix(io): smooth PSD gradients no longer overshoot their stops (#3028) (#3285)
The Catmull-Rom tangents used for Smoothness are derived from the
neighbouring stops, so a segment between two equal-coloured stops (a
flat plateau) picked up a non-zero tangent and dipped below its own
colour. In the Gradient Map from #3028 the 27%-53% plateau (#1c1c1c)
rendered about 19/255 too dark.

Bound each interpolated channel to the range of the segment's two end
colours. Smoothness still bends the ramp, but never invents a colour
outside the segment, so plateaus stay exactly flat.

The regression test uses the four stop positions and grey values from
the issue (synthetic, no artwork) and fails without the clamp.
2026-10-11 21:14:28 -04:00
aba9b2b4ef Curves: pencil mode, Smooth and Edit points (#3088) (#3108)
* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 21:05:58 -04:00
1051265f75 Type: fall back to installed Khmer, Lao and Burmese fonts (#218) (#3259)
Text in these scripts drew .notdef boxes although the OS had a font for
them. Detection was never the problem: the system font scan loads them,
and asking for one by name shapes correctly. The fallback stack is a
list of family names intersected with the installed fonts, and it had no
entry for any of the three, so an installed Khmer, Lao or Burmese font
could not be reached from a Latin-only face.

- photocraft-text: KHMER_FAMILIES, LAO_FAMILIES and MYANMAR_FAMILIES join
  fallback_candidates next to THAI_FAMILIES; the web build's served-font
  scripts gain Khmr, Laoo and Mymr (with their ranges and languages).
- photocraft-ui-egui: the UI's lazy loader had one non-CJK slot (Thai).
  It becomes a LazyScript slot table, so the three scripts are data
  rather than a third and fourth copy of the same state machine.
- Tests assert detection and selection separately, so a future failure
  names its layer, and skip when a machine lacks the script's font.

Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 21:03:22 -04:00
b0e555c60f automation: export layers, data sets and CSVs through the granted roots (#3216) (#3230)
* automation: doc.place reads a file through the read root (#2810)

file.placeEmbedded takes an ambient path, so automation refuses it and an
agent could not place a file into the open document at all. doc.place
resolves the path beneath the read root the way doc.open does and hands the
bytes it already read to the public file_cmds::place_bytes, so the existing
cap-std rules stay the only path rules: no new scope logic, and the ambient
command keeps its refusal. Trusted-local callers still run the command.

* automation: export layers, data sets and CSVs through the roots (#3216)

file.export.layersToFiles and file.export.dataSetsAsFiles take an ambient
dir, file.import.variableDataSets an ambient path, so all three are refused
in automation even for files inside the granted roots, and an agent could
not export a single file or a data set at all.

Both halves already existed: the engine writes bytes it has just encoded
(save_doc is encode + write_file, and the data-set exporter encodes with
photocraft_io::export before writing), and the automation side already
holds a write capability — AuthorizedWorkspace::export_sequence creates the
directory, refuses to overwrite, accepts only single-component names and
removes every file it created when a step fails, which is what Render Video
uses. So each command body moves behind the same sink callback
render_video_with takes: the command keeps writing the bytes it is handed
to its dir, the new methods write the very same bytes through the root, and
the two implementations cannot drift.

---------

Co-authored-by: switch87 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 21:01:26 -04:00
Vikram Bhaskaran 2af2cec6ea Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267) 2026-10-11 20:50:49 -04:00
6de37ce7f0 Interface: an Enable Animations preference (#3273) (#3276)
Preferences › Interface › Enable animations (interface.enableAnimations, on by
default). Off, prefs_ui::sync_animations sets egui's animation_time to 0 and
scroll_animation to none in both the dark and light styles, so menus, popups
and collapsing sections change at once and scroll areas jump. It is re-applied
each frame, so a theme change keeps it. Translated in all 16 catalogs; the
scorecard row UI-217-10 goes to partial.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 20:34:58 -04:00
6d08ae6400 Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119) (#3125)
* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)

- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
  the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
  tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
  Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
  "Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
  bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
  on main via #1961, #965).

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

* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)

On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.

shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.

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

* Shortcuts: Ö / # are the brush keys on a German layout (#3109)

Photoshop's German shortcuts step the brush size with Ö / # and the hardness
with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries
Alt on the Mac and Windows, which the shortcut match rejects). egui can't name
ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+#
types ' and arrived as Quote.

shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and
Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of
shortcuts::handle (unless a text field has focus), so Liquify's and the
Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took
from the physical key or from that very text change, and never digits.

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

* Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113)

A drag that started on a layer's mask thumbnail dragged the whole row, and
dropping it on the Trash deleted the layer. Now a drag from the pixel mask
thumbnail (where it was drawn last frame) drags the mask: it shows a "Layer
Mask" ghost and starts neither a layer reorder nor a cross-document transfer.
Dropped on the Trash it opens Photoshop's prompt "Apply mask to layer before
removing?" with Apply (layer.layerMask.apply, greyed out where it can't run),
Delete (layer.layerMask.delete) and Cancel. Clicking the Trash still deletes
the targeted mask at once (#2197).

The prompt (mask_trash_prompt.rs) is an ordinary dialog: ui.dialog.confirm
applies, ui.dialog.set {"choice": "delete"} + confirm deletes, cancel keeps.
Two new strings in all 16 catalogs.

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

* Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119)

A drag that started on a row's vector mask thumbnail picked up the whole
layer row, so dropping it on the Trash deleted the layer. The mask drag
from #3113 now covers both thumbnails: a vector mask dropped on the Trash
opens Photoshop's "Delete the vector mask?" (OK / Cancel), and OK runs
layer.vectorMask.delete. Clicks on the thumbnail are unchanged.

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

* Brush keys: layout table plus bracket-position fallback for every layout (#3109)

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

* layout_keys: drop unused field, fmt

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 20:29:36 -04:00
Valentin KleinandClaude Opus 5.5 c450e755ae PNG export: Indexed Color keeps the document resolution (#3047) (#3271)
The palette PNG encoder never wrote a pHYs chunk, so an Indexed Color
document saved as PNG reopened at 72 ppi while the RGB path kept its
resolution. encode_png_indexed now takes an optional dpi and the flat
exporter passes the document resolution under the same embed_metadata
rule as the ordinary PNG encoder. Save for Web PNG-8 is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 20:12:44 -04:00
872d3df2c5 Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113) (#3118)
* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)

- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
  the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
  tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
  Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
  "Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
  bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
  on main via #1961, #965).

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

* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)

On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.

shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.

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

* Shortcuts: Ö / # are the brush keys on a German layout (#3109)

Photoshop's German shortcuts step the brush size with Ö / # and the hardness
with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries
Alt on the Mac and Windows, which the shortcut match rejects). egui can't name
ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+#
types ' and arrived as Quote.

shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and
Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of
shortcuts::handle (unless a text field has focus), so Liquify's and the
Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took
from the physical key or from that very text change, and never digits.

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

* Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113)

A drag that started on a layer's mask thumbnail dragged the whole row, and
dropping it on the Trash deleted the layer. Now a drag from the pixel mask
thumbnail (where it was drawn last frame) drags the mask: it shows a "Layer
Mask" ghost and starts neither a layer reorder nor a cross-document transfer.
Dropped on the Trash it opens Photoshop's prompt "Apply mask to layer before
removing?" with Apply (layer.layerMask.apply, greyed out where it can't run),
Delete (layer.layerMask.delete) and Cancel. Clicking the Trash still deletes
the targeted mask at once (#2197).

The prompt (mask_trash_prompt.rs) is an ordinary dialog: ui.dialog.confirm
applies, ui.dialog.set {"choice": "delete"} + confirm deletes, cancel keeps.
Two new strings in all 16 catalogs.

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

* Brush keys: layout table plus bracket-position fallback for every layout (#3109)

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

* layout_keys: drop unused field, fmt

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 20:11:43 -04:00
Gert Pellin - GPE 5a907b110e test(paint): pin the retouch dab footprint to the Full Size ring (#1761) (#3258)
A dab never paints past radius + 0.5 px from its centre, at any hardness:
the half pixel is the anti-aliased edge of dab_coverage. A hard tip does
reach the ring; softer tips fade out inside it, which is the falloff
working as intended.
2026-10-11 20:02:35 -04:00
Dan HiornsandClaude Opus 5.5 599aea0086 Liquify: the brush fades to nothing at its edge at every Density (#2606) (#3241)
A dab's strength was (1 - density) + density * (1 - t^2)^2, so at the
brush rim it stopped at 1 - Density instead of 0: half strength at the
default Density 50, a hard-edged disc at 0. Forward Warp then moved the
pixels just inside the brush and left those just outside, tearing the
image along every dab's circle; with a dab every fifth of the radius
the tears stacked into the sawtooth edges and stripes in #2606. Density
was also backwards: 0 gave the strongest, flattest brush.

`falloff` now blends (1 - t^2)^3 (Density 0: the effect in the centre,
feathered) toward (1 - t^4)^2 (Density 100: most of the brush), both 1
at the centre and 0 with a flat slope at the rim, as Photoshop's
Density reads. Every Liquify brush tool and Freeze/Thaw use it.

Replaying saved Liquify strokes (the command, smart filters) now gives
the smooth result for the same strokes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 20:01:39 -04:00
f1059f1885 Shortcuts: layout-independent brush keys, Photoshop's German keys plus a fallback for all layouts (#3109) (#3112)
* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)

- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
  the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
  tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
  Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
  "Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
  bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
  on main via #1961, #965).

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

* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)

On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.

shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.

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

* Shortcuts: Ö / # are the brush keys on a German layout (#3109)

Photoshop's German shortcuts step the brush size with Ö / # and the hardness
with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries
Alt on the Mac and Windows, which the shortcut match rejects). egui can't name
ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+#
types ' and arrived as Quote.

shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and
Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of
shortcuts::handle (unless a text field has focus), so Liquify's and the
Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took
from the physical key or from that very text change, and never digits.

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

* Brush keys: layout table plus bracket-position fallback for every layout (#3109)

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

* layout_keys: drop unused field, fmt

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

* Drop shortcuts::layout_symbols left by the merge: layout_keys::apply replaces it

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:55:11 -04:00
14733b5223 Moving type keeps its rendering: Artboards to Files no longer drops PSD text (#3218)
translate_layer re-rendered a type layer on every whole-pixel move. For type
from a PSD that replaced Photoshop's rendering with PhotoCraft's: paragraphs
re-wrapped, and text in a font that isn't installed vanished. Artboards to
Files moves each board to the origin this way, so a Steam store template's
"How to use" artboard lost its "Hello!" title. The Move tool, nudges, layer
comps and moving artboards went through the same path.

A whole-pixel move now shifts the rendered pixels, as smart objects and the
Move tool's live preview already do, and moves the TySh position with
photocraft_text::psd::translate_tysh: only the transform's translation
bytes change, so the rest of Photoshop's type data stays byte for byte. Type
with nothing rendered yet is still rendered.

On that template, 17 of 20 artboards now match Photoshop's own composite
exactly (the "How to use" board went from a mean difference of 4.5 to 0.02);
the remaining differences are shape layers (same cause, separate path) and
pre-existing 1-level rounding on two smart objects.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:46:29 -04:00
4ab61338ca Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102) (#3106)
* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)

- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
  the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
  tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
  Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
  "Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
  bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
  on main via #1961, #965).

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

* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)

On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.

shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:45:42 -04:00
886372812d automation: doc.place reads a file through the read root (#2810) (#3215)
file.placeEmbedded takes an ambient path, so automation refuses it and an
agent could not place a file into the open document at all. doc.place
resolves the path beneath the read root the way doc.open does and hands the
bytes it already read to the public file_cmds::place_bytes, so the existing
cap-std rules stay the only path rules: no new scope logic, and the ambient
command keeps its refusal. Trusted-local callers still run the command.

Co-authored-by: switch87 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:33:29 -04:00
42ee0d9675 Content-Aware Scale: transform box with live preview (#3096) (#3103)
* Content-Aware Scale: transform box with live preview (#3096)

Edit > Content-Aware Scale opened a parameter dialog. Photoshop has no
dialog: it puts a scale-only transform box on the layer (or the
selection's bounds). The menu now opens that box.

- The box reuses Free Transform. Corner and edge drags scale and an
  inside drag moves; dragging outside does not rotate, Cmd does not
  skew or distort, and the Warp switch and the Transform modes are off.
- The options bar shows X/Y and W/H, then Amount, Protect (an alpha
  channel) and Protect skin tones.
- The live preview runs the command's own carving
  (ContentAwareScalePreview) on a 384 px copy on a worker thread. A
  run for a box that has changed since is cancelled after 150 ms, and
  the latest result is drawn through the box.
- Enter runs edit.contentAwareScale with the box's size and its new
  x/y params as one history step; Esc cancels.
- With params the command still runs directly.

Scorecard TOOL-214-7: done.

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

* Content-Aware Move's transform box: set the new content_aware field

#3095 (Transform On Drop) builds a TransformSession by hand; this PR adds
content_aware to it.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:33:15 -04:00
Dan HiornsandClaude Opus 5.5 c18ee8c88b Tests: one GPU harness at a time in every GPU test binary (WARP crash) (#3212)
On Windows, GPU UI test binaries crashed with STATUS_ACCESS_VIOLATION
whenever two of their tests ran at once: arbitrary_rotation_preview 3
runs in 4 on main, and color_managed_canvas, crop_default_mode,
drag_preview_canvas and full_res_preview at random. egui_kittest
prefers a CPU adapter, which on Windows is WARP over DX12, and a dump
puts the fault inside d3d10warp.dll with two WARP devices rendering on
parallel test threads. Bare egui harnesses with no PhotoCraft code
crash the same way, and Vulkan or GL never does. CI's Windows runners
have no GPU, so they run on WARP too.

Three binaries already held a per-binary lock for the same class of
driver bug (Mesa llvmpipe and RADV, #194; libvulkan with NVIDIA and
lavapipe). That lock now lives once in photocraft-testkit
(`gpu_lock`, with the reasons) and every test in the twelve GPU test
binaries without it takes it on its first line. Under nextest each
test is its own process and the lock is free.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 19:27:26 -04:00
imSlicedBread 92baf43103 Preserve whitespace in OpenRaster layer names (#3207) 2026-10-11 19:17:06 -04:00
aa1febaffb Keep fitted Camera Raw colour during tuning and crop Nikon D5100 borders (#3201)
* raw: fit non-DNG colour to the camera's embedded JPEG

* raw: preserve fitted Camera Raw look and crop D5100 borders

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 19:12:19 -04:00
d706acdde9 Numeric fields: typed units, scrubby labels, double-click reset (#3099) (#3100)
* Numeric fields: typed units, scrubby labels, double-click reset (#3099)

- Value fields convert a unit typed after a number into the field's unit
  (1in, 2.54cm, 72pt, 1pica/pc, 10px, 50%) at the active document's
  resolution; Image Size and New Document use their own resolution. A
  unit that can't convert, or bad arithmetic, keeps the old value.
- widgets::scrub_label: dragging a label scrubs the value field right
  after it on its row (a step a point, Shift x10, Alt a tenth as fast),
  with a horizontal-resize cursor. Used by options-bar labels, slider
  rows and the Character panel icons. A slider row reports the scrub as
  one drag, so adjustment edits coalesce into one history step.
- Adjustment slider rows with a known default and filter dialog sliders
  reset on double-click, like Color Balance already did.

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

* Scrubby labels: drag-only, not focusable, so Tab skips them

Sense::drag() is focusable in egui 0.36, so every scrubby label became an
extra Tab stop and tab_focus_reveals_the_final_narrow_tool_option could no
longer reach the last tool option.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:09:08 -04:00
Valentin KleinandClaude Opus 5.5 661ba806d1 Content-Aware Move: Transform On Drop (#3071) (#3095)
* Content-Aware Move: Transform On Drop (#3071)

The Content-Aware Move tool gains Photoshop's Transform On Drop checkbox. With it on, the drop
puts a transform box on the moved patch (the Free Transform box's handles, limited to move,
scale and rotate); Enter, the check button or a double-click computes the content-aware blend
with the transformed patch, Esc or the cancel button drops the whole move. The document is
untouched until the commit, so the move stays one History step.

Engine: paint.contentAwareMove takes scaleX / scaleY (%, 1..10000, negative flips) and rotate
(degrees, clockwise) about the selection's centre before the offset, validated (Err, not a
panic, for non-finite, out-of-range or off-canvas placements). The content and the selection's
shape are resampled through the inverse map on float samples (any depth); the selection is
transformed with the content. A plain move keeps the old, exact code path.

UI: cam_transform.rs holds the box (UiState.cam_transform, view state); the option is remembered
in ToolOptions and settable with ui.set camTransformOnDrop; ui.inspect reports camTransform.

Scorecard TOOL-213-4 stays partial: no live preview of the blended result yet.

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

* Tests: keep Transform On Drop cases in the Content-Aware Move test, not Patch (#3071)

paint.patch takes no scaleX/scaleY/rotate, so those cases (which only
paint.contentAwareMove rejects) failed patch_fails_gracefully. They are
already covered by content_aware_move_fails_gracefully.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 18:44:10 -04:00
Valentin KleinandClaude Opus 5.5 8584e5dbf8 Actions: Last Filter is recorded as the filter it repeated (#3089) (#3091)
filter.lastFilter was journaled as itself, so a recorded Ctrl+F step
looked up the playing session's last filter: it failed in a new session
("no filter applied yet") and applied whatever filter ran last otherwise.
dispatch now runs Last Filter as the filter it repeats, so that filter is
journaled, recorded, faded and run as a job, as Transform Again records
its transform (#1440).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 18:28:51 -04:00
Scott C Hughes 4e334b479b test(gradient): guard the on-canvas midpoint diamond drag (#3172) (#3512)
The midpoint diamond's hit-test and drag were untested, so a regression there would go
unnoticed. Add a repro that drags the diamond and asserts the midpoint follows (and no stop
is added). The path works on current main; this is coverage, not a behaviour change.
2026-10-11 18:20:26 -04:00
Valentin KleinandClaude Opus 5.5 f0d3156ad6 Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085) (#3087)
* Tools: Freeform Pen and Curvature Pen (#1054)

The Pen flyout (P / Shift+P) now holds Pen, Freeform Pen and Curvature Pen.

Freeform Pen: a drag draws freehand; on release the trace is fitted with
cubic Beziers within the options bar's Curve Fit (default 2 px) by
photocraft_vector::trace::fit_trace (Schneider, "An Algorithm for
Automatically Fitting Digitized Curves", Graphics Gems 1990, the fitting
Make Work Path already uses), keeping turns sharper than 60 degrees as
corners. Releasing on the start point closes the path.

Curvature Pen: each click adds a smooth point and the path runs through
every point (Catmull-Rom style handles from
photocraft_vector::edit::curvature_knots); dragging a point moves it,
double-clicking toggles corner/smooth, clicking the first point closes,
Enter/Esc finish and Backspace removes the last point, as with the Pen.

Both commit through the Pen's path: a shape layer in Shape mode, else the
targeted vector mask, else the work path, as one History step.

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

* Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085)

The Freeform Pen now draws through lasso_ui, as the Lasso does: Alt held at
the release keeps the path open, each Alt-click adds a straight segment,
releasing Alt with the button down goes on freehand, with it up finishes
the path; clicking the start point closes it. freeform_finish fits each
freehand run on its own and joins the runs with straight segments between
corner anchors. Cmd/Ctrl with the Freeform or Curvature Pen is the
temporary Direct Selection, as with the Pen (#790); it finishes a path in
progress first.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 18:16:39 -04:00
Scott C Hughes 3455234537 fix(crop): the crop box's own Undo/Redo (#3145) (#3510)
While a crop is pending, Edit > Undo/Redo now steps the *box*, not the document, as
Photoshop does. The frame's history lives on CropState; crop_ui::is_enabled/intercept route
the commands for every caller (menu, shortcut, control channel, MCP), and committing or
cancelling a crop clears it.

The ratio-field input is PR #3175. The handle-drag jitter is left as-is on main (whose
live crop-handle snapping, snap_ui::live_crop_handle, deliberately redesigns that path).

Test: edit_undo_steps_the_crop_box_not_the_document. cargo test -p photocraft-ui-egui --lib crop: 101 pass.
2026-10-11 18:10:38 -04:00
optimalbyteeandoptimalbytee da3f5ffd54 Optimise computed brush coverage and native raster row writes (#3098)
* Speed up raster writes with contiguous sample rows

* Specialise coverage rasterisation for computed brush tips

---------

Co-authored-by: optimalbytee <[email protected]>
2026-10-11 18:08:44 -04:00
0142b24fcb Artboards: a board moved past the canvas origin grows the canvas left/up (#3070) (#3073)
fit_canvas only grew the canvas to the boards' right/bottom edges, so a
board moved left of or above the origin (Move tool drag, layer.translate,
layer.artboard.set with negative x/y) stayed partly off-canvas. It now
shifts the whole document (boards, contents, guides, paths...) right/down
by the overhang via image_cmds::translate_doc and grows the canvas, inside
the move's undo step, like Photoshop's artboard documents.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 18:05:58 -04:00
2b2bb45870 Color Picker: no bigger than Photoshop's (532 × 376 points, was 682 × 437), buttons sized to their labels (#3500)
* Color Picker: no bigger than Photoshop's (532 x 376 points, was 682 x 437)

A 200-point colour field, 42-point component fields, buttons at their
width rather than stretched, smaller new/current swatches and tighter
spacing. Every control stays; the 16-point window padding stays.

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

* Color Picker: actions as wide as the longest label, not the column (133 points, was 160)

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 18:03:29 -04:00
4fe6dc6764 Layers: the New Layer dialog (Layer › New › Layer…, ⇧⌘N) (#3104)
* Layers: clip groups follow New Layer, Arrange, Reorder and Delete (#2203)

Adding, moving or deleting layers never touched Layer::clipped, so a
new layer above a clipping base split its group and became the base of
the layers clipped above it, and moving or deleting a base left its
clipped layers clipping to whatever ended up below them.

The new clip_group module applies Photoshop's rules, measured on #2203:
a layer added or moved joins the clip group it lands in; a clipped
layer moved stays clipped while it still sits on a clip group; the
layers clipped to a base that moves away or is deleted are released;
and a clipped run left with nothing below it is released. They are
applied by layer.new.layer (new clipToPrevious param, Photoshop's Use
Previous Layer to Create Clipping Mask), layer.arrange.*, layer.moveTo
(Layers panel drags, batches and ⌥-drag copies) and layer.delete.

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

* Layers: the New Layer dialog (Layer › New › Layer…, ⇧⌘N)

Layer › New › Layer… made a layer straight away. It now opens
Photoshop's New Layer dialog: Name (focused and selected), Use Previous
Layer to Create Clipping Mask, Color, Mode, Opacity, and Fill with
<mode>-neutral color, greyed out with "(No neutral color exists for
<mode> mode.)" for modes that have none. OK makes the layer in one
history step.

layer.new.layer gains mode, opacity, color and fillNeutral params
(new engine module new_layer.rs) beside clipToPrevious. The neutral
colors (white for the darkening modes and Divide, black for the
lightening and difference modes, 50% gray for the light modes) are
checked by compositing at 8, 16 and 32 bits.

As in Photoshop, the Layers panel's New Layer button still makes a
layer at once and ⌥-click opens the dialog; commands with params
(automation, Actions, MCP) never show it.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 18:01:12 -04:00
b26256faee Type: triple-click selects a line, quadruple a paragraph, five clicks all text (#3493) (#3496)
While editing type, a run of clicks at one spot now selects like Photoshop:
two clicks the word, three the laid-out line (a column in vertical type),
four the paragraph up to its hard return, five all text; the next quick
click starts over with a caret. egui stops counting at three, so the Type
tool keeps its own count per layer (next_click_count) and the ranges come
from a pure helper over the text and its layout (click_selection).

Scorecard TYPE-218-8 is done; docs/gaps.md G8 updated.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:55:18 -04:00
Valentin KleinandClaude Opus 5.5 d7e2f171f3 Actions: a command that runs other commands is recorded once (#3067) (#3072)
* Actions: a command that runs other commands is recorded once (#3067)

A journaled command that called Session::execute journaled the inner
calls as well as its own entry, so a recorded action replayed them twice
(Straighten Layer: rotate + crop, then Straighten again). dispatch now
remembers the journal length when a command starts; when a replayable
command succeeds its entry replaces the entries of the commands it ran.
Edit > Transform > Again is not replayable and keeps its concrete transform.

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

* Actions: Again on a Copy records its steps; taken-back steps are dropped (#3067)

- edit.transform.againCopy is not replayable, like Transform Again (#1440):
  it records layer.duplicate + edit.transform, which play in a session
  that never ran a transform. With one layer selected the duplicate names
  no layer id, so it plays on any document.
- compound, Again on a Copy and Frame from Layers take back what they ran
  when they fail; they now also drop those commands from the journal, so
  recording does not replay an undone edit.
- Recording pauses while a replayable command runs: a script that plays an
  action no longer flushes its earlier steps into the recording and loses
  its own entry.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:53:25 -04:00
d523405627 scorecard: FILE-216-5 save-path tests are in the tree (#3476)
Every existing save path (layered TIFF, print PDF, ACO/ASE, .shc, Smart
Filters in PSD, atomic-save failure) already has a save-and-read-back
test; SVG and Photoshop PDF have no writer yet (FILE-215-10).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:48:52 -04:00
cc772a03c1 fix(export): quick-export every selected layer as a separate PNG (#3462)
Enable Layers context-menu Quick Export as PNG for a multi-layer selection. Capture each selected source before showing one destination prompt and write separate numbered PNG siblings with sanitized layer names, without silently replacing existing sibling files.

Add actual PNG encoding and cancellation regressions, preserve the captured selection when the dialog is open, and update context-menu enablement tests.

Co-authored-by: kotulsky <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:48:03 -04:00
Valentin KleinandClaude Opus 5.5 8f7cf009bb Type Mask: Shift/Alt at commit add to or subtract from the selection; no Layers row for the working text (#3036) (#3069)
* Tools: Horizontal and Vertical Type Mask (#1055)

The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal
Type Mask, in Photoshop's order. They type like the Type tools, with the
canvas under Quick Mask's colour and the glyphs clear while editing. On
commit the glyph coverage replaces the selection; no layer is left behind
and the whole session is one "Type Mask" history step. Escape always
cancels and leaves the selection unchanged.

The commit runs a new engine command, type.createSelection: a type layer's
glyphs (optionally removing that layer, the tools' hidden working text) or
type.create's keys directly, with new/add/subtract/intersect and an
anti-aliasing override. type.create's layer building is shared through
type_cmds::new_text_layer.

Original dashed-T icons (ATTRIBUTION.md), tool label translations in every
catalog, README tool list and the gaps/parity docs updated.

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

* Type Mask: Shift/Alt at commit combine with the selection; no Layers row for the working text (#3036)

Committing a Type Mask with Shift held (click outside, Cmd+Shift+Enter or
the check button) adds the glyphs to the selection, Alt subtracts them and
Shift+Alt intersects, as in Photoshop; without modifiers it still replaces.
The Layers panel no longer lists the hidden working text layer while a
Type Mask session is open (layer_tree_ui::panel_rows).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:43:56 -04:00
Timothy DalyandClaude Opus 5.5 5e5a0f6d2d Layers: clip groups follow New Layer, Arrange, Reorder and Delete (#2203) (#3090)
Adding, moving or deleting layers never touched Layer::clipped, so a
new layer above a clipping base split its group and became the base of
the layers clipped above it, and moving or deleting a base left its
clipped layers clipping to whatever ended up below them.

The new clip_group module applies Photoshop's rules, measured on #2203:
a layer added or moved joins the clip group it lands in; a clipped
layer moved stays clipped while it still sits on a clip group; the
layers clipped to a base that moves away or is deleted are released;
and a clipped run left with nothing below it is released. They are
applied by layer.new.layer (new clipToPrevious param, Photoshop's Use
Previous Layer to Create Clipping Mask), layer.arrange.*, layer.moveTo
(Layers panel drags, batches and ⌥-drag copies) and layer.delete.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:43:30 -04:00
kotulskyandkotulsky 7c2e8c3d95 fix(actions): fill the resizable panel with the step list (#3460)
Remove the 320-point list height cap and nested dock scrolling. Reserve space for the footer while filling the available panel height.

Add a panel-height regression test and check recording autoscroll against the visible footer instead of a fixed coordinate.

Co-authored-by: kotulsky <[email protected]>
2026-10-11 17:41:04 -04:00
buivanvu94 17167e27d6 fix(transform): identity transform makes feathered selection edges translucent (#3079) (#3159) 2026-10-11 17:31:25 -04:00
Valentin KleinandClaude Opus 5.5 911f69d7b5 Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033) (#3046)
* Tools: Artboard tool (#2858)

Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).

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

* Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033)

With the Artboard tool, the selected board shows a "+" button beside each
side. A click adds an empty board of the same size (background and preset
copied) on that side, the 100 px artboard gap away and past any board in
the way; ⌥-click adds a copy with its contents instead. A board left of or
above the canvas origin shifts the whole document right/down and grows the
canvas, so it stays visible. Both go through the new engine command
`layer.artboard.addBeside` {layer?, side, duplicate} (one undo step).

⇧ while dragging a corner handle keeps the board's aspect ratio: the
opposite corner stays and the board scales by the larger axis factor.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:30:35 -04:00
72bf3e13b6 i18n(cs): translate the dynamic labels the Czech catalog was missing (#3454)
* i18n(cs): translate dynamic labels the literal scanner can't see

The Czech catalog covered every enforced key (menus, tl! literals, blend
modes, preferences) but not the labels built at run time: filter and
Filter Gallery parameter names and choices humanized from their keys,
Camera Raw controls, the Flame / Picture Frame / Tree render filters,
measurement columns, file info fields and the macOS application menu.
Those showed in English under Czech.

Adds 853 entries (2,441 -> 3,294) for keys that the other catalogs
already translate and that still exist in the code. Existing entries are
unchanged. Translated clean-room from the plain meaning of the English
labels, with the terminology already used in cs.tsv; no proprietary
localisation was consulted.

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

* i18n(cs): credit the Czech catalog in ATTRIBUTION.md and LICENSE-translations.txt

cs.tsv is a bundled translation asset but had no row in ATTRIBUTION.md
and was not named in LICENSE-translations.txt. Add both, worded like the
other catalogs (PhotoCraft contributors, original translations, MIT OR
Apache-2.0).

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

* i18n(cs): terminology review fixes for the new dynamic labels

Changes from an independent review pass over the added block:

- "Create By" (De-Interlace: how the missing lines are made) reads
  "Vytvořit pomocí", not "Vytvořil" (author)
- luminance is "jas" in Camera Raw, as in the existing "Luminance" label
  ("Detail jasu", "HSL – jas", "Jas")
- slider labels are nouns, like the existing "Redukce šumu" (Reduce Color
  Noise, Sharpen Details, Lighten Grout)
- grain and flame choices agree in gender with their neighbours; "Shadow"
  matches "Highlight"/"Světla" as "Stíny"; "At" is "Pozice"; "Water Paper"
  is "Mokrý papír"; "Leaves" is "listů" everywhere

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

* contributors: add my own people.toml entry

Own entry only, as AGENTS.md describes: credited by the name on my public
GitHub profile (sync_github_name), otherwise by @username.

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

* i18n(cs): drop three rows that main added in the meantime

"Columns", "Margin" and "Automatic" were added to cs.tsv on main while this
branch was open, with the same translations, so the appended block no longer
needs them (the catalog has no duplicate keys).

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

* i18n (cs): translate two strings that are still missing from cs.tsv

"Drag (lasso) or click points (polygonal) ..." and "The current gradient
(pick one in Window > Gradients)" exist in the other catalogs but not yet
in cs.tsv on main.

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

* i18n (cs): translate context-menu, channel and layer-style labels

These labels are rendered through tl!() with a variable, so the literal-based coverage test does not see them.

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

* Localize Czech status messages and notice wrappers

Translate shell messages before interpolating file names and engine errors.
Use the existing plural API for counted objects and preserve English fallback.

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

* Localize Czech UI labels and hardware information

Translate brush controls, accessibility names and rasterization prompts at
render sites. Preserve adapter, driver and display/profile identifiers.

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

* Localize Czech composed labels and distinguish Navigator zoom

Translate profile, preset and shortcut messages before interpolation, and use
a Navigator context for the zoom level. Keep shortcut warning state separate
from translated text and preserve document names and shortcut codes.
Cover remaining visible counts and add rendering/glyph regression tests.

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

* Fix Czech UI localization review follow-ups

Keep copied System Info and API diagnostics in English while localizing the
on-screen text. Avoid double translation of accessible names, honor command-id
shortcut labels, and translate stored auto backend values only for display.

Refine Czech warning punctuation and catalog wording, simplify template blocks,
and correct the documented catalog count to 3,522 rows. Add four regression tests.

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

* people.toml: keep axlecoffee's sync_github_name with their entry

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

---------

Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:23:27 -04:00
buivanvu94 680518b425 fix(image): arbitrary canvas rotation moves position-locked children in groups (#3078) (#3155) 2026-10-11 17:21:19 -04:00
Valentin KleinandClaude Opus 5.5 55c1847f89 Paths: Stroke Path with any brush-based tool and Simulate Pressure (#2978) (#3039)
* Paths: Stroke Path with any brush-based tool and Simulate Pressure (#2978)

path.stroke now takes every brush-based tool of Photoshop's Stroke Path
dialog: besides the Brush, Pencil and Eraser it renders itself, the
Background Eraser, Clone and Pattern Stamp, Healing and Spot Healing
Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color
Replacement and Mixer Brush run their paint.* command along each
subpath, in one history step, with only path.stroke journalled.

simulatePressure resamples each subpath and ramps the pressure from 0 at
its start to full at the middle and back to 0 at its end, so a brush
whose size follows pen pressure tapers at both ends.

The Stroke Path dialog (Pen/Path Selection right-click menu, the Paths
panel row menu, Alt-click on the panel's Stroke button) asks for the
tool and Simulate Pressure, as in Photoshop. Scorecard TOOL-214-11 done.

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

* Automation: classify path.stroke's params as a document vector path (#2978)

The new Stroke Path params doc says the tools run "along the path", which
registry_filesystem_path_params_are_classified reads as a possible host
file path. path.stroke strokes a document path and forwards only to
paint.* commands that take no files, so it joins the vector-path list.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:18:39 -04:00
6a4c48aea7 fix(warp): Perspective Warp moves only the selected part of a linked mask (#3083) (#3148)
With a selection, Perspective Warp moved only the selected pixels but warped
the whole linked layer mask, so unselected pixels changed visibility. The mask
now follows the same split as Free Transform (#205): the selected region of a
linked mask moves with the pixels and the rest stays put.


Claude-Session: https://claude.ai/code/session_015Vepmc5pt6MikxbsDj21Bu

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:16:55 -04:00
David MannandClaude Opus 5.5 9e378277e8 Crop tool: switching to another tool commits the pending crop (#3368) (#3451)
Leaving the Crop tool kept a drawn frame pending, so picking another tool
from the toolbar or by its key left the image uncropped. As in Photoshop,
the per-frame crop sync now commits a drawn frame (as Enter would) once the
current tool is no longer Crop; the untouched default frame is still just
dropped. The pen's eraser end and a gesture in progress keep the frame
pending; held tools (Space, Cmd) never change the current tool.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 17:16:18 -04:00
Diego Chagas 18f787f04d SVG: say when text had no font to use instead of opening an empty drawing (#3168)
With no font installed at all (a bare CI image, a minimal container) usvg drops every <text>
while parsing, so nothing reaches the conversion and the file opened as "nothing to draw". The
parser now notes when a drawing has text and the font database has no faces, and the import
warns that the text needs a font and was dropped. Fixes the FreeBSD svg test, which has been red
since the reader landed.
2026-10-11 17:09:21 -04:00
prekabrekiandprekabreki e762c784fb Keep transform controls with moved selected pixels (#3402) (#3436)
Co-authored-by: prekabreki <[email protected]>
2026-10-11 17:09:15 -04:00
Sir Starch d9ecd60fb3 automation: deny edit.installedProfiles, it scans the OS profile folders (#3763)
edit.installedProfiles lists ICC profiles from the OS and user profile
directories, so automation must not run it, the same as the other
profile commands that read ambient paths. Without a policy the registry
check in workspace::tests fails, which has kept CI red on main since the
command landed.
2026-10-11 17:08:47 -04:00
Sir Starch 40d7e6a1d8 fix(history): read pixels from locked history states (#3076) (#3186) 2026-10-11 17:06:15 -04:00
prekabrekiandprekabreki f9e94aa535 Keep the Color panel selected when switching themes (#3371) (#3433)
* Keep the Color panel selected when switching themes (#3371)

* Scope Camera Raw color checks to its settings panel (#3371)

---------

Co-authored-by: prekabreki <[email protected]>
2026-10-11 17:02:38 -04:00
Sir Starch 2bbe6c3fdc fix(filters): process untouched Reveal All masks (#3082) (#3156) 2026-10-11 16:59:55 -04:00
David MannandClaude Opus 5.5 cee1033112 Properties: a new adjustment or fill layer shows its settings, not its mask (#3369) (#3429)
Creating an adjustment or fill layer still targets its mask (#2166), so
painting and Invert edit the mask, but Properties now shows the layer's
own settings, as in Photoshop. A new UiState.properties_layer remembers
the layer; clicking its mask thumbnail, targeting the pixels or selecting
another layer clears it and Properties shows the mask controls again.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 16:55:33 -04:00
cc75ed52c1 fix(cli): skip recorded batch view and save steps (#3058) (#3143)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:54:24 -04:00
Valentin KleinandClaude Opus 5.5 3674ed62bd Single Row/Column Marquee: History names the step after the tool (#3031) (#3035)
select.rect takes an optional `line` ("row"|"column"); the canvas sends it
for the Single Row / Single Column Marquee, so History records
"Single Row Marquee" / "Single Column Marquee" instead of
"Rectangular Marquee". An unknown `line` is an error.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:52:17 -04:00
Sir Starch 06dcdc1267 fix(mode): restore indexed pixels after clearing transparency (#3080) (#3153) 2026-10-11 16:50:58 -04:00
a97cfd103e Fix Camera Raw preview color management (#3427)
* Fix Camera Raw preview color management

* Apply canvas color management to refined previews

* Camera Raw detail preview: clippy and rustfmt

allow too_many_arguments on DetailPreview::paint (the display joins seven
existing params) and as_chunks for the RGBA8 rows (clippy 1.99).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:47:56 -04:00
88ffd89e9f fix(crop): unlock Background when extending canvas (#3081) (#3158)
* fix(crop): unlock Background when extending canvas (#3081)

* Crop: fill an extended Background with the background colour

Photoshop's Crop tool keeps the Background when the frame extends past
the canvas and fills the added area with the background colour, as an
opaque Canvas Size extension does. Do that instead of converting the
Background to a normal layer; other layers still get transparent pixels.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:46:09 -04:00
Valentin KleinandClaude Opus 5.5 62e0335116 Transform Path: Distort, Perspective and Warp apply to paths (#3027) (#3032)
* Transform: Distort, Perspective and Warp keep shape layers live (#3009)

Edit › Transform › Distort / Perspective and Warp on a shape layer let the
user edit the box or mesh and then refused at commit ("needs a rasterized
shape"). Photoshop maps the shape's path and keeps it a shape.

- photocraft-vector: new `map` module. `map_points` sends every anchor and
  control point through a map (Distort / Perspective: exact for straight
  segments, exact end tangents for curves). `map_path` follows a curved map
  (Warp), halving each segment until the mapped control points follow the
  mapped curve within a tolerance (at most 64 pieces a segment, bounded in
  total); straight segments whose image is straight stay straight.
- engine: `transform_layer` and `warp_layer` map a shape's path instead of
  refusing; a live rectangle/ellipse/polygon/line becomes a plain path. A
  linked vector mask now follows Distort / Perspective / Warp too. A map that
  sends a point to infinity is an error, not a corrupt path.

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

* Transform Path: Distort, Perspective and Warp apply to paths (#3027)

path.transform accepted only an affine matrix, so the Free Transform
Path commit refused any box pulled out of a parallelogram ("Distort and
Perspective can't be applied to a path"), and Warp was not offered on a
path at all. path.transform now also takes a `quad` (with an optional
`rect` frame, default the path's control bounds) and a warp (`style`,
`mesh` or `warp`, as edit.transform.warp), validated (finite numbers,
magnitude at most 1e6, non-degenerate quad) and mapped with the
vector::map helpers from #3025. A parallelogram quad stays affine, so a
live shape stays live. The UI commit sends the quad or warp instead of
refusing, and Transform Path offers Warp; each is one undo step.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:43:13 -04:00
rafi612 014dd5f25d fix(ui): drop doubled options-bar borders reintroduced in UI.9 (#2226) (#3425) 2026-10-11 16:40:36 -04:00
f155374ac3 fix(type): navigate adjacent line with large leading (#3066) (#3141)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:38:14 -04:00
76c02862c6 Toolbox: make the colour swap arrow clearer (#3416)
* Toolbox: bolder Switch Colors arrow with filled heads

* Toolbox: match the reference Switch Colors glyph

A bold diagonal curve from the icon's top-right to its bottom-left with
filled heads, measured from the reference glyph, replacing the quarter
arc. Geometry extracted to swap_arrow.rs with unit tests.

* Toolbox: correct the swap glyph reference and arrow directions

* Fix formatting and Clippy checks for swap glyph

* swap_arrow: drop the test's env-var PNG dump

Tests shouldn't read env vars to write files; the render assertions stay.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:32:18 -04:00
Sir Starch b66ebb84d2 fix(type): count normalized insert text caret (#3074) (#3138) 2026-10-11 16:31:51 -04:00
c234865b17 fix(type): respect Lock All on text edits (#3092) (#3132)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:10 -04:00
7aba2bd305 Type: scan system fonts off the UI thread (#3021) (#3030)
* Type: scan system fonts off the UI thread (#3021)

The first `photocraft_text::shared()` call scans every installed font, and
nothing made that call before the Type tool's options bar drew its font
menu, so the scan ran inside a UI frame (over 60 s on some Macs, #2518).

- photocraft-text: `warm_shared()` starts the scan on a background thread;
  `try_shared()` returns the engine without blocking (None while the scan
  runs, started there if nobody warmed it; built in place on the web, in
  tests, if the thread can't start or the worker died). `shared()` keeps
  its blocking behaviour for engine commands.
- The desktop app warms the fonts at start-up.
- Font menus that draw every frame (options bar, Character, Glyphs and the
  Character/Paragraph Styles family menus) show a disabled "Loading fonts…"
  box and repaint until the scan is done. "Loading fonts…" is in all 16
  catalogs.

Complements #2644 (CoreText discovery on macOS): this changes where the
scan runs, not how fonts are discovered.

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

* Type: axis sliders wait for the background font scan too

#2996 (landed on main) added axis_sliders, which called the blocking
photocraft_text::shared() every frame; use try_shared() so the Character
section doesn't stall while the scan runs.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:25:57 -04:00
buivanvu94 3e45d769fa fix(ui): focus and select hex field when color picker opens (#3375) (#3408) 2026-10-11 16:21:57 -04:00
Sir Starch a893113502 fix(gradient): respect Lock All on Gradient Fills (#3093) (#3120) 2026-10-11 16:19:40 -04:00
Valentin KleinandClaude Opus 5.5 8458cf7095 Transform: Distort, Perspective and Warp keep shape layers live (#3009) (#3025)
Edit › Transform › Distort / Perspective and Warp on a shape layer let the
user edit the box or mesh and then refused at commit ("needs a rasterized
shape"). Photoshop maps the shape's path and keeps it a shape.

- photocraft-vector: new `map` module. `map_points` sends every anchor and
  control point through a map (Distort / Perspective: exact for straight
  segments, exact end tangents for curves). `map_path` follows a curved map
  (Warp), halving each segment until the mapped control points follow the
  mapped curve within a tolerance (at most 64 pieces a segment, bounded in
  total); straight segments whose image is straight stay straight.
- engine: `transform_layer` and `warp_layer` map a shape's path instead of
  refusing; a live rectangle/ellipse/polygon/line becomes a plain path. A
  linked vector mask now follows Distort / Perspective / Warp too. A map that
  sends a point to infinity is an error, not a corrupt path.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:16:04 -04:00
Sir Starch f435eb0b55 fix(paint): respect Lock All on layer masks (#3094) (#3111) 2026-10-11 16:15:08 -04:00
b3993e89b3 Linux: say so when no file dialog can open (#3354); read ldconfig on non-English systems (#3397)
* feat(linux): say so when no file dialog can open (#3354)

File › Open / Save / Export did nothing on a minimal Wayland session
(labwc, Void) and said nothing: rfd loads libdbus-1 for the XDG file
chooser portal and falls back to zenity, and when neither works it only
logs and answers "nothing picked", which the app took for Cancel.

A background probe now looks once for the pieces (libdbus in the linker
cache, the portal's FileChooser interface on the session bus, zenity on
PATH). When a dialog comes back empty and none of them is there, the
shell answers with the new FileDialogAnswer::Unavailable and the status
bar tells the user what to install. The probe only explains an empty
answer; it never blocks a dialog, and anything it can't tell counts as
present.

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

* fix(linux): read the ldconfig cache on non-English systems

The startup library check and the new file dialog probe parse
`ldconfig -p`, but accepted the listing only when its first line
contained "libs found in cache". On a German system the header is
"5522 Bibliotheken im Cache ... gefunden", so the cache was treated as
unreadable and the check silently fell back to a directory search.

Run ldconfig with LC_ALL=C, and accept any listing that starts with a
count. Test: a translated header is a valid listing.

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

* file_dialog_probe: no dead-code errors off Linux

Findings, problem and on_path are only used by the Linux probe (and the
tests); the empty non-Linux start() was never called. Windows/macOS
clippy -D warnings failed on them.

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:14:47 -04:00
5f55a29d2b Color: honour the EXIF sRGB tag on open; wire Ignore EXIF Profile Tag (#204) (#3026)
A file with no embedded ICC profile whose EXIF ColorSpace tag says sRGB
(what cameras write) now opens as sRGB, as in Photoshop: it is tagged
sRGB before the Color Settings policy runs, so it is kept, reported as a
mismatch, or converted like any embedded profile. Before, it was read as
untagged and took the working space, so with a wide working space such
as Display P3 its colours were reinterpreted.

Preferences › File Handling › Ignore EXIF Profile Tag (off by default)
restores the untagged reading, and is now shown in the dialog.

photocraft_codecs::exif_color_space reads the tag from the Exif sub-IFD,
bounds-checked; anything malformed reads as no tag. Settings that do
nothing: 43 → 42 of 150.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:07:03 -04:00
56b192453e i18n: translate the Select and Mask dialog labels (#3014) (#3015)
Smart Radius, Shift Edge, Decontaminate, Output and the Refine Edge
title had no catalog rows, so the Select and Mask dialog stayed partly
English in every language; Greek and Polish also missed Modify's
Apply At Canvas Bounds. A new test checks every generated select.*
dialog (params, choices, title) in all registered languages.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:01:04 -04:00
57cba5ef8e Window: a window saved while minimized reopens at the minimum size, not 160 x 64 (#3380)
On Windows, eframe saves a minimized window's size (160 pt wide, raised to
64 pt tall), and the minimum size isn't applied to a restored window, so
PhotoCraft reopened as a 160 x 64 strip on every launch. A window_builder
hook grows a restored window to the minimum size, keeping its centre.

Co-authored-by: Katt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:11 -04:00
b1900ace28 Installed ICC profiles in the profile menus, and Mirror in Print (#3011)
* Installed ICC profiles in the profile menus, and Mirror in Print

- photocraft_engine::installed_profiles: scans the OS colour profile
  folders (Windows, macOS, Linux), bounded and cached; new
  edit.installedProfiles command.
- Print's Printer Profile and the Proof Setup, Convert to Profile and
  Assign Profile dialogs list the installed profiles after the
  built-ins; a profile given by path that isn't listed shows by name.
- file.print emulsionDown mirrors the page (PDF and preview), with a
  Functions › Emulsion Down (mirror) checkbox.
- Print dialog: cm/mm/in, Height and Width fields, Print Resolution.

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

* Print dialog: translate Emulsion Down, Functions, Print Resolution in every catalog; cargo fmt

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:55:11 -04:00
bbb05cabfb fix(shapes): options bar Fill/Stroke edit the selected shape (#3347) (#3370)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:54:38 -04:00
539bf7ba14 Color panel: HSB on sRGB values, like Photoshop (#3323) (#3327)
The Color panel converted between the foreground and its hue, saturation and
brightness with egui's `Hsva`, which works in linear light. So a 50% grey
(128) read as Brightness 22%, and the colour field's left edge halfway down
was drawn as 188, not 128. Photoshop's HSB, and the Color Picker dialog's,
work on the sRGB-encoded values.

`srgb_hsva` / `hsva_srgb` now use the Color Picker's `rgb_to_hsv` /
`hsv_to_rgb`, and the field, hue strip, HSB slider tracks and swatch draw
through a new `hsva_color32` instead of egui's linear `Color32::from(Hsva)`.
`Hsva` only holds the numbers now.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:45:50 -04:00
Valentin KleinandClaude Opus 5.5 d9445c7d1d Free Transform: ⌘-drag of a corner scales editable type instead of distorting it (#3010) (#3012)
Distort and Perspective are greyed for editable type (#2701) and the engine
refuses them at commit, but the box's ⌘ / ⌘⌥⇧ corner drags didn't look at the
layer, so the box distorted and the commit then failed. On a box over live type
those drags now act as a plain corner drag (⌥ and ⇧ keep their meaning).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:42:43 -04:00
4ecf8ea6b9 feat(ui): Motion Blur and other filter angles get the angle dial (#3152) (#3407)
Move Drop Shadow's angle dial out of layer_style.rs into a shared
widgets::angle_dial_row that takes the param's range and uses the
dialogs' value_field. The field keeps Motion Blur's -360..360, and it
takes the dialog's opening focus. The filter dialog uses the dial for
direction angles that count counter-clockwise from three o'clock:
Motion Blur, Emboss, Oil Paint, Lighting Effects, Relight and Global
Light. Rotation amounts (Twirl, Rotate Arbitrary, Crop, Lens Correction,
Blur Gallery) and Flame's up-zero angle keep their sliders.

Arrow keys on the focused dial wrap round only when the range is the
dial's own -180..180 (Drop Shadow); on a wider range they step on, so
Motion Blur's 270° goes to 271°, not -89°.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:38:26 -04:00
67747dfd02 Type: Warp on live type takes the warp styles and stays editable, no prompt (#3004) (#3008)
* Type: ask to rasterize before Warp on live type (#3004)

Edit › Transform › Warp (the menu, or the warp toggle of an open transform
box) on editable type opened Warp mode, let the user shape the mesh, then
failed on commit with "Warp needs rasterized type". It now opens Photoshop's
"This type layer must be rasterized before proceeding…" prompt, the one the
pixel tools already use. OK applies an open transform box, rasterizes the
type (its own history state) and opens Warp on the pixels; Cancel does
nothing. The prompt's continuation is a menu command from an allow-list,
run with the egui context right after the confirm (control channel, OK
button) or on the next frame.

Distort and Perspective stay greyed for type (#2701), Puppet Warp and
Perspective Warp stay greyed for non-pixel layers, as in Photoshop.

Scorecard TYPE-218-9 -> done.

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

* Type: Warp on live type takes the warp styles, no rasterize prompt (#3004)

Photoshop 2026 keeps Edit › Transform › Warp enabled on live type and asks
nothing: the mesh can't be dragged, only the options bar's warp styles
(Warp Text's) apply, and the type stays editable. The earlier version of
this change asked to rasterize first; that is gone (and with it the prompt
continuation, which nothing else used).

Warp mode on a type layer now starts from the layer's own Warp Text style
over the unwarped type, ignores mesh drags and splits (Split, Grid and the
Custom style are greyed/hidden, the split menu items are greyed), and the
commit runs edit.transform.warp, which on type sets the Warp Text: one
history step, recordable, the layer stays Text. None takes a warp off. A
box scaled or rotated before toggling Warp is applied first.

Scorecard TYPE-218-9 note updated: Photoshop doesn't prompt for Warp;
Distort and Perspective stay greyed (#2701).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:35:47 -04:00
Valentin KleinandClaude Opus 5.5 bd3be48785 Pen: Alt breaks a handle while drawing, and close-drag pulls out the first anchor's handles (#3306) (#3314)
Alt during a drag unlinks the handle pair (only the outgoing one keeps following),
Alt-press on the last anchor's outgoing handle turns that handle alone, and pressing
the first anchor now closes on release, so a drag pulls out that anchor's handles
(a stand-in knot folded into the first one on commit). A plain click still closes
without touching the first anchor's handles. Still one History step per path.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:29:33 -04:00
KnottyDyes 2d6ff3f6cc Inspect: report each layer's vector mask (#2980)
`hasMask` in document inspection covers only raster masks, so a layer
with a vector mask looked unmasked to an agent. Layers, groups included,
now also report `hasVectorMask` and `vectorMaskEnabled` (null when there
is no vector mask). `hasMask` keeps its meaning.
2026-10-11 15:21:03 -04:00
28874bbfad Select and Mask: Output To New Document, with or without a layer mask (#2998) (#3007)
* Select and Mask: Output To New Document, with or without a layer mask (#2998)

select.refineEdge takes output newDocument / newDocumentWithMask: a new
document like the source (size, mode, depth, resolution, profile) with
the active layer cut out by the refined edge, or with the matte as a
layer mask. The source document is left untouched. The dialog's Output
choice lists all six targets, translated in every catalog.

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

* i18n: drop the duplicate pt-br New Layer row (main already has it)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:16:45 -04:00
8a72978549 File Handling: wire Image Previews to embed a PSD thumbnail (#204) (#2971)
* File Handling: wire Image Previews to embed a PSD thumbnail (#204)

Preferences > File Handling > Image Previews is live for PSD/PSB saves. With
Always Save (the default) and Ask When Saving, the file carries Photoshop's
thumbnail image resource (1036: the 28-byte kJpegRGB header and a JFIF stream,
at most 160 px on the long side); Never Save writes none. The thumbnail is the
reduced composite converted to sRGB through the document's profile with
photocraft-cms (8/16/32-bit; Gray, CMYK and Lab become RGB), over white where
transparent. Ask When Saving has no Save As checkbox yet, so it saves the
preview, as Photoshop's checkbox starts on.

The preference reaches Save, Save As, Save a Copy and automation saves in the
app (ExportSettings.image_previews), and the engine's headless saves
(file.saveACopy, batch, Layers/Comps/Artboards to Files) through SaveOpts.
photocraft_io::ExportOptions gains image_previews (default on).

Settings that do nothing: 32 of 153 (#204).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* File Handling: rustfmt the Image Previews save path

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:14:49 -04:00
f92f13490b Fix/3144 taskbar maximized (#3308)
* Window frame: trim a caption-less maximize clear of the taskbar (#3144)

* Credit axlecoffee in the About window

* rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:08:18 -04:00
58406c38da Cycle Color Lookup LUTs with keyboard and wheel (#3304)
* Preview Color Lookup LUT selection while cycling

* Test Color Lookup keyboard and wheel cycling

* rustfmt; drop a useless into_iter clippy flags

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:57:43 -04:00
Valentin KleinandClaude Opus 5.5 b54fdfd94f Tests: panic_hunt reruns a timed-out command before calling it hung (#2994) (#2997)
The first type.create starts the shared text engine, which scans every
system font once (3.5-5 s under load). panic_hunt billed that to
type.create against its 4 s deadline and failed intermittently. A real
hang reproduces, one-time setup does not: a timeout now counts only if a
rerun on a fresh session times out too.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:54:14 -04:00
Valentin KleinandClaude Opus 5.5 139df7ae85 Type: variable font axis sliders; optical size follows the type size (#2985) (#2996)
The Character section (Properties and Window > Character) shows one slider
per axis of the style's variable font below the font style (Weight, Width,
Slant, Italic, or the font's own tag), setting type.setStyle variations; a
drag is one history step. Optical size has no slider: the layout now sets
opsz to the type size unless the style sets it, as CSS font-optical-sizing
auto. Picking a font style or weight resets the wght axis to it, so an
earlier slider value no longer overrides the style the dropdown shows.

Scorecard TYPE-218-4 -> done.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:46:26 -04:00
Dan HiornsandClaude Opus 5.5 bb5d26e052 Free Transform: right-click W or H to pick their unit (#3235) (#3301)
As in Photoshop, right-clicking the W or H field of the Free Transform
(and Free Transform Path) options bar opens a menu of units: Pixels,
Inches, Centimeters, Millimeters, Points, Picas and Percent, the
current one checked. W and H then show the box's size in that unit at
the document's resolution (1200 px at 300 ppi is 10.16 cm), and a
value typed in it scales the box to that length; Percent, the default,
is the scale as before. The pick is remembered with the dialog
settings (`dialogs["edit.freeTransform"].sizeUnit`).

The menu is the rulers' unit menu (`rulers::unit_menu`), the
conversion is the Properties panel's `LenUnit`, and the size limits and
the link toggle work on the scale as before. Length fields are wider
(84 pt) so "2116.67 mm" fits; Percent keeps its 66 pt.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:40:14 -04:00
76c7d723f4 Move tool: double-click type on the canvas to edit it (#1132) (#3300)
Since Photoshop CC 2019 a double-click on a type layer with the Move tool starts
editing its text without switching tools by hand. PhotoCraft only offered this
from the Layers panel thumbnail. A double-click with the selected Move tool now
picks the topmost visible type layer under the pointer (the Type tool's own hit
test), makes it active and opens an edit session with the caret on the glyph
there, switching to the Type tool. Elsewhere the double-click does nothing, ⌘
held over another tool doesn't count, and an open transform box keeps its
double-click (commit).

The type tests' harness steps a quarter second per frame, past egui's
double-click delay, so the new test uses a 60 fps harness.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:28:05 -04:00
Valentin KleinandClaude Opus 5.5 65e8d5f020 GPU canvas: price the display textures before building them (#2934) (#3299)
The GPU canvas built a full-resolution, mipmapped texture set covering the
whole declared canvas (DocTextures::new) with only per-texture side limits,
so a document's size alone decided how much GPU memory was committed: a
300000x300000 canvas asks for hundreds of gigabytes.

canvas_texture_bytes prices the set in closed form (every tile and mip
level) and DocTextures::new refuses one over canvas_budget (a quarter of
RAM, at least MIN_GPU_BUDGET) without creating anything. refresh reports
"over-budget" and ensure_gpu then draws the document on the reduced CPU
canvas, so a supported large document still displays.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:16:27 -04:00
myangaa1 e5dbcab335 Open several files in name order, keeping it for background opens (#3248) (#3293)
Files opened together (File > Open with several files, paths on the
command line, the OS's "open these documents") appeared in random tab
order: each one opens in the background and its tab was added when it
finished decoding, so small files came first. The paths were also used
in the order the picker returned them.

`open_paths` now sorts the paths naturally (`File_2` before `File_10`,
case-insensitively) and opens them as a set: every background open
carries its position (`OpenOrder`), and when it finishes its document is
moved after the members before it that have already opened, so the set
reads in order however long each file takes. Members opened at once
(preset files) take their place immediately. The web build sorts the
picked files' contents the same way.

Tests: `natural_cmp`, a set opened synchronously, and a set whose
background opens finish out of order, mixed with a tab dropped at a
slot and an open from a forgotten set.
2026-10-11 13:51:46 -04:00
Gert Pellin - GPEandswitch87 e26442657f ui: Dust & Scratches previews at full resolution, as it applies (#3265) (#3287)
A 1/4 proxy averages away the specks the filter exists to remove, so the
preview showed an untouched image, at a scale that upscaled back to blocky
— while `radius` stayed in document pixels and covered k times the area it
covers in the result. OK always applied at full resolution, so only the
interactive feedback lied; add the filter to FULL_RESOLUTION_PREVIEW and
place its regression test on that path.

Co-authored-by: switch87 <[email protected]>
2026-10-11 13:39:11 -04:00
Valentin KleinandClaude Opus 5.5 740dd3019f Rulers: stay level and fully ticked on a rotated view; guides turn with the image (#3233) (#3282)
* Rulers: stay level and fully ticked on a rotated view; guides turn with the image (#3233, #3277)

The rulers measured along the rotated document axes, so at most angles the
ticks were squeezed into a few pixels (none at 90°) and the rest of the
ruler was empty. They now measure with the view minus its rotation: same
zoom, flip and pixel aspect, same document point under the canvas centre.

Guides were drawn as screen-level lines through the screen position of
(pos, 0), so on a rotated view they no longer marked their document
coordinate while hit-testing still used it. They are now drawn along the
document line across the clip rect.

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

* Rulers: anchor a rotated view's rulers at the document origin (#3233)

Photoshop 2026 keeps the rulers level at zoom scale on a rotated view, with 0
at the screen position of the document origin (where the rotated image's
top-left corner lands), not at the unrotated frame through the canvas centre.
ruler_xform now re-centres the level mapping so the origin projects where the
rotated view draws it; tick spacing still follows zoom, flip and pixel aspect.

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

* Rulers: label distances before the origin without a minus sign (#3233)

Photoshop's rulers print the distance from the origin, so ticks left of /
above it read "1000 500 0 500 1000", not "-1000 -500 0". The label is now
the absolute value (extracted into `ruler_label`), for every view.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 13:27:41 -04:00
Gert Pellin - GPEandswitch87 0144cb283d ui: keep the Color panel knob in the corner it was dragged to (#3228) (#3275)
The SV field re-derives Hsva from the colour every frame, and black reads
back as s = 0 — so a drag that bottoms out into black drew the knob at the
bottom-left one frame later, although the colour it picked was right.

Remember what the field was showing (hue *and* saturation) and restore both
only when the colour cannot carry them: both for black, the hue alone for a
grey, whose saturation is genuinely zero.

Co-authored-by: switch87 <[email protected]>
2026-10-11 13:16:02 -04:00
a35af4e279 Add FITS read/write (astronomy images) (#2956)
* Add FITS read/write codec

FITS (the astronomy format) opens and saves as a flat image: the primary
HDU or first IMAGE extension; gray, or RGB when NAXIS3 = 3; BITPIX 8/16/
32/64/-32/-64 with BSCALE/BZERO/BLANK; rows follow ROWORDER (bottom-up by
default). Camera U16 data stays exact; float data is normalised to 0..1.
Header cards round-trip as text metadata (HIERARCH for long names).
Tile-compressed (fpack) files are refused as unsupported.

Wired into File > Open, Save As and the batch commands as .fits/.fit/.fts.

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

* Test FITS open and save through photocraft-io

Gray 16-bit, RGB 8-bit and RGB 32-bit float documents round-trip exactly,
and header cards travel as the document's text metadata.

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

* FITS: hostile-header regression test (absurd axes, bad BITPIX, no END, HDU chains, overflowing sizes)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 13:15:22 -04:00
f8af27d261 Prevent white flash when opening the screen color picker (#3256)
* Avoid white flash when opening screen color picker

* Normalize screen picker source line endings

* rustfmt; doc comment on viewport_builder

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 13:04:55 -04:00
Gert Pellin - GPEandswitch87 308ef63294 raw: charge the lossless-JPEG intermediate against max_alloc (#2925) (#3194)
Co-authored-by: switch87 <[email protected]>
2026-10-11 12:52:41 -04:00
9441484d16 Quick Mask: an empty start and Select All stay apart (#2743) (#2922)
* Keep Quick Mask's empty start and Select All apart (#2743)

Quick Mask stored "no selection" as an all-white mask, the same as Select
All. With Color Indicates Selected Areas that white was coloured over the
whole canvas, and leaving Quick Mask turned any all-white mask (a real
Select All too) into no selection.

The document now records that Quick Mask was entered with no selection.
The empty mask is white for Masked Areas and black for Selected Areas, so
neither shows colour; switching the option on an untouched empty mask
keeps it empty. On exit only that untouched white start becomes no
selection, so Select All survives the round trip. The flag is saved with
the document.

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

* Quick Mask tests: pass the active layer to channel_view::render (new main signature)

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 12:51:18 -04:00
b814fd5241 Zoom and Hand tools: right-click the canvas for the view menu (#3242) (#3255)
Right-clicking the image with the Zoom or Hand tool opens Zoom In, Zoom Out,
Fit on Screen, Fill Screen, 100%, 200% and Print Size. Zoom In/Out step about
the right-clicked point (or centre it with Zoom Clicked Point to Center); the
other rows run their View menu command. Outside the image the pasteboard menu
still wins. The canvas keeps the view a menu row sets instead of writing its
frame copy back over it. Agents get the same menu through ui.pointer and
ui.context.choose.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 12:50:01 -04:00
a7f26522ff i18n(pt-br): translate 782 missing UI, filter, and tool strings (#3254)
Co-authored-by: AlastorIRL <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 12:42:10 -04:00
Brennan StehlingandClaude Opus 5.5 2b19987bf1 Menus: a submenu stays open while the pointer heads for it (#1218) (#3251)
In the in-window menu bar (Windows, Linux, --in-window-menus), moving from a
submenu's row toward one of its lower items crosses the rows below. egui keeps
the submenu open only while the pointer moves in a straight line at it: a
bowed path or a pause on the way closed it, and the crossed rows lit up as if
chosen.

As in macOS and Photoshop menus, the submenu now stays open while the pointer
is inside the triangle from where it left the row to the submenu's near edge,
until it rests on another row for 0.3 s or moves away. Its row stays lit and
the crossed rows don't light up; when the submenu lets go, the row under the
pointer takes the highlight. Clicks, keys and leaving the triangle behave as
before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 12:34:12 -04:00
Tanish Mankuandjerrykimo da5abd10f9 Native brush outline and crosshair cursors on macOS, X11 and Wayland (#1343) (#3243)
* Native brush outline and crosshair cursors on macOS, X11 and Wayland (#1343)

The brush outline and crosshairs were OS cursor bitmaps only on Windows; on
macOS and Linux the app painted them, so they trailed the pointer by the
render, present and compositor latency and jumped to catch up on slow frames.
tool_cursor now hands the OS the bitmap on every desktop root viewport.

macOS takes NSCursor images in logical points, so the bitmap scales with the
UI zoom only there; Windows, X11 and Wayland take physical pixels. Off Windows
bitmaps stay within 255 px (X servers and hardware cursor planes), and a
larger tip falls back to the painted outline plus a small OS crosshair.

The Pencil's grid-snapped square and the pipette stay painted, and the Hand
bitmap stays Windows-only (the other platforms draw their own hands).

Co-authored-by: jerrykimo <[email protected]>

* Cursor test: the Hand is a bitmap only on Windows; elsewhere the OS draws Grab

---------

Co-authored-by: jerrykimo <[email protected]>
2026-10-11 12:21:40 -04:00
Myosh1 3f0970923d Add built-in rainbow gradient presets (#2916)
Add a "Rainbows" group to the built-in Gradients panel with five presets: Rainbow,
Pastel Rainbow, Neon Rainbow, Oil Slick and Rainbow Fade. The three hue ramps sample 13
evenly spaced hues (red 0 deg to magenta 300 deg) so the transitions are smooth rather
than the seven coarse ROYGBIV points; Rainbow Fade reuses the classic ramp with
transparent ends. All colours are original (clean-room).

Covered by a new engine test, presets::tests::rainbow_gradient_presets.
2026-10-11 12:18:19 -04:00
28622aabfc Move dock tabs between groups individually (#3239)
* Report which dock tab leaves its strip

* Carry dragged dock tab identity

* Expose dock panel name normalization

* Move individual tabs across native dock groups

* Document cross-group dock tab drops

* Test individual tab drops between dock groups

* Resolve a dropped tab by its original index; rustfmt

The strip reports visible indices, so a reordered group moved the wrong
panel. Adds a regression test.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 12:10:27 -04:00
David MannandClaude Opus 5.5 66c0b59a23 Layers: Combine Shapes merges the selected shape layers (#2400) (#2906)
Layer › Combine Shapes › Unite / Subtract Front Shape / Intersect Shape
Areas / Exclude Overlapping Shapes only changed the operations between the
components of the active layer. With two single-shape layers selected (the
report's case) the active layer had one component, so nothing happened.

With two or more shape layers selected and no `layer`/`subpath` param, the
commands now merge them into the top-most layer in one history step: the
bottom layer's components keep their operations and every component of the
layers above takes the chosen one, in stacking order, then the other layers
are removed. Merge Shape Components merges them the same way (each layer
combined) and bakes the result. Selected non-shape layers are left alone,
locked layers refuse the merge, and one selected layer behaves as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 12:05:57 -04:00
Valentin KleinandClaude Opus 5.5 c1bd296dcf Select and Mask: Refine Edge Brush (#2972) (#2989)
* Select and Mask: live preview in Photoshop's view modes (#1754)

The Select and Mask dialog had no preview of the refined selection. It now
previews live in Photoshop's view modes: Onion Skin (default), Marching
Ants, Overlay, On Black, On White, Black & White and On Layers, with a
per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views,
O/M/V/A/T/K/Y pick one and holding X shows the image without a view.

The view state lives in the dialog's fields, so ui.dialog.set drives it.
The preview runs select.refineEdge on the proxy with output=selection and
builds the view from it (veil fill layers, a layer mask, or the refined
selection, whose marching ants the canvas draws scaled back up), through
the existing filter-preview worker.

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

* Select and Mask: Refine Edge Brush (#2972)

Select and Mask had no Refine Edge Brush. select.refineEdge now takes
refineStrokes: [{"points":[[x,y],...],"size":px,"mode":"add|subtract"}],
applied in painting order. Added areas become unknown and take a colour
estimate: each pixel's colour projected on the line between the mean known
foreground and background colours around it (normalised convolution over
a box of the brush size, four times as far where none are near), so hair
or fur beyond the edge-detection band joins the selection. Subtracted
areas keep the original edge. Malformed or oversized strokes are rejected
(stroke / point / swept-area limits), never a panic.

The dialog gets the brush: Refine Edge Brush (R), Mode (Expand Detection
Area / Restore Original Edge, Alt swaps), Size ([ and ]) and Clear Brush
Strokes. With it on, dragging on the canvas paints strokes into the
dialog's refineStrokes field, so the live preview, Show Edge and OK use
them, and ui.dialog.set / agents drive them like any other param.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 12:00:25 -04:00
3360f6e442 Speed up Tilt-Shift with existing per-level halo trimming (#2880)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 11:58:04 -04:00
Dylan M. Taylor 19f427aa00 ci: refactor workflows to use storytold/craft-common-actions (#3432)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:50:48 -04:00
myangaa1 d10ded55d6 Clip the checker under rounded colour swatches to their corners (#3208) (#3363)
The colour swatches (Layer Style's Stroke, Color Overlay and the rest,
and every `color_swatch` / `color_swatch_button`) painted a square
checkerboard under a rounded fill, so the checker's light cells showed
at the four corners, white specks on the dark themes.

The checker under a rounded swatch is now a repeating two-by-two tile
textured onto a rect with the swatch's corner radius: one shared texture
per context, nearest-filtered so it never blurs, scaled so a cell stays
4 pt. `checker` itself is unchanged for the square previews.
2026-10-11 11:48:13 -04:00
5025fd2895 Color Sampler tool (#1046) (#2753)
* Color Sampler tool (#1046)

Add the Color Sampler to the Eyedropper (I) flyout: click to pin numbered
sample points to canvas pixels, drag to move, Alt-click (or drag off-canvas)
to remove, Clear All in the options bar, at most 10 per document. Each point
reads one composite pixel (RGB 0-255 and naive device CMYK, same as the
pointer readout) and the Info panel lists them under their number with the
position in the ruler unit; the readouts refresh when the document changes.

Points are per-document engine view state (DocState.color_samplers): they
belong to their document, are discarded on close, and are not saved to
.pcraft or PSD. They are driven by commands shared by the UI, CLI, control
channel and MCP (view.colorSamplers.add/.move/.delete/.clear/.list); the
mutations change no pixels and add no History step.

Points follow the canvas geometry (crop, Canvas Size, Image Size, Image
rotation/flip, rotated crop) through a pending Session.sampler_map that
Session::edit applies before culling the points that land outside the new
canvas; the count removed is reported in the status bar.

- engine: new sampler_cmds.rs (commands + shared readout helpers) and the
  sampler_map wiring in lib.rs, image_cmds.rs, mode_cmds.rs, commands.rs,
  analysis_cmds.rs (compound rolls points back on error).
- ui-egui: Tool::ColorSampler + original sampler.svg icon, toolbar slot,
  Info panel list, options bar, pointer handling, overlay markers, cursor.
- docs/scorecard.md regenerated (TOOL-1649-1 no longer says the tool is
  missing). No menu item, so parity is unchanged (627/627).

* Color Sampler follow-up: tool count, README, i18n, roadmap

The full ui-egui suite (not just the filtered tests) surfaced these gaps:

- state.rs: Tool::ALL.len() assertion 52 -> 53.
- README.md: "52 tools" -> "53 tools" and add Color Sampler to the list,
  so readme_tool_list_matches_tool_all passes.
- i18n: the options-bar hint, "{n} of {max} sample points" and
  "No document open" need entries in every catalog that claims complete
  menus (every_tl_literal_is_translated); added to all 16 (coverage
  1584/1584). The translations were drafted alongside the tool.
- docs/roadmap.md: drop Color Sampler from the missing-tools list and note
  it landed.

* Color Sampler: fit main's new tools and Image Size resample type

Reset Tool covers ColorSampler, the test ViewXform sets aspect, and
Image Size checks Method::None instead of an Option.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:43:09 -04:00
d22a9fac6e fix(ui): Global Light opens with stale values and overwrites document setting (#3217)
Populate Layer › Layer Style › Global Light dialog fields from the
active document's shared global lighting rather than schema defaults or
remembered values from other documents. This ensures opening Global Light
and confirming unchanged preserves the active document's lighting.

Fixes #3054.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:36:23 -04:00
Valentin KleinandClaude Opus 5.5 7fd5715dbd Type menu: Anti-Alias › None first and checked; checks follow the edited layer (#2983) (#2984)
- menu_catalog: add Type › Anti-Alias › None before Sharp, as in Photoshop.
  It used to come from the engine list, appended after Windows and built
  without a checked state, so a layer set to None showed no check.
- view_cmds::type_checked reads type_tool::target_text (the edited layer,
  else the active or a selected type layer), as the options bar and the
  Type commands do, not only the active layer.
- Anti-alias and orientation items stay check items (Some(false)) with no
  type layer, so the native menu doesn't rebuild.
- Parity floor 629 -> 630; docs/parity-checklist.md regenerated.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:35:38 -04:00
Valentin KleinandClaude Opus 5.5 fd351918e5 PSD: cap the layer tree for psd_to_document; layered TIFF checks group depth (#2940) (#2975)
The file-open path rejects PSDs nested past MAX_GROUP_DEPTH before
conversion, but the public psd_to_document built file.layer_tree() to the
file's full depth (~16000 levels) before Ctx::build capped it, and layered
TIFF reached it with no preflight at all.

- photocraft-psd: PsdFile::layer_tree_max_depth(n) builds the tree
  iteratively and drops the children of groups nested n deep, so walking
  or dropping it recursively is bounded. layer_tree() is the uncapped case.
- psd_to_document uses it with MAX_GROUP_DEPTH, the same cap Ctx::build
  and apply_link_groups apply, so imports are unchanged.
- Layered TIFF rejects nesting past the cap with the same error as a PSD
  (shared psd_import::check_group_depth).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:20:32 -04:00
buivanvu94 6a2aaebe99 fix(ui): unchanged Custom Proof Setup replaces active document proof profile (#3213)
Populate View › Proof Setup dialog fields from the active document's
current proofing condition rather than schema defaults or remembered
values from other documents. This ensures opening Custom Proof Setup and
confirming unchanged preserves the active proof profile and condition.

Fixes #3055.
2026-10-11 11:18:41 -04:00
d8d8d9f3d2 Tools: Convert Point tool in the Pen flyout (#3204) (#3211)
Convert Point is a tool of its own, built on the existing path.convertPoint
edit that Pen + Alt already drives. A click makes a smooth anchor a corner, a
drag from an anchor pulls out smooth handles, a drag on a handle moves it
alone. The tool selects the anchor it converts so its handles show; a press on
empty canvas or a segment does nothing, and Cmd/Ctrl is Direct Selection, as
with the Pen. It shares the Pen's key (P).

Original glyph with an ATTRIBUTION.md row; tool name in all catalogs; README
tool list and the G6 / ui-parity / roadmap counts updated (tools 56 of 68, the
earlier 53 was stale).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:09:38 -04:00
c0110fb4d2 fix(engine): allow targeting layer mask and vector mask via channel.target (#2458) (#2705)
* fix(engine): allow targeting layer mask and vector mask via channel.target (#2458)

* fix(ui): apply channel target actions before syncing mask targets in channels panel

---------

Co-authored-by: huudinh1401 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 11:06:57 -04:00
deb419eeb2 fix: macOS Monterey crash on Intel Iris Pro (disable wgpu indirect validation) (#3171)
* GPU startup: skip wgpu indirect-call validation shaders (#3107)

wgpu compiles its indirect-call validation compute shaders inside
request_device. On Intel Iris Pro (Metal GPUFamily 1, Monterey) the Metal
compiler hits an internal error on them, which wgpu-core reports as
Device(Lost), so the window device can't be created in any mode, including
--safe-gpu and PHOTOCRAFT_CPU_CANVAS. PhotoCraft issues no indirect draws or
dispatches, so drop the flag; WGPU_VALIDATION_INDIRECT_CALL=1 still re-enables it.

* gpu_startup: skip indirect-call validation only on Intel Macs

Keep wgpu's default validation on Apple silicon, Windows and Linux; only Intel Macs,
where Metal's compiler crashed on the validation shaders (#3107), skip them.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:00:01 -04:00
a1de9ce4c8 fix(ui): respect native desktop scale under Auto UI scaling (#1278) (#3165)
* fix(ui): respect native desktop scale under Auto UI scaling (#1278)

* UI scale: drop the now-unused monitor size, update docs and scorecard

Merge main; Auto no longer reads the monitor size, so remove the parameter and its
computation, simplify the test, and update ui-design.md and UI-217-9 to match.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:53:55 -04:00
AyanandBrandon Thomas acce8e0e99 Five exact-output speedups: dense Extrude 160s to 0.39s (#2685)
* Rasterize Extrude blocks and scatter Wind streaks once

* Prepare Spin Blur rotations and fixed-channel sampling

* Share Lens Correction interpolation across color and alpha

* Make wide prepress traps independent of radius

* Add full-operation timing for five imaging algorithms

* Record 24 full-operation benchmarks and exact-output checks

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 10:50:45 -04:00
Valentin KleinandClaude Opus 5.5 0aaf5b60fe Select and Mask: live preview in Photoshop's view modes (#1754) (#2970)
The Select and Mask dialog had no preview of the refined selection. It now
previews live in Photoshop's view modes: Onion Skin (default), Marching
Ants, Overlay, On Black, On White, Black & White and On Layers, with a
per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views,
O/M/V/A/T/K/Y pick one and holding X shows the image without a view.

The view state lives in the dialog's fields, so ui.dialog.set drives it.
The preview runs select.refineEdge on the proxy with output=selection and
builds the view from it (veil fill layers, a layer mask, or the refined
selection, whose marching ants the canvas draws scaled back up), through
the existing filter-preview worker.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:48:26 -04:00
d67e28c118 fix(zoom): Alt-click with the Zoom tool on desktops that take Alt+click (#3137) (#3164)
Desktops that move windows with Alt+drag grab every Alt+left press, so a
Zoom tool Alt-click never arrived and nothing happened. Watch the Zoom
tool for that grab (alt_grab) so the status bar explains it, and let a
latched Alt (Window > Modifier Keys) zoom out: the click, cursor and zoom
rectangle now honour it.


Claude-Session: https://claude.ai/code/session_01JgB5X7gyFEFo4oDW6vH4VN

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:47:37 -04:00
Elvis33LE 2c3d972f18 Speed up large content-aware scale operations (#2596) 2026-10-11 10:46:43 -04:00
LessaandBrandon Thomas 53d09f5850 Preserve HDR range in float adjustments and stack statistics (#2592)
* Preserve HDR range in float adjustments and stack statistics

* Cover HDR exposure round trips and Levels record order

* Report the backend used by GPU parity tests

* Compare float PSD oracles at native depth and preserve signed Exposure

* Retain merged alpha metadata when decoding float references

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 10:44:55 -04:00
Valentin KleinandClaude Opus 5.5 989644cabd Type tool: right-click menu while editing text (#2968) (#2969)
* Type: Edit › Paste, Copy and Select › All work on the text being typed (#2893)

While editing type, Edit › Cut already worked on the selected characters (#1355), but the other
menu rows still ran their pixel commands: Edit › Paste tried to paste an image (and did nothing
with text on the clipboard), Edit › Copy copied rendered pixels, and Select › All selected the
canvas. Route them through the type editor like the keyboard: Paste asks the window for the
clipboard text, which arrives as a Paste event and replaces the selection; Copy copies the
selected characters; Select › All selects every character. Copy is enabled for a non-empty
character selection, Paste and Select › All while typing.

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

* Type tool: right-click menu while editing text (#2968)

A right-click inside an on-canvas type edit opened nothing: the Type tool
had no canvas menu, and the inline type editor is not an egui TextEdit, so
the text-field menu doesn't reach it. It now opens Photoshop's type edit
menu (canvas_tool_menu.rs TYPE_EDIT_MENU), built from existing commands:
Undo, Cut, Copy, Paste and Select All (dispatched as the Edit/Select menu
items, which act on the text being edited), Check Spelling..., Find and
Replace Text..., Faux Bold/Italic (type.setStyle on the edit's selection,
checked), Rasterize Type, Create Work Path, Convert to Shape (these and the
paragraph/point conversions end the edit first), Horizontal/Vertical and
the anti-alias modes (checked), Convert to Paragraph/Point Text and
Warp Text.... The right-click keeps the text selection. While the menu is
open the keyboard belongs to it: Esc closes only the menu instead of
committing the edit. Agents open it through ui.pointer as for the other
canvas menus.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:44:36 -04:00
0f8b370621 i18n(es): native-style review of the Spanish catalog (#3154)
Aligns the Spanish UI with the terminology a Spanish-speaking Photoshop user already knows,
fixes mistranslations and makes the register consistent. 351 rows changed out of 2,457.

- Dodge/Burn were swapped (tools, Color Dodge/Burn, Paint.NET variants).
- "Constrain proportions" read "Construir proporciones" (build) instead of "Restringir".
- Feather and Fade shared one label, as did Artboard and Canvas.
- Photoshop terms: Mesa de trabajo, Motivo, Ajuste preestablecido, Métodos abreviados,
  Cuadrícula, Sector, Deformación de posición libre, Tampón de clonar, Calar, menú Vista…
- Formal "usted" throughout (the catalog mixed tú and usted).

docs/localization-es.md records the terminology so later additions stay consistent.


Claude-Session: https://claude.ai/code/session_012JC2QZ1Qc5KQMBswS38GKy

Co-authored-by: Diego Ospina <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 10:43:15 -04:00
Valentin KleinandClaude Opus 5.5 0fb4bbf1db PSD: don't inflate an over-budget ZIP merged image at parse time (#2926) (#2967)
validate_planes bounded its zlib loop by the uncapped declared size, so a
PSD/PSB declaring more than MAX_DECODED_BYTES of merged image could make
PsdFile::from_bytes decompress far past the decode budget before any
error. decode_planes refuses such a layout with LimitExceeded anyway, so
the stream is now kept verbatim without inflating it, like an unknown
compression method. A layered file whose merged composite is too large
still opens (the importer only warns when the composite can't be decoded).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:40:31 -04:00
buivanvu94 1fdb0378a3 fix(ui): respect explicit dropdown defaults in schema dialogs (#3056) (#3149) 2026-10-11 10:36:15 -04:00
Brennan StehlingandClaude Opus 5.5 16bdcbd54e Window menu: list the open documents, the active one checked (#2991) (#3147)
Photoshop's Window menu ends with the open documents; PhotoCraft's had no
list in either menu bar (#1264 got the tab overflow menu only). Window now
ends with a separator and one row per document in tab order, labelled with
its name and checked when active; choosing one brings it forward. The ids
are window.document.<index>, so the rows also work over ui.menu.invoke.

- In-window menu bar: the rows come from menus::menu_items, after Window's
  last item. Names are shown as they are, never translated.
- macOS menu bar: the rows follow Bring All to Front, where a Mac app lists
  its windows. Opening or closing a document rebuilds only the Window menu:
  AppKit adds its own items there (window tiling, the window list), so muda
  can't insert rows by position, and the other menus stay as they are.
  Switching documents moves the check in place. The menu state hash now
  includes the documents' names, so a Save As rename shows up.

Tested in a Tart guest with three generated files (.psd, .png, .tif) in
both menu bars: on main neither lists them; with this change both do, and
choosing a row switches the document and moves the check.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 10:31:26 -04:00
kayrockscreenprintingandClaude Opus 5.5 1f742629bb Dialogs: keep the live dialog in front of older ones (#3140)
Only the last dialog takes input, but egui only lets the modal drawn on top
take input, and it raises an area that is pressed or wasn't drawn the frame
before. With several stacked dialogs (three files opened together, each with
an Embedded Profile Mismatch prompt) an older prompt could end up in front:
greyed out, with the live one blocked behind it, so nothing could answer
either and the app had to be quit. Raise the live dialog when another one
is above it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:25:29 -04:00
d45b3718ce New Document: presets follow Units & Rulers › Preset Resolutions (#204) (#3126)
Preferences › Units & Rulers › New Document Preset Resolutions did nothing:
Print Resolution was hidden as unread, and the File › New presets always used
300 / 72 ppi. As in Photoshop, print and photo presets now keep their size in
inches at the Print Resolution (Letter at 200 ppi is 1700 x 2200 px), and
screen presets keep their pixels at the Screen Resolution. The Clipboard
preset keeps the copied pixels. Values that are not positive and finite (a
hand-edited preferences file) leave the preset as listed, and sizes clamp to
the file.new limit.

Print Resolution is now shown in the Preferences dialog; settings that do
nothing: 43 -> 42 of 150.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 10:15:40 -04:00
Valentin KleinandClaude Opus 5.5 7b88189e2d Type: Edit › Paste, Copy and Select › All work on the text being typed (#2893) (#2960)
While editing type, Edit › Cut already worked on the selected characters (#1355), but the other
menu rows still ran their pixel commands: Edit › Paste tried to paste an image (and did nothing
with text on the clipboard), Edit › Copy copied rendered pixels, and Select › All selected the
canvas. Route them through the type editor like the keyboard: Paste asks the window for the
clipboard text, which arrives as a Paste event and replaces the selection; Copy copies the
selected characters; Select › All selects every character. Copy is enabled for a non-empty
character selection, Paste and Select › All while typing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:09:30 -04:00
ade5799165 Mirror the shell for right-to-left UI languages (DIST-220-4) (#3114)
* Mirror the shell for right-to-left UI languages (DIST-220-4)

Arabic, Hebrew, Persian and Urdu read right to left, but the window's
chrome was hard-wired left to right, so an RTL catalog (#2505) would
put Arabic labels into a left-to-right layout.

The reading direction now follows the language code
(i18n::dir_for_code: ar, ckb, dv, fa, he, ps, sd, ug, ur, yi), so a
catalog needs no extra setting and the LANGUAGES rows are unchanged.
In a right-to-left language the shell mirrors:

- the menu bar starts at the right with File; menus open under their
  title's right edge, and the arrow keys step through them in screen
  order
- the toolbox docks on the right, the panel dock and its rail on the
  left; tool flyouts open towards the canvas and their corner marks
  and the header chevrons follow
- the title, options and status bars read from the right, with the
  workspace controls and the Fit/progress group at the left end; the
  title and window-drag gap use the free span between them

New module direction.rs holds the helpers (start_panel/end_panel,
row/trailing layouts, horizontal_centered, the popup alignment). Left
to right, every change lays out as before.

No RTL catalog ships yet, so PHOTOCRAFT_UI_DIRECTION=rtl forces the
mirrored layout for review (read once, native only). Panel interiors,
dialogs, menu rows, submenus and document tabs stay left to right, and
egui still draws UI labels without Arabic shaping or bidi reordering;
docs/localization.md and the parity docs say so.

Adds Lucide's chevron-left (the status bar's info menu arrow points
left in RTL; Timeline's Previous frame button asked for it too and drew
the fallback square).

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

* Localization docs: egui shapes Arabic words; what's missing is a font and bidi word order

egui 0.36 shapes right-to-left runs with HarfRust, so letters join within
a word. The gaps are an Arabic fallback font in the UI and paragraph bidi
(words are placed left to right; emilk/egui#8576, #8577).

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

---------

Co-authored-by: MathMattTech <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:18 -04:00
Valentin KleinandClaude Opus 5.5 1a54384d80 Rulers: remember View › Rulers for new documents; guides as f64 (#2918) (#2954)
View › Rulers (⌘R) now writes unitsAndRulers.showRulers, which is applied
at launch and whenever the preference changes, so new and opened documents
show the rulers as last set, as in Photoshop. The Preferences dialog doesn't
list it: the View menu sets it.

Guides are Vec<f64> (doc, New Guide/Move Guide, guide layouts, artboard and
canvas geometry, snapping, PSD resource 1032), so a guide placed in ruler
units or read from a PSD's 1/32 px positions keeps its exact place.

Scorecard UI-217-20 done (part of #217).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:05:13 -04:00
2d2e4993a1 fix(ui): reduced suffix spacing width in the value field to prevent label overflow (#3040) (#3084)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:59:53 -04:00
Tanish Manku 75101ddce2 8-bit documents: soft, low-flow brush strokes are dithered instead of banded (#3237)
Brush phase 6c. A stroke composites in float, then each pixel is rounded to 8 bits, so a soft,
low-flow stroke's gentle gradient collapses into flat bands (the audit counted 35 levels against
119 at 16-bit). The pixels the stroke changes now get the same deterministic per-pixel offset of
under half a level on every channel before quantisation:
- fractional values round up or down in proportion, so bands become fine grain;
- a value already on an 8-bit level rounds back to itself, so exact colours never change, and
  pixels the stroke doesn't touch are left alone;
- the offset depends only on the pixel's position, so the live preview, the commit and a replay
  agree.
16-bit and float targets are unchanged.
2026-10-11 06:58:47 -07:00
52e130d481 Fix canvas context menus closing immediately on trackpad taps (#3029)
* Fix canvas context menus closing immediately on trackpad taps

Ignore outside-click dismissal during each menu's opening frame.
Share the guard across canvas tool and layer menus, including control-channel openings.
Add regression coverage for quick taps, repeated reopening, dismissal, and selection/history preservation.

* Share popup dismissal guard with the brush picker

* Merge main: pass the context to main's new control-channel Free Transform menu opener

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:52:26 -04:00
30bd8a5598 Fix Shape Stroke controls, popup interactions and paint colours (#3019)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:46:44 -04:00
3cbd727b85 Tests: the marquee ellipse check walks several dash periods of the curve (#3018)
* Tests: the marquee ellipse check walks several dash periods of the curve

The old check sampled ~12 positions along a 28 px arc of a 4-on/4-off ants
pattern: a margin of one sample, so renderer, antialiasing or dash phase
decided pass or fail. Walk the true ellipse around each edge midpoint over
±0.3 rad instead, with the ±2 physical px band hugging the curve, so the
check sees the dashes wherever the phase puts them (#253 follow-up).

* rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:40:44 -04:00
585131440a Pixel grid: fade the line colour across mid-tones instead of flipping at one luma (#2990)
* Pixel grid: fade the line colour across mid-tones instead of switching at one luma

The grid line was a full white line over pixels at or below luma 0.55 and a weaker
black one above it. On an image whose tones sit near that cut, neighbouring cells
landed on either side and the grid showed bright, dark and different-strength lines
in patches that traced the 0.55 contour.

The line is now a white layer and a black layer weighted by a smoothstep over luma
0.35 to 0.75, so mid-tones get a faint line that varies smoothly and dark and light
pixels keep the old white and black lines. The GPU shader and the CPU path
(pixel_grid.rs) use the same band; the CPU share is rounded to eighths so equal-colour
runs still merge into long segments.

Tests: unit tests for the fade and mid-tone evenness, and a GPU render test that
fails on the old shader (grid line 30 levels off its pixel at grey 0x87).

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

* Pixel grid: test the line over opaque white too (the #2461 review case)

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 09:35:34 -04:00
Harrison O'Connor-Hoover 7a6058e6b4 control: allow requests that operate an open Camera Raw dialog (#2988)
Preserve profile, competing-dialog, crop, discard and authorization gates. Add control-handler regressions for RAW preview, commit and cancel.

Prepared with Codex assistance.
2026-10-11 09:26:46 -04:00
Valentin KleinandClaude Opus 5.5 885b754414 Text fields: right-click edit menu (#2921) (#2952)
* Text fields: right-click edit menu (#2921)

Right-clicking in any text field (layer rename, a number field being
typed into, dialog and panel fields) opens Undo, Cut, Copy, Paste,
Delete and Select All at the pointer, acting on that field. A
right-click inside the selection keeps it, Esc closes only the menu,
and clicking the menu doesn't take the field's focus (the layer rename
would commit). The right-click never reaches the canvas or tool menus.

An egui plugin (text_field_menu.rs) opens and closes the menu for
every TextEdit; the app draws it before any field, so a chosen item is
handed to the field as its keyboard input in the same pass. Paste asks
the window for the clipboard text like Cmd+V in a field. No spelling
suggestions: there is no spell checker (UI-217-8 stays partial).

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

* Text field menu: correct the spell-checker note (#2921)

PhotoCraft has a spell checker (edit.checkSpelling with a suggest
action); it just isn't wired into the text-field context menu yet.

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

* Context-menu parity doc: same spell-checker correction (#2921)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:25:19 -04:00
1234ba4c6f Type: wire Show Font Names in English (#204) (#2981)
Preferences > Type > Show Font Names in English (default on) now drives the
font menus (Type options bar, Character panel, style and Glyphs panels). With
it off, a family whose name table carries a non-English family name (e.g.
"MS Gothic" -> "MS ゴシック", "Microsoft YaHei" -> "微软雅黑") is listed by
that native name, sorted by the shown label, and the search matches either
name. Choosing it still applies the English family, so documents and PSD
output never change with the preference.

- text: FontDb::localized_family_names() reads each family's first face
  (typographic family name, else family name) and a pure localized_name()
  picks the first non-English record.
- ui-egui: sync_font_names() publishes the labels to the egui context once a
  frame (cached per font generation); family_picker_in shows them.
- prefs: the row is no longer hidden; scorecard: dead settings 43 -> 42.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 09:21:55 -04:00
Valentin KleinandClaude Opus 5.5 7c96e9e120 Layer Style: contour picker and Contour Editor (#2912) (#2949)
Every effect with a contour (Drop/Inner Shadow, Outer/Inner Glow, Satin,
Bevel & Emboss Gloss Contour and Contour element) now shows a contour
thumbnail with a preset picker and an Anti-aliased checkbox. Clicking the
thumbnail opens the Contour Editor: add/drag/delete points (the shared
point-curve editor), Corner per point, Input/Output, Preset, and Load…/Save…
of .shc contour files, read and written from Adobe's public File Formats
Specification (Contours). Edits go to the dialog's pending params, so the
preview follows and OK applies them in the one layer.layerStyle.replace.

- doc: Contour::Custom gains per-point corner flags (serde default).
- compose: contour_curve_lut splits the spline at corner points; CPU and
  GPU effects use it.
- engine: the 12 standard presets with our own control points (older names
  still accepted); `contour` params also take a curve object
  {name, points, corners}; antiAlias params; Satin contour; the bevel
  Contour element edits in place; contours_from_shc / contour_to_shc.
- io: PSD contour points read and write the Cnty (continuity) flag.
- psd: shc.rs parser/writer.
- scorecard FILE-215-15 missing -> partial.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:21:18 -04:00
6601bbc3e7 Layers: Pattern Fill dialog for new and edited pattern fill layers (#2908) (#2979)
* Layers: Pattern Fill dialog for new and edited pattern fill layers (#2908)

Layer > New Fill Layer > Pattern... created the layer with default
settings and no dialog, and double-clicking a Pattern Fill thumbnail only
opened Properties, which has no pattern controls, so a pattern fill could
not be adjusted after creation.

Both now open Photoshop's Pattern Fill dialog: the pattern swatch picker,
Snap to Origin, Angle, Scale and Link with Layer, previewed live on the
canvas from a document snapshot. OK runs one engine command, so it is one
History step; Cancel changes nothing (a new layer is not created).

- engine: new `layer.fill.pattern.set` (pattern_fill_cmds) edits a Pattern
  Fill's pattern, scale, angle, link and phase in one undo step, validating
  every param; `preview` applies it or `layer.newFillLayer.pattern` to a
  copy of the document for the dialog. `new_fill_layer`'s edit is shared
  as `insert_fill_layer` so the preview is exactly what OK commits.
- ui: pattern_fill_ui (dialog body, OK, canvas preview), routed from the
  menu item (no params) and Layer Content Options; explicit params still
  run the engine directly. Catalog entries for the new strings.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Pattern Fill test: pick the swatch picker's Patterns button (main added a Patterns dock tab)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 09:16:18 -04:00
60339cf675 app.open: open directory .pcraft through the automation read root (#2490) (#2581)
* app.open: open directory .pcraft through the automation read root (#2490)

The native on-disk format is a directory, but the automation read path
serves regular files only, so agents could not open the files the app
itself writes. The format crate's storage seam is now public (ByteSource
+ load_bytes_source); the workspace opens the .pcraft subdirectory through
the held capability and loads through it. app.open tries the directory
form for .pcraft paths and falls back to the bytes path for the
single-file (zip) form; every other format is unchanged.

* app.open .pcraft: review fixes (#2581)

read_entry now bounds the read itself (the stat length is only a buffer
hint) and both directory sources reject non-regular entries, so a crafted
bundle with a FIFO entry can no longer block the open and stream unbounded.
The desktop opener runs under the crash guard like its import sibling, the
zip fallback is structural (Ok(None) instead of an error-message match),
a directory-form document no longer arms a save-back target the file-only
automation writer could never replace, and the open status is translated
(Opened {name}, 16 locales).

* read_entry: saturating max+1 so a usize::MAX budget can't overflow

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:12:59 -04:00
10fb9233d5 Tools: wire Enable Flick Panning for the Hand tool (#204) (#2964)
* Tools: wire Enable Flick Panning for the Hand tool (#204)

Preferences > Tools > Enable Flick Panning (default on) was never read.
Like Photoshop, a Hand drag (the selected Hand, the Space-held Hand or a
middle-button drag) released while the pointer is still moving now keeps
the view gliding that way and slows smoothly to a stop; a click, new drag
or key press stops it. Off, the view stops where the drag ended.

flick_pan.rs holds the pure state: the release velocity from the last
80 ms of drag steps, an exponential friction step (tau 0.18 s, so a fast
flick rests in under a second), a start/stop speed threshold, and clamps
for huge, NaN or infinite numbers. The canvas drives it per frame with
request_repaint only while gliding; with Overscroll off the glide is
clamped like the scrollbars and stops at the pan limit.

Settings that do nothing: 43 -> 42 of 149 (docs/scorecard.md).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Preferences: show Enable Flick Panning now that the Hand tool reads it (#204)

Remove tools.enableFlickPanning from prefs::HIDDEN_UNTIL_IMPLEMENTED so the
prefs_usage test passes and the dialog shows it; regenerate the scorecard
(settings that do nothing: 36 of 153) and mark flick panning done in
docs/ui-parity.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 09:03:11 -04:00
cb9513c718 Channels: keep RGB off while an alpha or mask is shown, and select from that channel. (#2555)
* Channels: keep RGB off while an alpha or mask is shown, and select from that channel.

Co-authored-by: Cursor <[email protected]>

* Channels: rustfmt the RGB-off view check.

Co-authored-by: Cursor <[email protected]>

* channel view: pass the active layer to render in main's new tests

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

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:03:05 -04:00
026432815c Preferences: wire Tools › Show Transformation Values (#204) (#2953)
* Preferences: wire Tools › Show Transformation Values (#204)

The size and angle readouts beside the pointer (marquee W/H, Crop frame
W/H and angle, Crop Straighten angle) now go through
canvas::draw_value_readout, which draws them only while
tools.showTransformationValues is on, as in Photoshop. The brush HUD
isn't a transformation value and keeps drawing.

The preference leaves HIDDEN_UNTIL_IMPLEMENTED, so Preferences › Tools
shows it (its label is already translated). Settings that do nothing:
43 → 42 of 149.

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

* Contributors: add my display name

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:57:49 -04:00
55a617cd66 Place: wire Always Create Smart Objects When Placing (#204) (#2543)
* feat(engine): Always Create Smart Objects When Placing gates the Place layer kind (#204)

* scorecard: regenerate after wiring Always Create Smart Objects When Placing

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:51:33 -04:00
a0ff3a01f2 Smart Objects: linked smart objects follow their files while open (#2510)
* Smart Objects: linked smart objects follow their files while open

As Photoshop documents ("Linked Smart Objects update automatically when
the Photoshop document is open, provided the source file has changed"),
a linked smart object whose file another app saves is re-rendered
without Update Modified Content. The desktop app stamps the active
document's linked files (size + modification time) on a worker thread
every two seconds and when its window comes back to the front; each
changed file is one Update Modified Content undo step, independent of
the selected layer. Stamps are kept per document; a file that can't be
read yet leaves no step and is tried again; files changed while the
document was closed still wait for Update Modified Content.
layer.smartObjects.updateChanged runs one look (refused over the
control channel like the other layer.smartObjects commands).

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

* Smart Objects: a linked file that can't be read is tried again once it changes, not on every look

A linked file the automatic update couldn't read (still being written, or a
format smart objects don't read) kept its old stamp, so every two-second look
saw it as changed and read it again on the UI thread. It now keeps its new
stamp and is tried again once it changes again.

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

---------

Co-authored-by: Katt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:42:52 -04:00
David MannandClaude Opus 5.5 f781cb70fc Layers: right-click an effect row for the layer style menu (#2846) (#2950)
Right-clicking the "Effects" row or an effect row under a layer did nothing.
Like Photoshop, it now opens the layer style menu for that layer: Blending
Options, Copy/Paste/Clear Layer Style, Global Light, Create Layer, Hide All
Effects and Scale Effects. Items run the existing commands with menu-item
params (dialogs open as from Layer > Layer Style), selecting the right-clicked
layer first. The item loop of the layer row menu is shared.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 08:37:34 -04:00
509b596805 Dropdowns: type a letter to jump to an item, Enter to choose (#1481) (#2948)
As in Photoshop's pop-up menus, typing a letter in an open dropdown list
jumps to the next option starting with it (the same letter again cycles
through them), letters typed within a second match a longer prefix, and
Enter closes the list on the option shown. Matching is case-insensitive
on the translated labels (Unicode lowercase, no byte slicing). While a
dropdown list is open it owns the keyboard like an open menu, so its
letters no longer reach the tool shortcuts.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:33:29 -04:00
Ahmed Hosny 3e3e580217 MCP bridge: reconnect before idle sockets die, retry reads, actionable timeouts (#2487) (#2507)
* MCP bridge: reconnect before idle sockets die, retry reads, actionable timeouts (#2487)

The app closes idle control connections after 30s (IO_TIMEOUT) while the
bridge kept one persistent connection, so the first call after a pause
failed with "operation may have completed". The bridge now drops and
reconnects before sending once the connection has been idle 25s, so a
request is never written to a socket the app may have dropped.

The default call timeout drops from 60s to 55s, strictly below the app's
60s reply deadline, so the bridge's own error — naming the method and the
likely causes — is the one callers see instead of a race between the
app's deadline reply and the caller's own cutoff.

Idempotent read methods (ui.inspect and friends) get one automatic retry
on a transport failure; edits keep #1007 semantics and are never resent.
A timed-out call is never retried (it would only double the wait).

* MCP bridge: caller-vouched read retries, per-failure wording, docs contract (#2487 review)

The retry decision moves from a hand-maintained method list — most of whose
entries were engine command ids that never travel as control-protocol
methods, so they could never match — to the call sites that know a call is
a read: call never resends (#1007), call_read retries once on a transport
failure, and the MCP server opts its pure-read tools in. Failures are
worded by what they prove: a connection that could never be established is
"the request was not sent", not "may have completed"; a read timeout says
a read may be retried safely instead of demanding a state inspection. The
55s client cap, the read-retry exception, and the idle reconnect are now
documented in the control protocol and the security book.
2026-10-11 08:29:35 -04:00
ecbd3d78be Toolbar: Pro themes draw tool glyphs at two thirds of the button (#2665) (#2932)
The Pro toolbar drew each tool glyph at 16 px in a 30 px button (the
generic icon-button proportion), which reads small and thin next to
Photoshop's toolbar. Pro themes now draw tool glyphs at 20 px; the other
themes keep the icon-button size.


Claude-Session: https://claude.ai/code/session_01CVtZHu998NDhf4Z8QvS5pd

Co-authored-by: ngrafton2006 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:28:38 -04:00
UnemarsandClaude Opus 5.5 0b4b1e4286 Image Size: Photoshop's resampling, measured (#2451)
* Image Size: Photoshop's resampling, measured

Photoshop 25.4's Image Size was measured as a black box (synthetic 16-bit
images in, exact RAW out); every method but Preserve Details' enlargement is
linear, so each was taken apart from its impulse responses.

- Bicubic is the Keys cubic at a = -0.75, not Catmull-Rom.
- Bicubic Smoother (stretched x1.15, a = -0.625) and Bicubic Sharper
  (x1.05, a = -1), with a growing with the reduction, and Bicubic Automatic.
- Reducing widens the filter by 1/s; below 1/4 the image is first averaged
  down to four times the target. Bilinear is never widened and averages
  whole pixels below 1/2. Positions are rounded to 1/128 pixel and stop at
  the edge pixel's centre.
- Two passes, the smaller scale first, clipped and stored between them; the
  bicubic filter is designed for the height's scale and serves both axes.
  Bicubic Automatic splits a mixed resize into Sharper, then Smoother.
- Image Size and Fit Image take automatic, bicubicSmoother, bicubicSharper
  and bicubicAutomatic; the dialog lists Photoshop's methods and opens on
  the interpolation preference.

On 225 probes the mean error falls from 240-550 to under 0.5 levels of
32768; 8-bit results are identical to Photoshop's for 99 % of values.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Format: one rustfmt change main needs

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 08:25:21 -04:00
Valentin KleinandClaude Opus 5.5 7107c80042 perf: pasting a layer recomposites only where it draws (#2901) (#2917)
* perf: Duplicate Layer recomposites only where the copy draws (#2883)

Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

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

* perf: pasting a layer recomposites only where it draws (#2901)

Paste (and Paste in Place, Paste Into, Paste Outside) inserted the
pasted layer through Session::edit, which reports no damage, so every
paste recomposited the whole document (P10/P11). Capture the document
before the paste and report the new layer's change bounds with
note_insert, as Duplicate Layer does (#2884). Anything that isn't
self-contained (a new clipping base, video, excluded channels) still
recomposites everything.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:24:51 -04:00
ec28fdd03d Add Photoshop-style guide layout dialog and live preview (#2930)
* feat(ui): add guide layout dialog with live preview

* test(ui): restore path helper used by save dialog assertions

* chore(i18n): avoid conflicts with appended catalog entries

* chore: avoid append conflict for contributor credit

* Merge main: one people.toml entry for Skyfuller1303, drop the duplicate joined helper

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:23:03 -04:00
Valentin KleinandClaude Opus 5.5 42e4af6416 Tools: Frame tool (#2914) (#2915)
The Frame tool (K, its own toolbar slot after the Crop group) draws a
rectangular or elliptical frame; the options bar picks the shape, Shift
constrains to a square or circle and Alt draws from the centre. A frame
drawn over the selected pixel layer's content takes that layer in;
otherwise it is an empty frame.

Frames are built as Frame from Layers builds them: a group clipped by a
layer mask (rectangular or anti-aliased elliptical). The new engine
command layer.new.frame does it in one Session::edit, so one undo step.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:20:15 -04:00
Valentin KleinandClaude Opus 5.5 614d06892c perf: marquee selections on large documents are built row by row (#2888) (#2907)
The Elliptical Marquee tested and wrote every pixel one at a time
(Surface::write_pixel), single-threaded, with no shared full tiles. The
add/subtract/intersect modes read both selections over the whole canvas
into f32 buffers. On 6000x4000 an ellipse took ~0.6-1.1 s and a mode
combination ~0.3-0.5 s.

ellipse_surface builds the shape per row: a run of fully selected pixels
(bounded analytically, checked with the exact f32 tests at both ends)
and edge pixels that get the original per-pixel coverage test. Tiles are
built in parallel, and fully selected tiles share one tile.
combine_surfaces maps two GRAY8 selections tile by tile through a
256x256 byte table, sharing tiles that come through unchanged. Both
produce exactly the old bytes (equivalence test against the old
per-pixel code).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:11:17 -04:00
984bc5c715 scorecard: twenty rows describe work that is already in the tree (#2894)
* scorecard: eight Open bugs rows describe work that is already in the tree

AGENTS.md sends contributors to the scorecard's missing and partial rows when
picking work, so a stale row costs someone a session: they sit down to write a
test that is already there. These eight ask for work that exists and is covered
by passing tests.

#205 / #207, mask and channel transforms:
- BUG-205-4 ("no raster-mask test"): linked_raster_mask_moves_with_the_pixels.
- BUG-205-5 ("the other two not implemented"): all three cases are covered, by
  linked_mask_with_a_selection_moves_only_the_selected_region,
  unlinked_mask_stays_put and targeted_unlinked_mask_transforms_alone.
- BUG-207-4 ("transform() has no target handling"): edit.transform takes target
  (pixels, mask, quickMask or {channel: i}) and documents it in its params doc;
  targeted_alpha_channel_transforms_alone covers the alpha channel.

#206, kerning — the whole section:
- BUG-206-1: CharStyle has both `kerning: Kerning` and `kern: f32`.
- BUG-206-2 / BUG-206-3: io/tests/psd_kerning.rs round-trips manual, Off and
  Optical kerning, and reads the AutoKerning flag and Kerning value of every
  character back out of the written TySh.
- BUG-206-4: Alt+Left/Right at a collapsed caret calls kern_pair, and the
  Character panel field parses Metrics / Optical / a manual value; both tested.
- BUG-206-5 stays partial, not done: the round trips are covered, but no oracle
  corpus file is dedicated to kerning values, which that row also asks for.

FILE-216-5 keeps its missing status (no save-path tests for layered TIFF, PDF,
SVG, ACO/ASE, .shc or Smart Filters), but its note claimed there is no
atomic-save failure-injection test; format/src/atomic/tests.rs has two, and
BUG-203-6 already records them as done.

docs/scorecard.md regenerated with cargo xtask scorecard; Open bugs moves from
22/2/8 to 29/2/1.

* scorecard: the Type section's kerning and Thai rows are out of date too

TYPE-218-6 ("Optical renders as Metrics; Alt+arrow moves by word") is done:
Alt+Left/Right at a collapsed caret calls kern_pair, optical kerning is computed
from the glyph outlines in text/src/optical.rs and applied by layout.rs when the
run's mode is Optical, and both modes round-trip through PSD. Covered by
type_tool_alt_arrows_kern_the_pair, optical_matches_photoshop,
optical_kerning_tightens_open_pairs, txt2_carries_optical_kerning and
io/tests/psd_kerning.rs. The row points at #206, whose own rows are in the same
state.

TYPE-218-3 goes to partial, not done: Thai is covered (THAI_FAMILIES in fonts.rs
and thai_in_latin_font_falls_back_to_installed_thai_font, #1909), but Khmer and
Indic have no fallback list and there are still no per-script golden tests.

Unchanged because they read correctly: TYPE-218-1 (TextShape is still Point and
Box), TYPE-218-2 (no direction UI), TYPE-218-4 (the axes in the shell are the
brush's), TYPE-218-8 (no triple or quadruple click) and TYPE-218-9.

* scorecard: Tools and Distribution rows are out of date too

TOOL-213-7 ("Live stroke only for Brush and Eraser"): canvas.rs strokes_live
covers Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn and Sponge,
and live_retouch_command adds the Clone Stamp, Healing Brush and History Brush.

TOOL-214-8 ("ruler only feeds straighten"): image.rotation.arbitrary opens
seeded with the measured ruler angle and the direction that straightens it;
arbitrary_rotation_starts_at_the_ruler_angle checks 5.7106 deg within 1e-3 and
the direction for three lines.

TOOL-214-5 goes to partial, not done ("No context menu in canvas.rs or
transform_tool.rs"): TRANSFORM_MENU does open on a right click during a
transform, with Free Transform, Scale, Rotate, Skew, Distort, Perspective and
Warp, and three tests cover it — but the row also asks for Flip and
Rotate 90/180, which are not there.

DIST-220-2 ("release.yml only x86_64 and i686 msvc"): the Windows matrix has
{ arch: arm64, target: aarch64-pc-windows-msvc }, and windows-arm64.yml
installs that MSI on a Windows 11 ARM64 runner, checks the binaries are ARM64
and runs the CLI natively. Signing takes the same path as x64 (DIST-2).

Unchanged because they read correctly: the whole Automation section (ABI_VERSION
is still 1 with Kind::Filter only, there is no ONNX dependency, no such xtask
check, and agent_tasks.rs really does run ten scripted tasks), TOOL-213-12,
TOOL-214-4, TOOL-214-6, DIST-220-1 and DIST-220-3.

* scorecard: Workspace and UI and File compatibility rows are out of date too

UI-217-9 ("prefs.rs UiScale unused"): prefs::UiScale offers Auto plus 75 to 300
percent, display_scale resolves it and sync_display_scale applies it with
ctx.set_zoom_factor; being a preference, it is remembered. The zoom_factor
assert the note cites is in a canvas-zoom test, checking that zooming the
document does not scale the interface.

FILE-216-1 ("No corpus step in ci.yml; in progress in #190"): ci.yml has a
corpus job on every PR and on main, pinned, sha256-verified and cached by the
pins. #190 is merged.

UI-217-22 and UI-217-23 go to partial, not done:
- layer.stampVisible merges the visible layers into a new layer and leaves the
  originals, and layer.stampDown exists, but neither has a menu entry and
  Alt+Merge is still missing.
- layer.copyToDocument copies layers into another document keeping masks,
  styles and blending (#589), but there is still no copy/paste layer clipboard.

UI-217-11 keeps missing; only its note changes, from "five themes" to the eight
ThemeKind has. No Soft dark/Soft light, previews or outlined knobs.

Unchanged because they read correctly: UI-217-20 (guides are still Vec<f32> and
there is no rulers preference), FILE-215-6 (SmartObject.transform is still an
Affine), FILE-215-19 (no JPEG Options dialog), and the rest of both sections.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:10:23 -04:00
a874b149f2 Themes: Photoshop's light-gray and light Pro brightness levels (#2661) (#2892)
Photoshop has four interface brightness levels; PhotoCraft had the two
darkest (Pro, Pro Medium Gray). Add the other two with the Pro layout:

- Pro (Light Gray), id proLightGray: #B8B8B8 panels.
- Pro (Light), id proLight: #F0F0F0 panels.

They are light themes: Preferences > Interface lists them under Light
Theme, Window > Theme has an entry for each, and the legacy
interface.theme preference selects and migrates them. ThemeKind::is_pro
replaces the hard-coded Pro | ProMedium checks so the dock keeps the Pro
tab layout on every Pro level. Labels are translated in every catalog.

Tests check the panel greys, ids and names, the darkest-to-lightest
order, and WCAG contrast of text, icons and accent text on every surface.


Claude-Session: https://claude.ai/code/session_01CVtZHu998NDhf4Z8QvS5pd

Co-authored-by: ngrafton2006 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:09:05 -04:00
Valentin KleinandClaude Opus 5.5 e7c5df0d69 perf: keep resident GPU tiles when a brush dab grows a layer (#2900) (#2902)
A dab that painted past a layer's stored tiles changed its resident page
region, which recreated the texture and converted and uploaded every tile
of the layer in that page cell again. Copy the overlap from the old
texture on the GPU instead (in its own submission, ahead of the queued
tile writes) and keep its tile map, so only new or changed tiles upload.

32-bit float RGB/gray tiles also convert to RGBA16F straight from their
samples (same bytes as the general path) instead of decoding into a float
buffer and converting pixel by pixel.

P16 (32-bit, 20 layers) brush dab + refresh, median of 6 alternating runs:
p50 8.9 -> 3.1 ms, p95 27.2 -> 7.5 ms. P13 is unchanged (~1.6 / ~4.5 ms).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:01:03 -04:00
David MannandClaude Opus 5.5 167ea87ef1 Status bar: buttons sit inside the bar with centred labels (#2823) (#2876)
The Fit button was a full-size 30 pt button in the 30 pt Studio bar and
the zoom field a 24 pt box in the 24 pt Pro bar, so both touched or
overflowed the bar's edges. Inside every value field the theme's 24 pt
interact height and 4 pt button padding made the number taller than its
slot, so it sat low and, in a narrow box, spilled out of the left edge.

The status bar's controls now take a compact height (22 pt in Studio,
18 pt in Pro) from status_bar_heights, leaving even margins, through
new compact_value_field and compact_button widgets. A value field's
number slot is at least one text row tall and the number widget is
sized to it, so the digits sit 1 pt above the box's centre as intended.

With the bar at its true height the canvas shifted by 1 pt, which showed
that the live lasso ants were not on pixel centres; they are now snapped
like the marquee's.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 08:00:18 -04:00
Valentin KleinandClaude Opus 5.5 3e90c81e3c perf: Duplicate Layer recomposites only where the copy draws (#2883) (#2884)
Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:56:05 -04:00
SkyfullerandBrandon Thomas 51f0a287b1 Refine gradient stop controls in Properties and Gradient Editor (#2859)
* feat(ui): refine gradient fill properties

* feat(ui): use combined stops in Gradient Editor

* test(ui): restore cross-platform save path helper

* Refine Properties with a shared gradient ramp

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:54:47 -04:00
Valentin KleinandClaude Opus 5.5 9b3c6359ae perf: Select > Modify > Feather/Smooth on 24 MP within budget (#2873) (#2882)
The blur itself was already parallel (9-15 ms on 24 MP); most of the
command was serial glue around it. Modify copied the mask into a padded
buffer and back out sample by sample even with no padding, and combine
built a full-canvas byte buffer, scanned it, then copied it into tiles
on one thread.

- modify: use the mask as is when the selection is clear of the canvas
  edge (pad 0); pad and crop row by row otherwise.
- combine / mask_to_surface: fill selection tiles in parallel straight
  from the mask, a row at a time; fully selected tiles share one
  copy-on-write tile.

Bit-identical: the per-sample oracles (combine, now with whole tiles,
and the old pad/crop) are compared in tests.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:48:51 -04:00
c732ecd5b0 Dropdowns: press, drag onto an option and release to choose it (#2735) (#2854)
A dropdown needed two clicks: egui's ComboBox opens its list on a whole
click and its rows only take clicks, so press-drag-release (as the menus
do since #775) chose nothing. press_menu::PressCombo gives combo boxes the
gesture without replacing egui's widget:

- after the box, a primary press on it opens the closed list (egui counts
  a popup opened in a pass as open, so it is drawn from the next frame);
- a row released on by that press-drag is chosen and closes the list;
- on the click that ends the press (pointer still on the box), egui would
  toggle the list shut, so it is closed just before the box and egui's
  toggle reopens it, drawn that frame: a plain click still leaves the list
  open for a second click.

widgets::dropdown (and its hovered/wheel variants), dropdown_with_tooltips,
the font family picker, the kerning presets, the Gradient Map presets, the
shape stroke choices and the credits sort all use it.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:46:46 -04:00
Valentin KleinandClaude Opus 5.5 a030d19437 perf: repeated Select Subject answers from the last mask (#2865) (#2870)
Select Subject recomputed superpixels, saliency, GrabCut and the
full-resolution re-cut on every call (P29: 260-370 ms on 24 MP here,
693 ms p95 in the baseline). The session now keeps the last result,
keyed by the pixels it came from: `same_pixels` (tile pointers) against
a weakly held document, plus the sampled target (composite or layer).
Selection-only edits such as Deselect keep it valid; the mode
(replace/add/subtract/intersect) is still applied each time. One entry
per session, dropped on pixel edits and when its document closes.

Repeat: 7-10 ms p95 (was 270-370 ms). First run unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:44:00 -04:00
David MannandClaude Opus 5.5 79e8ad35a6 Text fields: drag-selecting in a single-line field follows the pointer's x (#1946) (#2821)
egui's TextEdit picks the character under the pointer with
Galley::cursor_from_pos, which returns the start of the text when the
pointer is more than 5 points above the first row and the end when it is
below the last one, for single-line fields too. So dragging up selected
everything left of the anchor and dragging down everything right of it.

A small egui plugin, installed by PhotocraftApp::setup_context, keeps the
pointer's y on the text row of the focused single-line field while the
primary button pressed in it is held, before egui sees the input. The x is
untouched, so the selection follows it, as in Photoshop and native fields.
It covers every single-line TextEdit, number fields being typed into
included; a multi-line field (taller than two widget heights) keeps its
vertical drag.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 07:41:27 -04:00
1d425ce8f9 Actions: check boxes switch single steps on and off (#2855) (#2866)
Photoshop's Actions panel has a check column: an unchecked step is skipped
by playback, Batch and droplets. PhotoCraft could reorder and delete single
steps but always ran every step.

- Action gains `disabled` (0-based step indices, omitted from actions.json
  when empty), kept in step with actions.move and actions.delete.
- New command actions.toggle {action, step?, enabled?}: one step, or every
  step like the action's own check; bad indices and types are errors.
- actions.play, the desktop playback path and the steps handed to Batch and
  Create Droplet skip unchecked steps; actions.get reports `disabled`.
- Panel: a check box per step, dimmed label when off, "2/3 steps" count.

Part of #219 (AUTO-219-2).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:39:35 -04:00
Valentin KleinandClaude Opus 5.5 017d1aea1c Tools: Perspective Crop (#1043) (#2864)
New Perspective Crop tool in the Crop flyout (C): drag a box or click four
corners, drag corners independently onto a skewed object, perspective grid
(Show Grid), Enter commits, Esc cancels. W/H/Resolution come from the Crop
tool's fields (Front Image, Clear), or the quad's mean side lengths.

Engine: image.perspectiveCrop {corners, width?, height?, resolution?,
interpolation?} maps the quad onto an upright canvas through the
quad-to-rectangle homography (Heckbert's square-to-quad map, inverted):
every raster layer, mask, alpha channel and the Quick Mask, one undo step.
The Background stays opaque over the background colour; type, shape, fill
and smart object layers are rasterized first; vector geometry follows the
nearest affine map. Non-convex, self-crossing, counter-clockwise or
degenerate quads are refused with an error.

algo: warp_surface_clipped bounds the per-tile warp by the new canvas even
when a layer reaches past the map's horizon; convex_quad, quad_size,
quad_to_rect.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:28:11 -04:00
2e0c426692 Tools: Artboard tool (#2858) (#2862)
Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:12:53 -04:00
1f64daeba4 Speed up large feathered mask refreshes (#2417)
* Speed up large feathered mask refreshes

* Format preference label for workspace check

* gpu test: allow float noise on a one-code vector-mask parity step

The combined mask is cached as U16 now; one pixel lands exactly one 8-bit
code apart (1.0000075/255), within the <=1/255 parity rule.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:09:04 -04:00
5866cd2cf6 Tools: Horizontal and Vertical Type Mask (#1055) (#2861)
The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal
Type Mask, in Photoshop's order. They type like the Type tools, with the
canvas under Quick Mask's colour and the glyphs clear while editing. On
commit the glyph coverage replaces the selection; no layer is left behind
and the whole session is one "Type Mask" history step. Escape always
cancels and leaves the selection unchanged.

The commit runs a new engine command, type.createSelection: a type layer's
glyphs (optionally removing that layer, the tools' hidden working text) or
type.create's keys directly, with new/add/subtract/intersect and an
anti-aliasing override. type.create's layer building is shared through
type_cmds::new_text_layer.

Original dashed-T icons (ATTRIBUTION.md), tool label translations in every
catalog, README tool list and the gaps/parity docs updated.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:06:10 -04:00
c436aa7294 Tools: Freeform Pen and Curvature Pen (#1054) (#2856)
The Pen flyout (P / Shift+P) now holds Pen, Freeform Pen and Curvature Pen.

Freeform Pen: a drag draws freehand; on release the trace is fitted with
cubic Beziers within the options bar's Curve Fit (default 2 px) by
photocraft_vector::trace::fit_trace (Schneider, "An Algorithm for
Automatically Fitting Digitized Curves", Graphics Gems 1990, the fitting
Make Work Path already uses), keeping turns sharper than 60 degrees as
corners. Releasing on the start point closes the path.

Curvature Pen: each click adds a smooth point and the path runs through
every point (Catmull-Rom style handles from
photocraft_vector::edit::curvature_knots); dragging a point moves it,
double-clicking toggles corner/smooth, clicking the first point closes,
Enter/Esc finish and Backspace removes the last point, as with the Pen.

Both commit through the Pen's path: a shape layer in Shape mode, else the
targeted vector mask, else the work path, as one History step.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:56:44 -04:00
3af4c46a18 View: Pattern Preview tiles the document around the canvas (#1067) (#2803)
View > Pattern Preview only toggled a flag that nothing read. With it on, the
canvas now draws the document repeated around itself as a seamless tile, like
Photoshop, on the GPU canvas and the CPU fallback:

- pattern_preview.rs works out which copies cover the visible area (at most
  31 per axis, centred on the view).
- GPU: each copy is another paint callback over the same document texture,
  offset by whole document sizes in the shader (ViewParams::offset, the spare
  view-uniform slots), so nothing is composited again and seams are exact.
- CPU: the same texture drawn again with a shifted view mapping.
- The drop shadow is off while tiling; selection, guides and other overlays
  stay on the document itself.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:51:50 -04:00
bacc825d33 Move tool: drag a marquee from an empty spot to select the layers it touches (#2844) (#2852)
With Auto-Select on (or Cmd held with it off), a Move-tool drag that starts where
no layer has pixels (an empty spot, or the pasteboard) now draws a marquee instead
of moving the previously selected layer. Releasing selects every layer whose
content touches it (Auto-Select: Group selects their outermost groups); Shift adds.

New engine command layer.selectInRect; the UI side lives in move_box.rs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:47:53 -04:00
20dab123fb Free Transform: oriented rotate cursor and live transform readout (#1767) (#2809)
The rotate zone drew its own fixed arc with hard-coded black and white,
and the angle was bare white text that vanished over light images. W/H
and the move offset were not shown at all.

The rotate zone now uses the Crop tool's turn cursor (theme tokens),
bent round the nearest corner through the view, so it follows a turned
or flipped view. While dragging, the shared rounded readout beside the
pointer shows the angle (document-relative, one decimal), W/H in
percent while scaling, or the offset while moving, like Photoshop's
Show Transformation Values. live_readout() exposes the value for tests.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:40:41 -04:00
Valentin KleinandClaude Opus 5.5 7609834df8 Channels: Edit › Transform works on a targeted alpha channel (#2835) (#2845)
edit.transform without a "target" (or "layer") param now follows the
Channels panel: a targeted alpha channel or the Quick Mask transforms
instead of the layer. Edit › Transform › Rotate 180°/90° and Flip
Horizontal/Vertical are routed to the panel target like the paint and
filter commands, so they turn a targeted alpha channel, the Quick Mask or
an unlinked layer mask within its frame (one undo step). The Background
no longer greys the transforms when a channel is targeted.

Flips scorecard row BUG-207-4.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:38:36 -04:00
d79fc5e8ef Free Transform: right-click menu with the transform modes, rotate and flip (#2832) (#2840)
The Free Transform context menu listed only the modes. It now follows
Photoshop's order: the modes, then Content-Aware Scale and Puppet Warp
(greyed while a box is open), Rotate 180°, Rotate 90° CW and CCW, and Flip
Horizontal and Vertical.

The Rotate and Flip items, whether chosen from this menu, Edit › Transform,
a shortcut or the control channel, now turn or mirror the live box about its
reference point without committing it (transform_tool::intercept). Before,
they ran the engine preset on the layer under the open box. They are greyed
while warping. Distort and Perspective are greyed over a box on editable
type, matching #2701. A control-channel right-click during a transform opens
the same menu as the canvas.

Scorecard TOOL-214-5 is now done.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:30:33 -04:00
830541ded2 Options bar: Reset Tool and Reset All Tools (#2825) (#2830)
* Options bar: Reset Tool and Reset All Tools (#2825)

Right-click the tool icon at the far left of the options bar for Reset
Tool (the current tool's options back to their defaults) and Reset All
Tools (asks "Reset all tools to their default settings?", then resets
every tool's options), as in Photoshop. Both are shell commands,
tool.reset and tool.resetAll, so the control channel can run them;
tool.resetAll opens the confirmation as a dialog, or skips it with
{"confirmed": true}.

ToolOptions is one struct, so tool_reset::reset_options maps each tool
to its fields; fields shared between tools (Tolerance, Anti-alias,
Feather, Strength, ...) reset with any tool that shows them. Selection
tools also go back to New Selection, and painting tools get the default
brush (colours kept).

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

* Reset Tool: cover Color Replacement, the anchor point tools and the crop custom ratio

Tools and options that landed on main since the branch was cut.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:26:26 -04:00
203b5796b4 Tools: remember tool options between launches (#2814) (#2822)
* Tools: remember tool options between launches (#2814)

The options-bar settings in ToolOptions (Move > Auto-Select, marquee
Feather, tolerance, gradient style, crop ratio, eyedropper sample, ...)
only went through serde for Tool Presets, so every launch started from
the defaults. Photoshop keeps each tool's options between sessions.

Preferences gain `toolOptions` (shell-owned JSON, like brushPicker): the
fields that differ from their defaults, saved once the pointer is up and
restored at launch field by field. An unknown key is ignored; a field
that doesn't read back (wrong type, a number out of f32 range) keeps its
default with a log warning.

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

* Merge upstream/main into fix/2814-persist-tool-options; regenerate docs/scorecard.md

The branch adds a preference, so the prefs audit count in docs/scorecard.md changes
(CI's xtask scorecard staleness test).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:20:17 -04:00
mattyandBrandon Thomas ac19db1046 TGA: targa options asks for 24 or 32 bits/pixel, same as in Photoshop (#2760)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:17:53 -04:00
Valentin KleinandClaude Opus 5.5 273ff014e3 Layers: double-click a shape layer's thumbnail to pick its fill colour (#2812) (#2820)
Like Photoshop, double-clicking a solid-colour shape layer's thumbnail (or
Layer > Layer Content Options...) opens the Color Picker on the shape's fill.
The canvas previews the pick, Cancel changes nothing and OK commits one
undoable shape.edit with the new fill (keeping the fill's alpha). Gradient,
pattern or no-fill shapes keep opening their Properties controls.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:12:57 -04:00
Valentin KleinandClaude Opus 5.5 1608b7b309 Spot Healing: Content-Aware dab no longer scans every patch in full (#2771) (#2805)
A Content-Aware Spot Healing dab spent ~70 % of its time in inpaint's
onion_peel: at the coarsest pyramid level every hole pixel computed the
full 7x7 SSD against every valid source patch. The pyramid and the
sampling window scale with the dab, so this cost ~200-450 ms whatever
the brush size.

The scan is still exhaustive and picks exactly the same source (first
minimum in source order): the target's known offsets and samples are
gathered once, the filled neighbours' sources (shifted, as in PatchMatch
propagation; every 16th source at the hole's edge) give an upper bound,
and each candidate's sum is abandoned once it can no longer win. Sums
keep their order and only grow, so the result is bit-identical.

PatchMatch: the vote weight exp(-cost/2s^2) is computed once per target
instead of once per covered pixel, and dist has a fast path (row slices,
same summation order) for patches fully inside a level with no void.

retouch_bench (6016x6016 8-bit, best of 3 rounds x 5 dabs, loaded
machine): 50 px 178 -> 63 ms, 100 px 190 -> 75 ms, 200 px 226 -> 118 ms,
300 px 239 -> 186 ms; 4.5x fewer instructions. Healed pixels of all
three Spot Healing types are bit-identical at 8 and 16 bit.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:08:21 -04:00
Valentin KleinandClaude Opus 5.5 b7b3117aea Droplets: carry the actions their Play Action steps call (#2794) (#2800)
* Batch: a failed step inside a called action fails the file and the script (#2779)

Scripts (File › Scripts › Browse, Script Events) ignored the `failed` entry that
`actions.play` returns when a step inside the called action fails, so they reported
ok and kept going. Batch and droplets ran each file in a scratch session without
the actions list, so any nested action call failed with "no action named". The
scratch session now gets the caller's actions and playback stack (an action that
batches itself is caught as recursive), and both runners check nested_failure.

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

* Droplets: carry the actions their Play Action steps call (#2794)

createDroplet saved only the chosen action's own steps, so a droplet whose
action plays another action failed on every file when run outside the
creating session (photocraft-cli droplet and the .command shim run it in a
fresh Session with no actions).

createDroplet now embeds the called actions, followed transitively with a
seen-set and the 16-level playback cap, under "calledActions" (not
"actions", which the CLI reads as an action set). Index references become
names. runDroplet passes them to file.automate.batch, whose per-file
sessions then play from them. Droplets without the key behave as before.

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

* Droplets test: build Actions from JSON so the test survives new Action fields

#2866 adds Action.disabled; a struct literal here would stop compiling once both land.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:06:20 -04:00
Valentin KleinandClaude Opus 5.5 009bef76e7 CLI: batch plays an action from an Actions set with the whole set loaded (#2786) (#2792)
`batch --actions` ran each file in a fresh headless session with no
actions, so a step that plays another action (`actions.play`) failed for
every file, and the Actions panel's own actions.json could not be passed
at all. `--actions` now also takes an Actions set; `--action <name>`
picks the action to play and every per-file session gets the whole set.
A failed step inside a called action fails the file instead of saving it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:01:07 -04:00
80519e1a17 Type: ⇧⌘> / ⇧⌘< change the type size (and work on Nordic/German layouts) (#2402)
* feat(type): Cmd+Shift+> / < change the type size, Alt for 10 pt steps

Photoshop's type size shortcuts: the selected characters while typing,
else the whole active type layer, in the points the layer shows. The
period and comma keys match by position so the shortcuts also work on
Nordic and German layouts, where Shift+. types a colon.

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

* test(type): bad size steps fall back or clamp

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 05:56:28 -04:00
aa509ca966 UI fonts: lazy fallback for Thai, Arabic, Indic and other scripts (#2275) (#2758)
Layer names (and any other UI text) in scripts the bundled Inter lacks
showed missing-glyph boxes even when the canvas found a fallback font.
The lazy UI loader only knew CJK plus a short hard-coded list of Thai
font paths, so any other script, or a Thai font at another path, never
got a fallback.

photocraft_text::scripts classifies 20 scripts and picks candidate files
by name (Noto, Windows, macOS and common Linux families) from a listing
of the platform font directories, made once per process on first need.
The UI loader tries a script once when one of its characters is missing,
reads at most a few candidates, and registers the first face whose cmap
really maps the character. Latin text loads nothing; the web build has
no candidates and does nothing.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:55:39 -04:00
2189a1610e fix(crop): stabilize resizing and apply ratio changes immediately (#3206)
Compute snapped handles from press-time geometry to avoid feedback from
Auto Center Preview. Paint the image and crop frame with the same camera.

Apply ratio and size options immediately as centred rectangles contained
in the current frame. Respect saved ratios, keep Swap and X consistent,
and let Enter apply output sizes without an initial resize.

Use container padding for the options bar's text allowance so right-aligned
crop actions do not introduce horizontal overflow.

Add regression coverage for handle snapping, rendered frames, preset/Enter
and Swap/X interactions, and options-bar scrolling at multiple UI scales.
Enable public GitHub profile name syncing for TheTeslak's contributor credit.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:54:09 -04:00
d96bb50161 Add Anchor Point and Delete Anchor Point tools (#1044) (#2756)
Paths could be reshaped after drawing since #865, but the number of
anchors could not change. Two new engine commands do that, and two tools
in the Pen flyout drive them on the canvas.

- photocraft_vector::edit::add_anchor splits the segment at t with de
  Casteljau, so a curve keeps its shape and gets a smooth anchor; a
  straight segment gets a corner on the line. delete_anchor joins the
  neighbours keeping their handles, drops an emptied subpath and refuses
  to remove a path's only anchor.
- path.addAnchor / path.deleteAnchor take the same targets as the other
  point edits (work path, saved path by name, shape layer path, vector
  mask) and record one history step each.
- Tool::AddAnchorPoint / DeleteAnchorPoint join the P group after the Pen.
  They act on Direct Selection's paths, highlight the segment or anchor
  under the pointer, show a +/- cursor badge, swap with Alt and turn into
  Direct Selection with Cmd/Ctrl. An edit clears a Direct Selection on the
  same path so it never points at a shifted anchor.
- Icons derived from Lucide pen-tool (ATTRIBUTION.md), hints and tool
  names in all 16 catalogs, README tool list, roadmap.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 05:48:07 -04:00
303b7849e8 raw: fit non-DNG colour to the camera's embedded JPEG (#2135)
* raw: fit non-DNG colour to the camera's embedded JPEG

* raw look: clippy (as_chunks) and rustfmt after merging main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:45:46 -04:00
1d67cae1ba Keep custom crop ratio fields editable (#3209)
* Keep custom crop ratio input fields

* Persist custom crop ratio values

* Wire custom ratio edits to persistent option state

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:41:44 -04:00
59da288daa fix(type): preserve formatting for trailing empty paragraphs (#3198)
* fix(type): preserve formatting for trailing empty paragraphs

Represent an explicitly styled terminal empty paragraph with a zero-byte paragraph run, keep it through normalization, style only the caret's paragraph, and transfer that style when text is inserted at EOF. Cover line geometry, typing, and PSD text serialization.

* fix(type): retain imported terminal paragraph marker style

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:36:52 -04:00
11817fcb12 Test Move layer edges across bit depths and view modes (#2738)
* Test Move layer edges across bit depths and view modes

* Layer Edges tests: set ViewXform's new aspect field after merging main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:36:25 -04:00
468b8f2792 Crop tool: New / Delete Crop Preset saves and removes user presets (#1919) (#2723)
* Crop tool: New / Delete Crop Preset saves and removes user presets (#1919)

The Crop options-bar dropdown ends with New Crop Preset... and Delete Crop
Preset..., as in Photoshop. A crop preset is a "crop" tool preset
(tool.presets.*, persisted with the preferences), so it also shows in the
Tool Presets panel. New saves W x H x Resolution (W, H, resolution and
unit) or a ratio under a name made from the values; it is greyed out with
nothing to save. Delete lists the saved crop presets for a multi-select;
built-in size presets stay. The dropdown shows the preset the fields hold.

Engine: tool preset names are checked (1-255 chars) and the store capped;
saved tool presets load entry by entry so a damaged one is skipped; a
multi-name delete is all or nothing. ui.dialog.open gains newCropPreset /
deleteCropPreset.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Regenerate the scorecard after rebasing onto main

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 05:31:19 -04:00
8a9f4722d2 fix(shapes): preserve transformed outline on unchanged live geometry edits (#3197)
* fix(shapes): preserve transformed outline on unchanged live geometry edit

A nonuniform scale stores an anisotropic outline but live rounded rectangles only store scalar corner radii. Avoid regenerating the path when all live values are unchanged, preserving transformed corners and PSD vector source. Cover no-op edits, real radius edits and undo at all depths.

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:23:55 -04:00
073e1cc1d2 Add the Color Replacement tool to the Brush toolbar group (#1045) (#2719)
The engine already had paint.colorReplacement, but the desktop UI had no
tool for it: no Tool entry, no toolbar slot, no options bar and no stroke
routing, so only agents and the CLI could use it.

Tool::ColorReplacement joins the Brush (B) flyout after the Mixer Brush,
so B / Shift+B cycle to it and ui.set / ui.inspect name it. Its options bar
has Mode, Sampling (Continuous / Once / Background Swatch), Limits,
Tolerance and Anti-alias, all mapped to the command's params, with the
foreground as the replacement colour. Strokes are drawn live through
LiveDab, which can now preview the stroke's finishing dab, so the preview
at the last move matches the commit. The command refuses a mask, channel
or Quick Mask target instead of silently recolouring the layer. New icon
derived from Lucide's brush; label translated in all 16 catalogs.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 05:20:00 -04:00
Nigel Ihza Firdausy d8217b3476 fix(image): keep smart-filter masks aligned through Image Size, Canvas Size and Crop (#3195) 2026-10-11 05:18:52 -04:00
094d0776d1 Improve zoomed Liquify preview detail (#2715)
* Improve zoomed Liquify preview detail

* Record Liquify quick benchmark results

* Keep the roadmap as on main; record the Liquify numbers in target-app-parity.md

The branch carried an old copy of the roadmap's parity tables, which have moved
to docs/target-app-parity.md on main.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:03:10 -04:00
09d079278e fix(video): retain original footage for Restore Frame and Show Altered Video (#3192)
* fix(video): retain original footage for restore and comparison (#3059, #3060)

* Clamp original-frame inserts to the original stack; cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:02:29 -04:00
Reynold HuandBrandon Thomas 67e3d9be9f feat(ui): Font Preview Size drives the font menu samples (#204) (#2668)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:52:17 -04:00
6cfed3a7dc fix(masks): bake feathered coverage when applying pixel masks (#3044) (#3191)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 04:49:03 -04:00
e46f119e03 i18n test: scan past small test modules; translate what it was missing (#2660)
every_tl_literal_is_translated cut each source file at its first `#[cfg(test)] mod`, so a small
test module early in a file hid every tl!("…") after it. panels.rs has one around line 540,
which left most of the toolbar options, the Layers panel and the Properties panel unchecked:
112 literals were never scanned.

The scan now removes each test module on its own (brace counting; a #[cfg(test)] on a single
item still doesn't count), with a unit test for the helper.

The strings it now finds are translated: 69 in Russian (most of the Layers panel and toolbar
tooltips), and the crop, lasso, zoom and fill hints in cs, de, es, fr, id, it, ja, ko, pl, pt-br,
uk, zh-hans and zh-hant. Translations reuse each catalog's existing terms; a native review is
welcome.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:39:45 -04:00
01833cfac2 fix(actions): avoid replaying Browse script edits twice (#3190)
* fix(actions): avoid replaying Browse scripts twice (#3057)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 04:38:07 -04:00
21326ded43 fix(vector): preserve scaled live-line thickness during endpoint edits (#3184)
* fix(vector): keep scaled live-line weight after editing endpoints (#3063)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 04:31:47 -04:00
Reynold HuandBrandon Thomas 93f2ff835a feat(ui): Beep When Done plays the platform beep for finished jobs (#204) (#2658)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:29:59 -04:00
a9aabcdfde fix(vector): pivot explicit path transforms on target layer (#3062) (#3183)
* fix(vector): pivot explicit path transforms on target layer (#3062)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 04:23:08 -04:00
233237e9dc Reject bad params in the control channel's synthetic input methods (#2449) (#2656)
ui.pointer, ui.click/ui.move, ui.key and ui.screenshot read every value
with as_f64/as_bool/as_str and fell back to a default on any other type,
and an unknown button or event kind fell through to a left click or a
move. A typo such as "button":"ritgh" ran a different action and still
replied ok.

Each method now validates its whole request before acting, like ui.set:
unknown fields (top-level, under modifiers, or in an event), wrong JSON
types, unknown button/kind/tool values (the error lists the accepted
ones) and out-of-range pressure, tilt and rotation return an error that
names the parameter, and nothing is queued or applied. Null fields still
mean omitted, and valid calls behave as before.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:18:18 -04:00
f6dd0aa6f3 fix(io): preserve group fill opacity in OpenRaster exports (#3181)
* fix(io): preserve group fill opacity when exporting OpenRaster (#3048)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 04:16:30 -04:00
458870dadc Auto-Blend Stack Images: fuse the merged layer per pyramid level by region energy, with halo control (#2044)
* Auto-Align Layers: register focus and exposure brackets by intensity when there is nothing to match

Feature matching fails outright on layers that nearly coincide (a focus bracket: sub-pixel shifts, up to a few percent of scale from focus breathing, much of every frame defocused). `photocraft_algo::direct` registers the target to the reference coarse-to-fine on a Gaussian pyramid of the luminance, the cost the mean-removed RMS of the warped difference over the pixels both cover, by a bounded Nelder-Mead search with a per-level schedule; the pose is a similarity about the centre, affine or projective per the projection, and resolution independent, so the search stops short of full resolution. `edit.autoAlignLayers` gets `registration`: auto (features, then intensity when nothing matches), features, intensity; layers register outward from the reference, each to the previous one aligned.

Closes #2064.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Intensity registration: reject a no-fit, search on coarsened planes

Review follow-up on #2065:
- `direct::cost` also returns the correlation of the reference with the
  warped target over the overlap; `Registration.correlation` carries it.
  Unlike the mean-removed RMS it is exposure independent, so it tells a
  bracket (near 1) from unrelated pictures (near 0).
- `register_direct` fails with the existing "couldn't find enough matching
  detail" error when a layer's best pose overlaps its neighbour by less than
  a quarter of the plane or correlates below 0.5, instead of placing it at
  whatever pose the search box ended at.
- The luminance planes are built at the search scale (`luma_alpha` with the
  reduction factor, ≤ 2048 px across) and the warp of each registered layer
  runs there too; the pose is resolution independent and its matrix at full
  resolution places the layer. Nothing full-resolution is held.
- The reference index is checked once with `get`; no unclamped indexing.
- Tests: correlation at an exposure step, for unrelated textures and a flat
  plane; the engine rejects unrelated layers and a mostly empty one.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Auto-Blend Stack Images: fuse the merged layer per pyramid level by region energy, with halo control

The merged layer of Edit › Auto-Blend Layers › Stack Images was the per-pixel masks blended with a multiresolution spline, so a wrong pick in the mask reached every scale. `pyramid::fuse_stack` fuses the layers on the Laplacian pyramid instead: per coefficient the layer with the largest region energy of its luminance band wins (Adelson et al. 1984's multifocus composite with Wang & Chang 2011's rules), the residual follows local deviation + entropy, and with halo control the levels coarser than the guide are weighed by the guide's energy so a bright object's defocused glow stays out. `haloControl` (0..8, default 2) on `edit.autoBlendLayers`; the masks and the panorama method are unchanged.

Closes #2043.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:13:11 -04:00
5c9d9116bc fix(layers): select Shift ranges in visible panel row order (#3041) (#3180)
* fix(layers): select Shift ranges in visible panel row order (#3041)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 04:12:27 -04:00
Lyraedan f11380d5bd Double-clicking a mask thumbnail in the Layers panel now opens that mask's settings (#2654)
* Double-click a layer mask thumbnail to open its settings

Double-clicking a mask thumbnail in the Layers panel now opens that mask's settings in the Properties panel, matching Photoshop. When the Preferences ▸ Tools ▸ "Double Click Layer Mask Launches Select and Mask" option is on (the current default) a pixel mask still launches Select and Mask, exactly as before; with it off, double-clicking targets the mask and opens its Properties (Density/Feather). Vector masks now behave consistently too: double-clicking one targets it and opens a new Vector Mask Properties section.

* Fixed syntax issue from merge

* Fixed missing closed brace in ui tests
2026-10-11 04:11:35 -04:00
ragtuxandClaude Fable 5.1 ec60f43b5b Auto-Align Layers: register focus and exposure brackets by intensity when there is nothing to match (#2065)
* Auto-Align Layers: register focus and exposure brackets by intensity when there is nothing to match

Feature matching fails outright on layers that nearly coincide (a focus bracket: sub-pixel shifts, up to a few percent of scale from focus breathing, much of every frame defocused). `photocraft_algo::direct` registers the target to the reference coarse-to-fine on a Gaussian pyramid of the luminance, the cost the mean-removed RMS of the warped difference over the pixels both cover, by a bounded Nelder-Mead search with a per-level schedule; the pose is a similarity about the centre, affine or projective per the projection, and resolution independent, so the search stops short of full resolution. `edit.autoAlignLayers` gets `registration`: auto (features, then intensity when nothing matches), features, intensity; layers register outward from the reference, each to the previous one aligned.

Closes #2064.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Intensity registration: reject a no-fit, search on coarsened planes

Review follow-up on #2065:
- `direct::cost` also returns the correlation of the reference with the
  warped target over the overlap; `Registration.correlation` carries it.
  Unlike the mean-removed RMS it is exposure independent, so it tells a
  bracket (near 1) from unrelated pictures (near 0).
- `register_direct` fails with the existing "couldn't find enough matching
  detail" error when a layer's best pose overlaps its neighbour by less than
  a quarter of the plane or correlates below 0.5, instead of placing it at
  whatever pose the search box ended at.
- The luminance planes are built at the search scale (`luma_alpha` with the
  reduction factor, ≤ 2048 px across) and the warp of each registered layer
  runs there too; the pose is resolution independent and its matrix at full
  resolution places the layer. Nothing full-resolution is held.
- The reference index is checked once with `get`; no unclamped indexing.
- Tests: correlation at an exposure step, for unrelated textures and a flat
  plane; the engine rejects unrelated layers and a mostly empty one.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-11 04:02:26 -04:00
Nigel Ihza Firdausy 37929405df fix(history): restore empty layer target on undo and redo (#3042) (#3179) 2026-10-11 04:02:23 -04:00
Reynold HuandBrandon Thomas fa2fe1c90f feat(ui): the brush HUD honors Vary Round Brush Hardness and Brush Preview Color (#204) (#2640)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 03:59:04 -04:00
64ec252dbe fix(layers): remap linked-layer groups when copying between documents (#3178)
* fix(layers): remap link groups when copying across documents (#3045)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:48:33 -04:00
cf83e05821 fix(psd): Improve interoperability and decoding (#1955)
* fix(psd): Align global tagged blocks to four bytes

Use four-byte defaults for global blocks and streamed layer information.
Read global payloads without full copies while preserving block order,
explicit padding and the first layer candidate. Keep two-byte layer
alignment; test canonical padding and lossless PSD/PSB parsing.

* fix(psd): Preserve transparent composite colours and precision

* fix(psd): Check ZIP integrity and bound layer previews

* fix(io): Use document CMYK profiles for PSD fill pixels

Generated fill pixels used the default CMYK separation even when the PSD
embedded a different profile. Scope layer export to the document colour
space so cached pixels and the composite use the same profile.

Check native cached samples against an explicit ICC transform at 8 and
16 bits.

* io: refuse an empty RGB composite before splitting it into planes

chunks_exact(0) panics; an empty decoded composite now returns an error.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:46:52 -04:00
cfd12263af Rulers: keep tick marks and labels out of the origin corner (#2617)
* Rulers: clip tick marks and labels outside the origin corner

* Rulers test: set ViewXform's new aspect field after merging main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:46:29 -04:00
Nigel Ihza Firdausy 3188866b25 fix(masks): retain off-canvas alpha in From Transparency (#3043) (#3177) 2026-10-11 03:43:18 -04:00
2785699464 fix(type): use the correct line at soft-wrap caret boundaries (#3174)
* fix(type): use the next visual line at shared wrap boundaries

At soft-wrap and forced-break character offsets, resolve the caret's visual line from the last matching line rather than the preceding one. Make line-edge navigation share that choice and add regression tests for wrapped, forced and paragraph breaks.

* test(type): use actual break characters in caret boundary regressions

* fix(type): preserve the visual line when hit testing a shared wrap offset

Keep caret index preference on the following line, but use pointer geometry for click-hit line identity at an adjacent line's end.

* Update the caret round-trip test for wrap-boundary lines; cargo fmt

The existing round-trip helper expected the first line containing a
shared soft-wrap offset; the caret now reports the following line, where
it is drawn and where hit testing lands.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:37:27 -04:00
Reynold HuandBrandon Thomas de58a0a778 feat(ui): the Type units preference drives the type metric units (#204) (#2587)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 03:37:06 -04:00
334ae5779c Enhanced Controls: wire Zoom with Trackpad Pinch (#204) (#2577)
* feat(ui): Zoom with Trackpad Pinch gates the canvas pinch (#204)

* Navigator: pass the pinch preference to wheel_nav::read (new caller on main)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:26:07 -04:00
ee3839955b fix(type): exclude text inside hidden groups from automatic hit testing (#3173)
* fix(type): skip text in hidden ancestor groups during automatic hit testing

Honor the effective visibility of every ancestor when locating a type layer by point. Explicit layer targeting remains available, with a regression test for nested hidden groups.

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:17:46 -04:00
Fabian GhenofandClaude Opus 5.5 c54b3c4a02 Render Video: animated GIFs keep their transparency (#3061) (#3162)
Animated GIF export quantised every frame with no transparent index, so a
transparent document came out opaque black. Each frame now does what Save for
Web's GIF does: pixels under 50% opacity get a transparent index, the rest are
drawn opaque, and the palette comes from the visible pixels with one entry
kept for transparency.

The encoder also left every frame in place (disposal 1). A frame with a
transparent index is now cleared before the next one (disposal 2); otherwise
earlier frames would show through it once transparency works.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:12:26 -04:00
LuniiyaandBrandon Thomas e44d96e1d4 Favorite themes from the appearance button's right-click menu (#2445)
* feat(ui): favorite themes from the appearance button's context menu

Hovering a theme in Preferences > Interface shows a star; clicking it saves
the theme to interface.favoriteThemes (filled accent star once favorited).
Right-clicking the title-bar appearance button opens a menu with Appearance
Settings, then the favorite light themes and the favorite dark themes.

* Regenerate the scorecard for the favorite themes pref

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 03:09:47 -04:00
f9c5486ffe fix(shortcuts): reject malformed bindings and correctly display Plus (#3006)
* fix(shortcuts): reject malformed key bindings and format Plus

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:05:09 -04:00
Ahmed Hosny c25630b5fa app.save: encode on the save worker, not the UI thread (#2488) (#2517)
* app.save: encode on the save worker, not the UI thread (#2488)

The automation save ran export+write inline inside the control drain,
freezing the window and every queued control request while a large
document encoded — and overrunning the 60s reply deadline. It now uses
the same #2017 background path as File > Save when background jobs are
on: the encode and the capability-scoped write run on a worker, the
document is locked, and the control reply waits on the job (wait: false
answers {job, pending} at once). Tests and the web keep the inline path.

The save job's result now carries the path alongside the warnings, so
waiting callers get the same {path, warnings} reply both ways.

* app.save: never fire saveDocument hooks from automation saves; review fixes (#2488)

Automation saves complete in the job tick, long after run_waiting lifted
its synchronous event suppression, so the background branch fired the
user's saveDocument script bindings and Image Assets — the exact hooks
docs/control-protocol.md promises never run for automation. SaveJob now
carries an automation flag and records through saved_automation, which
skips document_saved on every path; the inline fallback records through
it too, so both modes behave the same. A regression test installs a
saveDocument binding and asserts it fires for an interactive save and
not for app.save.

The desktop's two save workers collapse into one save_through parameterized
by the write step (drift between interactive and automation saves), the
Saved status/notices are translated (Saved {name}, 16 locales), the pending
reply carries warnings like file.save's, and the success payload is built
at one site.
2026-10-11 02:54:14 -04:00
Nigel Ihza Firdausy d5bab13674 fix(transform): commit Free Transform on an outside click (#2909)
* fix(transform): accept a free transform on an outside click (#2851)

* fix(transform): accept outside clicks in the rotation zone (#2851)
2026-10-11 02:51:59 -04:00
Nigel Ihza Firdausy 03d45a80ee fix(brush): preserve pen pressure when joining Shift-click strokes (#2827) (#2890) 2026-10-11 02:46:01 -04:00
Tanish Manku c6a525cc99 Releasing a brush stroke no longer freezes: the commit takes the pixels the live stroke already drew (P32: 136 ms → 0.8 ms) (#2131)
* Releasing a brush stroke no longer freezes: the commit takes the pixels the live stroke already drew (P32: 136 ms → 0.8 ms)

Brush phase 4. On release the canvas dropped its live stroke and ran `paint.stroke` over every
point again (the whole stroke re-rendered), then re-composited the stroke's bounding box, although
the canvas already showed exactly those pixels. Long strokes froze for 100–250 ms at mouse-up
(#2068).

- `Session::offer_live_stroke` hands the finished live stroke to the next command. A
  `paint.stroke` / `paint.pencil` whose command, document revision, params, seed and points match
  it exactly takes its pixels instead of rendering (result `"fromLive": true`). Anything else
  ignores the offer and renders as before. It still runs through the normal dispatch
  (authorization, journal, one history step), and a replay renders identical pixels.
- The canvas feeds the release frame's last points to the live stroke first, then remembers the
  stroke's per-step damage for one revision (`CommittedStroke`): each canvas cache re-composites
  only the steps it hadn't shown, usually nothing, instead of the bounding box.
- P32 (brush release on a long stroke) becomes measurable: perf_scenarios times a 600-point
  200 px soft stroke on 24 MP. 136 ms → 0.83 ms on the GPU canvas, 257 ms → 0.71 ms on the CPU
  canvas (RTX 5090, release). Budget 16 ms, not enforced until a baseline run.
- Scorecard: TOOL-213-10 (⌃⌥-drag resize with readout) is done; TOOL-213-7 (live strokes for every
  brush-based retouch tool) is partial (Spot Healing, Mixer Brush, Background Eraser and Pattern
  Stamp still aren't live).

* Mask view test: a commit that takes the live stroke uploads nothing it already showed
2026-10-11 02:42:12 -04:00
ea4579ea11 fix(canvas): update Move transform-control cursors on first hover (#2887)
* fix(canvas): show Move transform control cursors before first interaction (#2871)

* Fix the Move transform-controls cursor test after merging main

ViewXform gained an aspect field on main, and the rotation-band probe sat
within the top-centre handle's grab radius; probe between handles instead.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 02:39:28 -04:00
1ccdac4c94 Show camera data in File Info (#2424) (#2692)
File Info only read the editable XMP description fields, so the camera
settings stored in the file's EXIF block (JPEG APP1, PSD resource 1058)
never reached the dialog or file.fileInfo.

file.fileInfo now returns a read-only "camera" object (make, model, date
taken, exposure, f-stop, ISO, focal length, 35 mm focal length, lens),
formatted for display (1/250 s, f/8, ISO 400, 33.1 mm). It reads the
existing photocraft_algo::exif parser, which gains DateTimeOriginal, and
falls back to the XMP tiff:/exif:/aux: properties when there is no EXIF
block. The dialog shows the values in a Camera Data section; labels are
translated in all 16 catalogs.

The EXIF reader now uses checked offset arithmetic and requires a tag's
whole value to lie inside the payload, so hostile counts and offsets
cannot overflow on 32-bit targets (wasm) or read past the data.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 02:26:29 -04:00
e59bb2909b feat(ui): allow disabling Studio canvas background dots (#2317)
* feat(prefs): persist an optional canvas dot pattern setting (#2264)

* feat(canvas): respect interface canvas dot preference (#2264)

* test(prefs): cover canvas dot toggle persistence and reset (#2264)

* Translate the Show canvas dots preference and regenerate the scorecard

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 02:15:06 -04:00
Elvis33LE 29f1ff9e90 Design: bounded GPU stack prefix reuse for property edits (#1164)
* docs: design bounded GPU stack prefix reuse

* docs: advanced blending stays on the CPU; cache admits whole grid chunks and serves partial rects
2026-10-11 02:08:08 -04:00
a8ad82dd46 GPU composite: reuse unchanged lower-stack chunks for property edits (#1169)
* perf(gpu): reuse unchanged lower-stack composite chunks

* test(gpu): verify half-float composite checkpoint reuse

* docs: record measured GPU composite reuse gains and limits

* perf(gpu): admit whole grid chunks, serve partial damage as sub-rect copies

Cache entries were keyed by the exact damage rectangle, so brush strokes
(a new partial rect every frame) filled the budget with entries that
never hit again. Entries are now whole chunk-aligned, chunk-sized grid
cells: a capture merges into the cell it overlaps at the rectangle's
offset, a read is served as a sub-rect copy of the covered part, and
coverage is tracked per cell so a resume only happens where every pixel
of the requested rectangle is cached. After a full render, any in-bounds
partial rectangle hits.

Regression test: two series of disjoint strokes inside the same chunks
leave composite_cache_bytes unchanged (no entry-per-rectangle growth),
a full refresh still finds its chunks and a top-layer edit afterwards
reuses them. The old partial-damage test now expects the covered-chunk
hit and checks outside-the-document renders admit nothing.

* docs: record the GPU reuse measurement in target-app-parity.md

The honest assessment moved from roadmap.md to target-app-parity.md on
main; keep main's roadmap and add only the dated measurement.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 02:07:48 -04:00
skylar taylor-barrick 97136485f0 Merge pull request #3097 from skylartaylor/codex/ui-suite-integration-20261010
Adopt shared docking while preserving panel controls and workspaces
2026-10-10 21:40:26 -06:00
skylar taylor-barrick d0a18cfd83 UI.9: keep browser bundle under size budget on current main 2026-10-10 20:56:34 -06:00
Brandon ThomasandClaude Opus 5.5 7926161595 Release prep 0.7.0: Linux riscv64 tarball + version bump (#2965)
* Release prep 0.7.0: add Linux riscv64 tar.gz to the release; bump to 0.7.0

- release.yml: new linux-riscv64 job, ported from GridCraft: cross-compiled on ubuntu-24.04
  against ports.ubuntu.com riscv64 libs, tar.gz only, photocraft-cli --version smoke-tested
  under QEMU. The draft-release job now needs it, so SHA256SUMS.txt covers it.
- packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE / EMULATOR support.
- linux_libs: search /usr/lib/riscv64-linux-gnu on riscv64 (was the x86_64 triplet).
- README and docs/releasing.md list the riscv64 tarball.
- Version 0.6.0 -> 0.7.0 (cargo xtask version set), Cargo.lock refreshed; contributors refreshed.

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

* canvas: crisp elliptical marquee ants again in an unrotated view

#2732 (a27d7175) built the elliptical marquee's live ants in document space and snapped every
vertex to the half-pixel grid on its own. Near the edge midpoints the nearly flat arcs became
1 px stairs, whose antialiased steps are grey, so the ants no longer contrast on white or black:
drag_preview_canvas's marquee test fails on main (ubuntu and windows). In an axis-aligned view
(rotation a multiple of 90 degrees) draw the ellipse inscribed in the snapped screen box again,
as before #2732; rotated views keep the mapped ellipse.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 21:36:30 -04:00
skylar taylor-barrick 77f50330c6 UI.9: refresh shared panel integration against current main 2026-10-10 19:30:56 -06:00
953390a36e Navigator preview fills its group; no scroll fade over dock panels (#2726) (#2818)
* Let the Navigator preview fill its dock group, drop the scroll fade (#2726)

The Navigator drew a fixed 150 pt preview inside the dock's outer
ScrollArea, so a taller group left dead space below the zoom slider,
and a shorter one scrolled with egui's scroll fade painting a dark
gradient over the thumbnail. The same fade darkened the Color field
and swatches at the bottom of their groups.

The Navigator tab now lays itself out (like Layers and History): the
preview takes the group's height above the zoom controls, keeps the
document's aspect and stays centred. The dock's outer scroll areas no
longer paint the fade; their scroll bar already shows there is more.

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

* Navigator wheel test: point below the preview, which now fills the group

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 19:32:48 -04:00
3c70d3b34f Layer > Rasterize > Layer Style bakes effects; Rasterize Layer converts vector masks (#2245) (#2669)
* Add Layer > Rasterize > Layer Style; Rasterize Layer converts vector masks (#2245)

Nothing could bake layer effects into a layer's pixels, and Rasterize >
Layer refused a raster layer that has a vector mask.

New command layer.rasterize.layerStyle (Layer > Rasterize > Layer Style,
and in the Layers context menu). It renders the layer by itself through
the compositor at 100% opacity in Normal mode, so the effects, fill
opacity and masks end up in the pixels, including those past the canvas.
The layer's own opacity, blend mode, knockout and Blend If stay layer
properties. Type, shape, fill and Smart Object content is rasterized
first, all in one history step. With a Normal layer and Normal-mode
effects the composite is unchanged at 8, 16 and 32 bit. Effects in
another mode are evaluated against the isolated layer's transparent
backdrop, because one layer of pixels cannot hold several blend modes.

Rasterize > Layer and All Layers now also turn a vector mask into a
pixel mask ("all vector data") and leave effects live, as Photoshop
does. The menu enablement follows both changes.

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

* Regenerate docs/scorecard.md after rebase (#2245)

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 19:20:33 -04:00
7bea7dae1c Wake the UI when the first Linux appearance read lands late (#2798) (#2941)
The appearance worker stored the first portal/gsettings answer and
signalled readiness, but only SettingChanged updates requested a
repaint. If Read replied after the 250 ms start-up wait, an idle Auto
window kept its fallback palette until some unrelated event.

Route the initial read and later signals through one publish step that
repaints the registered context whenever the value changes, and move
the thread/wait setup into start() so a test can drive it without D-Bus.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 19:19:27 -04:00
Sir Starch bfa523f916 fix(theme): subscribe before reading Linux appearance (#2833) 2026-10-10 19:15:38 -04:00
Ehsan FazliandBrandon Thomas d9ae73e95f feat(ui): live font preview on hover (#2431)
* feat(ui): preview fonts on hover

* test(ui): exercise font preview through eframe

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 19:11:27 -04:00
325c5b3194 Tools: Single Row Marquee and Single Column Marquee (#2837) (#2848)
Both tools join the Marquee flyout (no key, outside the M / Shift+M cycle,
as in Photoshop). A click selects the one-pixel row (column) under the
pointer across the whole canvas; a press-drag moves the line until the
release. The selection goes through select.rect, so Shift adds, Alt
subtracts, Shift+Alt intersects and the options-bar modes apply; Feather
applies, Anti-alias and Style/Width/Height are greyed. Selection-mode
cursor badges, the selection right-click menu and snapping cover them.
Original row/column glyphs with an ATTRIBUTION.md row; tool names in all
catalogs; README tool list and the G6 / ui-parity rows updated.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 19:10:34 -04:00
9d3ba499ad Layer > New > Background from Layer; the Background stays at the bottom (#2626) (#2663)
* Add Background from Layer and keep the Background at the bottom (#2626)

Layer > New > Background from Layer was missing, and the Background was a
locked layer that Layer > Arrange (with an explicit layer), Reverse and
dragging in the Layers panel could move, or put other layers below.

The new layer.new.backgroundFromLayer command renders the active layer as
it looks over the background colour, drops it to the bottom and locks it
as the Background, in one undo step. It is unavailable when the document
already has a Background, and for groups and adjustment layers.

Arrange refuses to move the Background and stops other layers above it;
layer.moveTo and Reverse refuse edits that would move it off the bottom.
The Layers panel shows no drop indicator for those drops.

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

* Parity floor 629; the empty-space drop test uses a document without a Background

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 19:09:26 -04:00
dhiaaddin 1e0f8844eb Font menus: add an Arabic sample for families that cover Arabic (#2535) 2026-10-10 19:06:05 -04:00
5b778b49f2 Move tool: highlight the layer Auto-Select would pick on rollover (#2834) (#2843)
* Move tool: highlight the layer Auto-Select would pick on rollover (#2834)

With the Move tool and Auto-Select on (or ⌘ held while it is off), hovering
the canvas outlines, in the theme accent, the layer (or group, per the
Auto-Select target) that a click there would pick. Preferences › Tools ›
Show Highlight on Rollover turns it off (on by default).

The hover asks the click's own hit test (pick_cmds::auto_select_target, now
shared with layer.pickAt), so a type layer is outlined anywhere inside its
text and fully locked layers are clicked through, as on a click. The answer
is cached per document revision and pointer pixel. Nothing is drawn while a
button is down, a drag or transform runs, or the selected pixels would move.

Part of #214 (scorecard TOOL-214-4, now partial).

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

* Move hover: follow main's moves_selected_pixels_with rename

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 19:04:19 -04:00
Sir Starch 67e34a1d4c Themes: add Breeze Light and Breeze Dark (#2276) (#2335)
Two Studio-layout themes for KDE Plasma desktops: original colour values
chosen to sit close to Breeze's cool blue-grey surfaces and blue accent
(and its 5 px radius) so the app blends into a Plasma desktop, without
copying Breeze's colour schemes. Text readability comes first.

- theme.rs: ThemeKind::BreezeLight / BreezeDark. Every text colour (text,
  secondary, faint, accent, danger, warning) holds 4.5:1 on every surface
  and on hovered, pressed and selected states; the primary button is a
  deeper blue that takes white text and stays above 4.5:1 while it
  darkens on hover and press. Breeze's own inactive text and link blue
  are only about 2.8:1 on its light surfaces, so ours are darker. The
  close button goes red on hover.
- prefs: Theme/LightTheme::BreezeLight ("breezeLight") and
  Theme/DarkTheme::BreezeDark ("breezeDark"): Preferences > Interface,
  `prefs.set`.
- Window > Theme > Breeze Light/Dark Theme (`window.theme.breezeLight`,
  `window.theme.breezeDark`) and `ui.set {"theme": ...}` (aliases:
  breeze, kde, plasma for the light one; ...dark for the dark one).
- i18n: the four new labels in all 16 catalogs.
- docs/ui-design.md and ATTRIBUTION.md (original work, credits Breeze as
  the inspiration; Breeze's schemes are LGPL-2.0-or-later and none of
  their values, files or code are copied).
- Tests: names/aliases, surface order and blue-grey character, the
  contrast matrix for both themes, and the preference/mode selection.
2026-10-10 19:01:53 -04:00
FinnNkandClaude Opus 5.5 0c4016a4db feat(ui): hold Alt+Space to zoom out, as in Photoshop (#2877)
* feat(ui): Alt turns a held temporary Zoom In into Zoom Out

Alt reverses the Zoom tool's direction, but a held Zoom In (Cmd+Space)
ignored it: with Zoom Out unbound or rebound, Cmd+Alt+Space zoomed in.
held_tool now reports Zoom Out whenever Zoom In is the held binding and
Alt is down, unless Alt is part of the Zoom In key itself (a rebound
Alt+Z still zooms in). The default keys don't change here.

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

* feat(ui): hold Alt+Space to zoom out, as in Photoshop

Photoshop zooms out with Alt+Space (Option+Space on the Mac), usually
pressed as Space first, then Alt, then click. PhotoCraft's Zoom Out
(hold) default was Cmd+Alt+Space only, so Space then Alt still panned.

Zoom Out (hold) now defaults to Alt+Space. Holding Cmd+Alt+Space
matches both Cmd+Space and Alt+Space equally; the previous commit's
Alt rule resolves that to Zoom Out, so it keeps zooming out. Saved
overrides are kept. Holding Space first also avoids the Mac's Spotlight
shortcut and the Alt+Space window menu on many Linux desktops.

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

* chore(contributors): credit FinnNk by GitHub profile name

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:56:04 -04:00
6911075037 Edit: Copy/Paste with no selection keeps type, shape and group layers (#2670) (#2686)
* Smart Objects: Copy/Paste with no selection keeps the smart object (#2428)

Photoshop's layer-based copy (CC 2018 on) copies the active layer whole
when there is no selection. Edit > Copy always lifted the layer's pixels,
so Paste made a raster layer from a smart object. Copy now also keeps the
smart object layer on the clipboard (with its pixels for Paste Into,
masks, channels and other apps). Paste recreates it with contents of its
own, converted to the destination's colour and depth and placed as the
pixels would be. With a selection, Copy still takes pixels.

The colour/depth/contents-id handling of layer.copyToDocument moves into
shared helpers (adapt_copies, translate_copies) used by both.

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

* Edit: Copy/Paste with no selection keeps type, shape and group layers (#2670)

Photoshop's layer-based copy, verified in Photoshop: with no pixel
selection, Copy takes a type layer, a shape layer or a group (its whole
subtree) whole, and Paste / Paste in Place recreate it instead of a
pixel layer. #2428 did this for smart objects; this extends the same
path. Pixel layers, several selected layers and any copy with a
selection still take pixels.

A group has no pixels of its own, so Copy (now enabled for a group with
no selection) puts its composite on the clipboard for Paste Into, masks
and other apps.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 18:50:04 -04:00
37cb99eb1f Let dock tabs be dragged to reorder within their group (#2272) (#2774)
Dock tabs only sensed clicks, so a drag on a tab fell through to the
strip behind it and moved the whole group; there was no per-group tab
order to change.

DockLayout gains tabOrder (tab names per group, like hidden_tabs, so the
Pro/Studio Color/Swatches swap is safe). It is read leniently: unknown
groups, names and wrong types fall back to the default order instead of
dropping the saved layout. It is drawn through visible_tabs, while
dockTabs and the panel bodies keep using the original indices.

Tabs on the dock strips now sense drags: dragging one along its strip
shows an insertion line and drops it there, a plain click still selects,
and a tab dragged off the strip moves the group as the strip does.
Lock Workspace stops both. The order is part of ui.set {dock}, the
remembered panelLayout and saved workspaces.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:47:36 -04:00
Brandon ThomasandClaude Opus 5.5 9c90365c45 ui-egui tests: restore the joined helper (fixes ui-egui test build after #2924 and #2497 both removed one) (#2947)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:46:16 -04:00
Valentin KleinandClaude Opus 5.5 4916a0f088 Pencil: 1 px lines step pixel by pixel, no doubled corners (#2139) (#2806)
Small aliased tips (dabs at most a pixel apart) now walk each segment
pixel by pixel on the line between the pixels holding its ends, one dab
per pixel along the major axis at the pixel's dab centre. Distance
spacing put two dabs in a row wherever the line crossed a pixel corner,
which left L-shaped doubled pixels at every step of a Pencil line.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:45:57 -04:00
93599a1837 Smart Objects: Copy/Paste with no selection keeps the smart object (#2428) (#2653)
Photoshop's layer-based copy (CC 2018 on) copies the active layer whole
when there is no selection. Edit > Copy always lifted the layer's pixels,
so Paste made a raster layer from a smart object. Copy now also keeps the
smart object layer on the clipboard (with its pixels for Paste Into,
masks, channels and other apps). Paste recreates it with contents of its
own, converted to the destination's colour and depth and placed as the
pixels would be. With a selection, Copy still takes pixels.

The colour/depth/contents-id handling of layer.copyToDocument moves into
shared helpers (adapt_copies, translate_copies) used by both.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 18:43:46 -04:00
87f74cc8d2 Type: tabs advance to Photoshop's default tab stops (#1881) (#2733)
#1942 stopped tabs from shaping as .notdef by laying them out as a
space, so a tab was only one space wide. Layout now widens each tab to
the next default stop (every 36 pt, from the anchor of point text or the
left edge of the box). Paragraph text breaks lines with the real tab
widths: it starts from a safe upper bound (space + one interval) and
re-breaks until the widths agree, so expanded tabs never overflow the
box and lines don't wrap early. The text keeps its \t, so PSD round
trips are unchanged. Tab in the Type tool now types a tab.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:38:53 -04:00
xsvmandBrandon Thomas 13e478cc6e Filter Gallery: an unknown option is a bad-params error (#2548 follow-up) (#2599)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 18:37:25 -04:00
Brandon ThomasandClaude Opus 5.5 e1a13013b6 ui-egui tests: drop the shadowing joined closure (fixes clippy dead-code on main) (#2924)
#2782 and #2853 both fixed the save-dialog test's path expectations; together they left a
top-level joined() helper shadowed by an identical local closure, so clippy -D warnings fails
with "function joined is never used". The test now uses the documented helper.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:34:51 -04:00
7687380604 feat(prefs): add search across preference sections and settings (#2679)
* feat(prefs): search visible settings across sections (#2593)

Filter the Preferences sidebar by section and editable field labels,
switch to the first match, and show matching settings in the editor.
Keep the draft intact while searching and hide Reset Section during filtering.

* Preferences search: translate the field hint and empty state; rustfmt

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

* Merge fixups: pass the new search argument in a main test; search test uses a visible Type setting

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:32:04 -04:00
7c9e7e5ae3 Keep the type style when a data set replaces text (#2742) (#2785)
Applying a textReplacement value cleared the layer's character and
paragraph runs, so the text re-flowed from the summary fields (family,
size, colour) and lost weight, italic, tracking, underline, caps and the
rest of the run style. The new text now takes the first character's style
and the first paragraph's settings, as in Photoshop. The value is kept
verbatim, as before.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:31:50 -04:00
308c3369c0 Control: timeout errors name the likely causes (#2489) (#2497)
* Control: timeout text derives from the reply deadline (#2489 review)

The server deadline and both timeout replies now share one constant:
REPLY_DEADLINE drives the serve thread's clock and the text is built from
it, so the message can never claim a number the server no longer uses.

* Tests: drop the duplicate joined() helper left by #2782 and #2853 both landing

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:27:03 -04:00
07e7a73636 Show non-square pixels on the canvas (#2594) (#2718)
View > Pixel Aspect Ratio and its Correction toggle only stored a value:
the canvas transform (ViewXform) had no notion of pixel aspect, so the
image was always drawn with square pixels.

ViewXform now carries the displayed pixel aspect ratio and stretches
document x by it before rotation and flip. Drawing, pointer-to-document
mapping, pan and zoom-about deltas, rulers, grid, Fit/Fill on Screen,
scrollbars, the Navigator's view box and paste-in-place visibility all
go through it. Correction is on by default, as in Photoshop. A corrected
view draws through the CPU canvas path, like a flipped one, since the
GPU shader assumes square pixels. The document's pixels are unchanged.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 18:25:24 -04:00
46af2e6e30 Workspaces: preserve the embedding host menu and rail flags (#2620)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:22:30 -04:00
skylar taylor-barrick 254450f38e UI.8: pin final shared dock action labels 2026-10-10 16:20:57 -06:00
Arthur Wagner 2194c7c58f Status bar: show document dimensions in the ruler unit (#2618) 2026-10-10 18:18:19 -04:00
Valentin KleinandClaude Opus 5.5 faf2639fb8 Web: File › Open opens every selected file (#2802) (#2807)
The web file dialog called rfd's pick_file() and ignored the request's
`multiple` flag, and FileDialogAnswer could only carry one file's
contents. Pick several files when File › Open asks for them, answer
with the new ContentsMany variant, and open each as its own document,
reporting a failed file without stopping the rest (as the desktop's
multi-path open does).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:15:47 -04:00
Gustavo Ziger 1a59f6f6a3 Fix brush picker closing immediately on trackpad taps (#2772)
Ignore outside-click dismissal during the opening frame.
Add regression coverage for repeated opening after outside clicks.
2026-10-10 18:10:12 -04:00
Valentin KleinandClaude Opus 5.5 f9f65447f8 Layers: Layer via Copy duplicates every selected layer (#2777) (#2796)
With several layers selected and no pixel selection, Cmd+J now copies every
selected layer in one undo step (each copy above its original, the copies
selected), reusing Duplicate Layer's multi-layer path. With a pixel
selection it still copies the active layer's selected pixels.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:09:03 -04:00
Valentin KleinandClaude Opus 5.5 444323ee74 Warp Text: the dialog lists Style, orientation, then Bend and distortions (#2689) (#2819)
The schema dialog draws its fields in the order of the command's params
doc, which put Orientation last, below the three sliders. Photoshop's
Warp Text dialog has Style, then Horizontal/Vertical, then Bend,
Horizontal Distortion and Vertical Distortion. Reorder the params doc.

Live preview and opening with the layer's current warp already landed
in #2539 (after v0.6.0).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 18:06:09 -04:00
27e25c2cb4 Crop/marquee readout and Properties W/H/X/Y in the ruler unit (cm, mm, in…) (#2334)
* Crop / marquee size readout in the Units & Rulers unit (cm, mm, in…) as Photoshop shows it

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

* Properties: Transform W/H/X/Y and Canvas W/H in the Units & Rulers unit (cm, mm, in…), typed values converted back to px

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

* Merge origin/main: size readout test follows the trimmed unit format

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 18:03:32 -04:00
Elvis33LE 03f4cc0de1 Correct Photomerge roadmap status (#2831) 2026-10-10 17:57:51 -04:00
mroxsoandhighperfocused d9e6cee908 Tests: build save-dialog expectations with Path::join so they pass on Windows (#2745) (#2853)
Co-authored-by: highperfocused <[email protected]>
2026-10-10 17:57:41 -04:00
46d170b19b Free Transform frames the whole type layer; Distort/Perspective greyed for type (#2630) (#2701)
* Free Transform on type: whole-layer frame, grey Distort/Perspective (#2630)

With a selection active, Free Transform on a type layer framed only the
selected part of the text and the preview lifted half of the rendered
glyphs, but the engine then transformed the whole type layer, so the box
and the result disagreed. Type, shapes and smart objects always move
whole, so transform_bounds no longer intersects their frame with the
selection and the preview no longer lifts pixels out of them.

Distort and Perspective stayed enabled for editable type and then failed
on commit. They are now greyed for type layers (menu bar, marquee and
transform-box context menus), as in Photoshop, unless an unlinked mask or
channel is targeted.

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

* transform_type_tests: set CanvasToolMenu::pasteboard (#2630)

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:57:26 -04:00
a3d2a6d486 Place: wire Resize Image During Place into the implicit fit (#204) (#2538)
* feat(engine): Resize Image During Place drives the implicit Place fit (#204)

* Merge origin/main; regenerate docs/scorecard.md

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:53:25 -04:00
Gert Pellin - GPEandswitch87 a27d717597 canvas: the marquee hinting follows a rotated view like the release does (#2732)
The live ants for the Rectangular/Elliptical Marquee (and Object Selection)
were an axis-aligned screen box built from the two projected corners of the
drag. A rotated view projects the drag's document rectangle onto a rotated
one, and the box through two of its corners is a different shape in a
different place: on a 30 degree view a corner landed 21 x 50 px away from
where the selection it previews was committed. The release was always right,
only the hinting was drawn elsewhere (#2512).

marquee_drag_ants builds the outline in document space and maps every point
through the view instead: four corners for the rectangle, the ellipse
inscribed in it for the elliptical marquee, with as many vertices as its size
on screen asks for. Every point is snapped to the half-pixel grid like every
other ants outline (marching_ants_segments), so the lines stay crisp at any
zoom. Unrotated the mapping is only a scale, and the corners come out where
they always did.

Tests drag a marquee in a 30 degree view for both tools and check the drawn
outline against the selection the release commits: each corner is the mapped
corner of the dragged rectangle, and each point of the ellipse maps back onto
it.

Co-authored-by: switch87 <[email protected]>
2026-10-10 17:51:20 -04:00
Valentin KleinandClaude Opus 5.5 63fee8c07c wasm: gate native-only constants so wasm32 builds without dead-code warnings (#2808) (#2813)
PAR_MIN_PIXELS / PAR_CHUNK_PIXELS (photocraft-cms) and OPENABLE
(photocraft-engine) are only used by native-only code (the rayon paths
and list_images). Give them the same cfg so wasm32 check and clippy
-D warnings are clean. No behaviour change.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:48:19 -04:00
Valentin KleinandClaude Opus 5.5 1320dac5bf Spot Healing: Create Texture keeps the grain, joining blocks along min-error cuts (#2104) (#2762)
Create Texture (inpaint::synthesize) averaged each block 50/50 into the
already-synthesised overlap. Averaging two unrelated grain patterns cuts
their standard deviation by about 1/sqrt(2), so the fill came out smoother
than its surroundings (75 % of the grain for one 30 px dab), and every
overlapping stroke, which samples the previous ones, lost more (57 % after
eight). Blocks are now joined along Efros & Freeman's minimum-error
boundary cut (Image Quilting, SIGGRAPH 2001): each pixel is copied from
one block or the other, never averaged.

Content-Aware and Proximity Match already keep the grain on main since
#2202 (patch copies instead of voting blur); the new test guards all three.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:46:41 -04:00
Valentin KleinandClaude Opus 5.5 ab3e6ebe81 Brush: Wet Edges acts on the whole stroke, not each dab (#2088) (#2721)
Wet Edges multiplied every dab by its own rim profile and max-accumulated the
dabs, so the rims of neighbouring dabs showed through the stroke as a chain of
rings. Dabs now accumulate normally and record how deep they reach each pixel
(1 - rn); the stroke keeps the deepest value per pixel and applies the
wet-edge transfer to its accumulated coverage when composited (live preview,
tail preview, commit and retouch coverage alike). The interior of the stroke is
evenly light and only the stroke's own rim is darker.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:41:14 -04:00
twentymls d3ae5aad0f Smart filter edits: re-render from cached stages, in a background job (#2739)
Showing, hiding or editing a smart filter re-rendered the whole stack from the
placed source on the UI thread: on a 3000x4498 layer with five filters every
click on a filter's eye froze the window for about 1.5 s with no feedback.

- Each render keeps the placed source and the result after every visible filter
  (blake3 of the source contents, the placement and the filters so far), so an
  edit reruns only the filters from the edited one up. Edit > Purge > All drops
  them.
- Smart filter edits apply the edit at once (so bad params still fail
  immediately) and re-render as a background job when started with
  Session::start: the window keeps drawing, with progress and Esc to cancel
  between filters. Session::execute is unchanged.
2026-10-10 17:38:59 -04:00
d0127f9294 Eraser: Block mode (#2770) (#2778)
* Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662)

The Pencil cursor always outlined the square grid_square footprint, but the
aliased dab is a disc from 4 px on. It now draws a circle on the same
pixel-grid square when the tip is round, and the square for 1-3 px tips
whose dab really fills it.

The Eraser options bar gains Photoshop's Mode: Brush / Pencil (replacing
the blend-mode dropdown the Eraser never used). Pencil mode commits
paint.pencil with erase: true (hard, aliased, full flow), live and on
release, and shows the Pencil's cursor. Block mode is left for later.

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

* Eraser: translate the Pencil mode label in every catalog

The Eraser's Mode dropdown added tl!("Pencil"), which had no entry in the
cs, el, ko, zh-hans and zh-hant catalogs, so every_tl_literal_is_translated
failed.

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

* Eraser: Block mode (#2770)

Photoshop's Eraser Mode is Brush / Pencil / Block; #2694 added Pencil.
Block erases a hard, aliased square of 16 screen pixels, so 16 / zoom
document pixels, whatever the brush's size, hardness, opacity or flow.

- paint: BrushSettings.square (with aliased) rasterises the dab's whole
  pixel-grid square (serde: omitted when false, so saved brushes are
  unchanged).
- engine: paint.pencil "block": true builds that brush from the stroke's
  "zoom" (brush_cmds::block_size: round(16 / zoom), 1..5000 px); a zoom
  that is not a positive number is an Err. Params doc updated.
- ui: Mode dropdown gains Block; the Block-mode Eraser commits
  paint.pencil with "block": true, shows the block's square cursor and
  greys out Size/preset, Hardness, Opacity and Flow.

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

* Eraser: translate the Block mode label

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 17:35:55 -04:00
6bbfa77230 Interface: wire Dynamic Color Sliders for the colour tracks (#204) (#2531)
* feat(ui): the Dynamic Color Sliders preference controls the colour slider tracks (#204)

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:31:24 -04:00
5e0e9c595b Speed up large group merges (#1561) (#2664)
* Speed up large layer group merges

* Merges: bound by composite_bounds so layer effects outside the pixels are kept

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:28:53 -04:00
MarekSpodBiedry d29d81d5c0 Linux: no false missing-library warning when the linker cache can't be read (#1144) (#2503) 2026-10-10 17:19:47 -04:00
Valentin KleinandClaude Opus 5.5 5a1f6eef2e Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662) (#2694)
* Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662)

The Pencil cursor always outlined the square grid_square footprint, but the
aliased dab is a disc from 4 px on. It now draws a circle on the same
pixel-grid square when the tip is round, and the square for 1-3 px tips
whose dab really fills it.

The Eraser options bar gains Photoshop's Mode: Brush / Pencil (replacing
the blend-mode dropdown the Eraser never used). Pencil mode commits
paint.pencil with erase: true (hard, aliased, full flow), live and on
release, and shows the Pencil's cursor. Block mode is left for later.

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

* Eraser: translate the Pencil mode label in every catalog

The Eraser's Mode dropdown added tl!("Pencil"), which had no entry in the
cs, el, ko, zh-hans and zh-hant catalogs, so every_tl_literal_is_translated
failed.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:14:17 -04:00
96ec16ed7d fix(compose, gpu): round dithered gradient fills to the document depth (#2755) (#2781)
A live gradient's dither adds a fraction of a level of noise, but gradient
fill layers kept it in float. Color Dodge divides by 1 - cs, so under a
bright dodge layer that sub-level noise came out as speckle several levels
high, and channels at 0 were lifted off black (coloured fringes).

Dithered gradient pixels are now rounded to the document depth's levels
(8/16-bit; float documents unchanged), as a painted gradient stores them.
Same rounding in the GPU shader. Regression tests in compose and a GPU
parity case for stacked dithered gradients under Color Dodge.


Claude-Session: https://claude.ai/code/session_01Qpa5z8GksXAFiDavFkmh4R

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:11:46 -04:00
ede3da019d i18n: wrap remaining hardcoded UI strings for translation (#2467)
- Wrap "PhotoCraft — Screen Color Picker" viewport title with tl!()
- Wrap "OK" button in widgets.rs with tl!()
- Add "PhotoCraft — Screen Color Picker" entry to all 14 language catalogs

Co-authored-by: seldoms <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:10:36 -04:00
994a042123 Paint a pen tap at its own pressure, not full or zero (#1798) (#2757)
A tap shorter than a UI frame left the stylus with no current sample by the
time the canvas ran: the platform had already cleared it (macOS proximity
out, a non-tablet button-up, the web's pointerleave) or set it back to
pressure 0 (X11 and macOS hover). The press then read as a mouse and painted
a full-size dab, or read the lift's 0. The same happened to the last moves of
a stroke that lifted in the frame they arrived in, which painted at full
pressure.

Stylus now drops zero-pressure samples from a frame that also had contact and
falls back to the frame's last contact sample when the feed was cleared or
reports 0. A tap (a drag still at its press point on release) paints at the
highest pressure seen since the press, so a tap spanning two frames is not
stuck at its first light sample. A mouse still paints at full pressure.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:06:44 -04:00
6cac936112 Image Size: Resample off keeps the pixel readouts honest (#2393) (#2533)
With Resample off, image.imageSize keeps the pixel dimensions and only
changes the resolution, but the dialog still accepted pixel Width/Height
edits and showed them in the Dimensions line and size estimate.

Now, as in Photoshop, with Resample off:
- width and height stay at the document's pixels (readouts included);
- in Pixels or Percent the Width/Height fields are disabled;
- in a physical unit (in, cm, mm, pt, pica) editing Width or Height
  changes the resolution to fit that size, keeping the pixels;
- the constrain chain stays linked.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 17:05:37 -04:00
817678d6a0 fix(ui): prevent Layers opacity controls from overflowing narrow docks (#2325)
* fix(ui): fit Layers opacity controls within narrow docks (#2309)

* Layers Opacity row: keep the label at the default dock width

Show the label while the blend dropdown keeps 80 px, so the default
Layers dock looks as before and only long translated labels on narrow
docks give way.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:03:45 -04:00
Valentin KleinandClaude Opus 5.5 737d1ca753 Brush cursor: Normal Brush Tip ring on the real 50% contour (#2744) (#2747)
Since #2167 the soft tip falls off as (1 - t^2)^4, whose 50% point is at
t = sqrt(1 - 0.5^(1/4)) ~ 0.399, but the Normal Brush Tip cursor still used
the old smoothstep midpoint r * (0.5 + 0.5 * hardness). Soft brushes got a
ring up to 25% too large (size 21, hardness 0: 5.25 px instead of 4.19 px).

photocraft_paint::half_coverage_radius inverts tip_falloff, and the cursor
uses it, so the two can't drift apart again.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:58:06 -04:00
Valentin KleinandClaude Opus 5.5 14dfa38581 Actions: double-click an action name to rename it (#2032) (#2769)
Photoshop renames an action in place when its name is double-clicked in the
Actions panel. PhotoCraft had no way to rename an action at all, so every
action kept its "Action N" default name.

- New engine command `actions.rename {"action": name|index, "name": str}`.
  Empty, whitespace-only and duplicate names are rejected (the old name
  stays). Nested `actions.play` steps that call the action by name, and
  its function-key binding, follow the rename. Refused during playback.
- Actions panel: double-clicking the name opens an in-place field with the
  name selected; Enter, Tab or clicking elsewhere commits through the
  command, Esc cancels. Double-clicking elsewhere on the row still plays.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:54:36 -04:00
5963e5b7f0 Move tool: ⌘-drag with a painting tool floats the selected pixels from the control channel too (#2768) (#2776)
* Move tool: ⌘-drag temporary Move gets the drag box and snapping (#2681)

snap_ui::begin chose the snap gesture from app.ui.tool, the selected tool,
not the tool the press acts as. A ⌘-drag with a painting tool (temporary
Move) or with a selection tool outside the selection (moves the layer)
got no moving box and no smart-guide/guide/grid snapping.

The ⌘ resolution in canvas::tool_event moves into canvas::event_tool,
which begin now uses together with canvas::command_moves_layer.

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

* Move tool: ⌘-drag with a painting tool floats the selected pixels from the control channel too (#2768)

`move_ui::moves_selected_pixels` asked `app.active_tool()`, which only sees the
held-key override the canvas sets while ⌘ is down. Control-channel, MCP and test
input send ⌘ on the event, so `tool_event` resolved the Move tool but the
selected-pixel checks still saw the Brush and moved the whole layer. The press
now asks with the event's tool (`canvas::event_tool`): the floating-piece drop,
Auto-Select pick, float, lock check and snap box.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 16:50:50 -04:00
F4LCON b773a39efa Home screen: remove individual Recent entries (#2731)
Fixes #2691. The hovered row of the Home screen's Recent list shows a ×
that takes the file off the list without opening it, and right-click
offers Remove from Recent and Clear Recent Files. The change is stored in
the preferences, so it survives a restart, and the file comes back when it
is opened again. `file.removeRecent {"path"}` does the same for agents.
Rows carry their file name as their accessible label.
2026-10-10 16:49:34 -04:00
a28d786638 fix(navigator): zoom the document with the mouse wheel over the preview (#2724)
* fix(navigator): zoom with wheel over the preview (#2716)

* rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:43:57 -04:00
cfe031f2a6 Batch: skip recorded view steps in batch and droplets (#2752) (#2761)
* Actions: record File › Save and Save As steps and replay them (#2032)

Save and Save As run in the desktop shell (dialogs, TIFF prompt, background
save job), outside Session::execute, so they never reached the journal that
action recording copies from. The shell now journals `file.save` {} for a save
in place and `file.saveAs` {path, tiffLayers?} once a Save As has its path
(after the dialog or the TIFF Options answer). The engine treats both as
replayable shell steps; shell playback writes them synchronously without a
dialog. Batch and droplets drop recorded saves, since their destination folder
overrides them (Photoshop's "Override Action 'Save As' Commands").

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

* Batch: skip recorded view steps in batch and droplets (#2752)

Actions recorded in the desktop app can hold the shell's view steps
(view.zoomIn, view.fitOnScreen, ...). Batch and Create Droplet checked
every step against the engine registry and rejected the whole action
with "unknown command". They now skip view steps, which don't change
the output, alongside the recorded saves #2746 already skips.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 16:43:36 -04:00
Valentin KleinandClaude Opus 5.5 6ff2a03f33 Actions: record File › Save and Save As steps and replay them (#2032) (#2746)
Save and Save As run in the desktop shell (dialogs, TIFF prompt, background
save job), outside Session::execute, so they never reached the journal that
action recording copies from. The shell now journals `file.save` {} for a save
in place and `file.saveAs` {path, tiffLayers?} once a Save As has its path
(after the dialog or the TIFF Options answer). The engine treats both as
replayable shell steps; shell playback writes them synchronously without a
dialog. Batch and droplets drop recorded saves, since their destination folder
overrides them (Photoshop's "Override Action 'Save As' Commands").

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:38:37 -04:00
4db47f2bcc Esc deselects when no active operation needs cancelling (#2765)
* Esc deselects when no active operation needs cancelling (#2674)

* Esc: keep the selection while a canvas drag is in progress

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

---------

Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:36:44 -04:00
719465dc5b Lasso: ⌘-drag outside the selection moves the layer (#2725) (#2737)
* Guides: ⌘-drag over a guide moves it with the selection tools too (#2690)

⌘ is the Move tool with any tool, and the Move tool drags a guide under the
pointer before anything else. Painting tools already got this through the
held-⌘ Move override, but the marquees, lassos and Magic Wand keep their own
tool under ⌘ (`command_moves_layer`), so a ⌘-drag over a guide moved the layer
(or drew a lasso outline) and left the guide in place.

`canvas::command_guide_at` takes the guide on a ⌘ press with a selection tool
(same hit test as the Move tool: hidden or locked guides are not hit), the
guide drag snaps like the Move tool's (never onto guides, so not back onto
itself), the Lasso hands the drag's events to the canvas, and the cursor over
a guide is the guide's double arrow.

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

* Lasso: ⌘-drag outside the selection moves the layer (#2725)

`lasso_ui::pointer` took a ⌘ press outside the selection before
`canvas::tool_event` reached `command_moves_layer`, so it drew an outline;
`canvas_input` did not forward the drag's moves and release either. The
Lasso now hands such a press to the canvas (the temporary Move tool) and
feeds it the rest of the drag, also when a locked layer refuses the move.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 16:34:43 -04:00
cf54d5e8a3 Keep the blend-mode preview between list rows (#2553) (#2612)
The Layers panel blend-mode list previews the hovered mode, but egui's
item spacing leaves a gap between rows that hovers no row, so moving the
pointer down the list flashed the layer's current mode back at every gap.

While the pointer is over the open list and between rows, report the
nearest row as hovered, so the preview moves straight from one mode to
the next.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:33:04 -04:00
Valentin KleinandClaude Opus 5.5 9f82e6522f Guides: ⌘-drag over a guide moves it with the selection tools too (#2690) (#2699)
⌘ is the Move tool with any tool, and the Move tool drags a guide under the
pointer before anything else. Painting tools already got this through the
held-⌘ Move override, but the marquees, lassos and Magic Wand keep their own
tool under ⌘ (`command_moves_layer`), so a ⌘-drag over a guide moved the layer
(or drew a lasso outline) and left the guide in place.

`canvas::command_guide_at` takes the guide on a ⌘ press with a selection tool
(same hit test as the Move tool: hidden or locked guides are not hit), the
guide drag snaps like the Move tool's (never onto guides, so not back onto
itself), the Lasso hands the drag's events to the canvas, and the cursor over
a guide is the guide's double arrow.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:28:48 -04:00
4908fde283 fix(ui): make painting symmetry available across all themes (#2704)
* fix(ui): expose painting symmetry for all themes (#2659)

Render the existing symmetry control for Brush, Pencil and Eraser
independent of palette. Add a per-theme, per-tool UI visibility test.

* rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:26:54 -04:00
Valentin KleinandClaude Opus 5.5 2fac209d56 Batch: a failed step inside a called action fails the file and the script (#2779) (#2783)
Scripts (File › Scripts › Browse, Script Events) ignored the `failed` entry that
`actions.play` returns when a step inside the called action fails, so they reported
ok and kept going. Batch and droplets ran each file in a scratch session without
the actions list, so any nested action call failed with "no action named". The
scratch session now gets the caller's actions and playback stack (an action that
batches itself is caught as recursive), and both runners check nested_failure.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:25:22 -04:00
5c039af581 Selection: moving a selection outline snaps its bounds, not the pointer (#2736) (#2740)
Dragging inside a selection with a marquee or lasso (or ⌘-dragging its
pixels as a floating piece) now starts a SelectionMove snap gesture over
the selection's bounds, like the Move tool's box, instead of snapping the
pointer as a marquee corner (marquees) or not snapping at all (lasso).
The selection's own edges are left out of the targets.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 16:19:02 -04:00
Valentin KleinandClaude Opus 5.5 dfa4a2e51e Smart Objects: stacked layers sit at the contents top level (#2657) (#2676)
* Smart Objects: converting several layers keeps them at the contents top level (#2648)

Convert to Smart Object with several selected layers wrapped them in a
temporary group before embedding, so Edit Contents showed one group named
after the top layer. Photoshop puts the selected layers at the top level of
the embedded document. The embedding now goes through layers_to_smart, which
takes the layers directly; a pass-through root composites the same, so the
smart object looks unchanged. A single selected group still converts as is.

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

* Smart Objects: stacked layers sit at the contents top level (#2657)

Load Files into Stack with Create Smart Object, and Statistics with
alignment, put the loaded layers in a temporary group before embedding,
so the smart object's contents held one group named after the first file.
Photoshop puts one layer per file at the top level of the contents. Both
call sites now pass the layers to layers_to_smart. The wrapper was an
opaque Normal group of opaque Normal layers, so the composite is the same.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:17:49 -04:00
5d9e812efc feat(prefs): add intermediate UI scale options from 80% to 95% (#2678)
* feat(prefs): add 80–95% UI scale presets (#2492)

Offer finer five-percent steps between 75% and 100% for small screens.
Exercise the new values through the live preference and egui scaling test.

* Label the 80-95% UI scale choices as percentages

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

* Translate the 80-95% UI scale labels in every catalog

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:16:47 -04:00
Valentin KleinandClaude Opus 5.5 9bd6b8111e Smart Objects: converting several layers keeps them at the contents top level (#2648) (#2651)
Convert to Smart Object with several selected layers wrapped them in a
temporary group before embedding, so Edit Contents showed one group named
after the top layer. Photoshop puts the selected layers at the top level of
the embedded document. The embedding now goes through layers_to_smart, which
takes the layers directly; a pass-through root composites the same, so the
smart object looks unchanged. A single selected group still converts as is.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:10:36 -04:00
dfd63b0945 Move tool: ⌘-drag temporary Move gets the drag box and snapping (#2681) (#2684)
snap_ui::begin chose the snap gesture from app.ui.tool, the selected tool,
not the tool the press acts as. A ⌘-drag with a painting tool (temporary
Move) or with a selection tool outside the selection (moves the layer)
got no moving box and no smart-guide/guide/grid snapping.

The ⌘ resolution in canvas::tool_event moves into canvas::event_tool,
which begin now uses together with canvas::command_moves_layer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 16:10:12 -04:00
7dc8bbd84a Channels: Cut with one channel targeted clears only that channel (#2698) (#2712)
* Copy and paste a single channel from the Channels panel (#2307)

With a colour channel (Red, Green, Blue, ...) targeted, Copy copied the whole
layer and Paste made a new layer, so a channel could not be copied into
another one. Now, as in Photoshop:

- Copy with a colour or alpha channel targeted copies that channel's values
  as a grayscale clip, inside the selection or across the whole canvas. With
  an alpha channel targeted, Copy needs no pixel layer.
- Paste, Paste in Place and Paste Into with a colour channel targeted write
  the clip's luminosity into that channel of the active layer, in one undo
  step. The other channels and the layer's transparency are left alone, and
  a pixel lock refuses the paste.

The logic lives in a new channel_clip module; Copy, Paste and Paste Into only
call it. Cut keeps its old behaviour.

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

* Channels: Cut with one channel targeted clears only that channel (#2698)

With a single colour channel (e.g. Red) targeted in the Channels panel,
Edit > Cut copied and cleared every channel of the layer: `edit.cut` is not
one of the channel-routed commands, so it always ran the whole-layer path.

Now, as in Photoshop, Cut with a colour channel targeted copies that channel
as a grayscale clip (the channel Copy from #2307) and fills the cut area of
that channel only with the background colour's value for it, weighted by
selection coverage. The other channels and the layer's transparency are left
alone, and the whole cut is one undo step. With the composite targeted, Cut
is unchanged.

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

* Cut on a colour channel: reuse clear_color_channel from #2730

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

---------

Co-authored-by: Fabian Ghenof <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 16:05:08 -04:00
Valentin KleinandClaude Opus 5.5 f158cd45aa Move tool: the drag's box and snapping follow the Auto-Selected layer (#2666) (#2675)
snap_ui::begin ran on the press before the Move tool's Auto-Select
layer.pickAt, so the drag box, the snapped rect and the excluded targets
were the previously selected layers'. Another layer's box moved instead,
and with an empty layer or a group selected there was no box and no
snapping. Re-run it when the pick changed the selection.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 16:03:25 -04:00
Fabian GhenofandClaude Opus 5.5 96198fe49d Copy and paste a single channel from the Channels panel (#2307) (#2695)
With a colour channel (Red, Green, Blue, ...) targeted, Copy copied the whole
layer and Paste made a new layer, so a channel could not be copied into
another one. Now, as in Photoshop:

- Copy with a colour or alpha channel targeted copies that channel's values
  as a grayscale clip, inside the selection or across the whole canvas. With
  an alpha channel targeted, Copy needs no pixel layer.
- Paste, Paste in Place and Paste Into with a colour channel targeted write
  the clip's luminosity into that channel of the active layer, in one undo
  step. The other channels and the layer's transparency are left alone, and
  a pixel lock refuses the paste.

The logic lives in a new channel_clip module; Copy, Paste and Paste Into only
call it. Cut keeps its old behaviour.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 15:59:34 -04:00
Valentin KleinandClaude Opus 5.5 f9700ea95e Lasso: an open Free Transform box takes the drag with the Lasso selected (#2153) (#2766)
The Lasso reads its own pointer events (lasso_ui::canvas_input) and the canvas
cleared the generic drag buttons for it. With a Free Transform box open, the
press reached the box but the moves and the release were dropped (no lasso
outline was in progress), so the box never changed. The open box now owns the
pointer with the Lasso too, as it already does for the Hand (#2302).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 15:56:43 -04:00
d5694490f5 Channels: Delete with an alpha channel targeted fills that channel (#2734) (#2741)
* Channels: Delete with one channel targeted clears only that channel (#2720)

Edit > Clear ignored the Channels panel target: with a single colour channel
targeted it filled every channel of the Background with the background colour,
and made a normal layer's pixels transparent. It now sets only the targeted
channel inside the selection to the background colour's value for that channel
(partly selected pixels blend), keeps the other channels and the layer's
transparency, and stays one undo step, as Photoshop does.

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

* Channels: Delete with an alpha channel targeted fills that channel (#2734)

Edit > Clear ignored an alpha channel or Quick Mask target: it cleared the
active layer's pixels and left the channel alone, and it was disabled on a
non-pixel layer even with a channel targeted. With an alpha channel targeted
(or in Quick Mask mode) it now fills the selection (or the canvas) of that
channel with the background colour as gray, leaves the layer untouched and
stays one undo step, as Photoshop does.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 15:53:09 -04:00
d537946ff6 Make the save-dialog memory test path-separator independent (#2745) (#2782)
pick_save builds the suggested path with Path::join, which uses `\` on
Windows, but save_dialogs_remember_where_each_document_was_last_saved
compared it against hard-coded "/dir/name" strings, so it failed on
windows-latest. The behaviour is correct; build the expected values with
Path::join too.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 15:51:08 -04:00
Valentin KleinandClaude Opus 5.5 db7a7885f4 Channels: Delete with one channel targeted clears only that channel (#2720) (#2730)
Edit > Clear ignored the Channels panel target: with a single colour channel
targeted it filled every channel of the Background with the background colour,
and made a normal layer's pixels transparent. It now sets only the targeted
channel inside the selection to the background colour's value for that channel
(partly selected pixels blend), keeps the other channels and the layer's
transparency, and stays one undo step, as Photoshop does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 15:48:01 -04:00
Valentin KleinandClaude Opus 5.5 e57ee674ae PSD: warn when the Lr16/Lr32 layer block is unreadable, save one layer list (#2700) (#2709)
A 16/32-bit file whose global Lr16/Lr32/Layr block failed to parse opened
as a single Background layer from the merged image with no warning: the
parse error was discarded in PsdFile::from_bytes. The raw block stayed in
the preserved global blocks, so a PSD save wrote the document's fresh Lr16
plus the unreadable original: two layer lists in one file.

- psd: PsdFile::unreadable_layer_block() reports the kept block and its
  parse error.
- io import: a warning names the block and the error and says only the
  merged image was opened.
- io export: a preserved Lr16/Lr32/Layr block is not re-emitted when the
  document's own layers are written.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 15:42:00 -04:00
Sir StarchandClaude Sonnet 5.5 e2aa19341f PSD export: pad regenerated fill blocks to 4 bytes, as Photoshop does (#2759)
Photoshop pads every layer-level tagged block to a multiple of 4 inside the length field; all 567 Photoshop-authored fixtures in the corpora do. The exporter padded regenerated blocks to even only, so a Pattern Fill's PtFl came out 2 bytes short of the boundary for half of all pattern names and left the layer record's extra data unaligned.

The pad is applied where the fill blocks are regenerated, not in the generic loop: preserved blocks must stay byte-identical (psd_structure, roundtrip) and shmd is length-structured (comps_artboards).

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-10 15:39:01 -04:00
MarekSpodBiedry ab76c242a1 File > Scripts > Browse: filter the picker to script files (#2452) (#2499) 2026-10-10 15:20:22 -04:00
Benziza 0b4fe4b17f Keep As Is when reusing variable definitions (#2655) 2026-10-10 15:16:42 -04:00
Brandon ThomasandClaude Opus 5.5 939c8f2c70 Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#2696)
Progress docs per craftrules standards/progress-docs.md: target-app parity (merges parity-estimate.md), gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and UI, brush, format, hardware and localization parity docs. docs/parity.md is now docs/parity-checklist.md.

The Windows test failure (file_dialog::tests::save_dialogs_remember_where_each_document_was_last_saved) is pre-existing on main: the test hard-codes POSIX paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 15:09:04 -04:00
Reynold HuandBrandon Thomas 5ab78e8ce1 feat(ui): the Use Smart Quotes preference turns typed quotes typographic (#204) (#2522)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:55:53 -04:00
skylar taylor-barrick 99a3970399 UI.9: preserve PhotoCraft controls in shared UI migration 2026-10-10 12:51:56 -06:00
Erica FrancoandBrandon Thomas 706c730cd2 i18n(es): translate the Add Noise and Reduce Noise dialog controls (#2470)
Ten labels under Filter > Noise and Filter > Sharpen fell back to English in a
Spanish UI. Add Noise showed Distribution, Uniform, Monochromatic and Seed next
to a translated "Cantidad"; Reduce Noise showed Strength, Reduce Color Noise,
Sharpen Details and Remove Jpeg Artifact next to a translated "Conservar
detalles"; Smart Sharpen showed Reduce Noise.

Wording follows the terms the catalog already uses (Noise > Ruido, Sharpen >
Enfocar, Remove > Quitar, Amount > Cantidad) and the file's clean-room note:
written from the plain meaning of the English labels, with no localisation
resource consulted. "Gaussian" is only ever a distribution value here (Add
Noise and Lens Blur), so it reads "Gaussiana".

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:46:28 -04:00
Erica FrancoandBrandon Thomas 31def413cc fix(docs): Linux build prerequisites only name Debian packages (#2437)
The prerequisites listed `libxkbcommon-dev ... libgtk-3-dev`, which exist only
on Debian and Ubuntu: on Arch none of those names resolve and `pacman -S` fails
on the whole line, and on Fedora and openSUSE they all need `-devel`. Nothing
else in the repository gives the equivalents.

The app already speaks four distros at runtime: linux_libs.rs carries a
{soname, debian, fedora, arch, suse} table, reads ID and ID_LIKE from
/etc/os-release and prints the matching apt, dnf, pacman or zypper command when
a runtime library is missing. This brings the build instructions up to that.

Also adds the prerequisites to the book's Building page, which said only to
install the Rust toolchain.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:35:12 -04:00
f8ea3d8423 Ruler units keep their own decimal places (#2434) (#2476)
* Ruler units keep their own decimal places (#2434)

Document sizes were shown with one decimal place whatever the unit, so a
1234 px side at 300 ppi read 4.1 in instead of 4.113 in, and millimetres,
centimetres and picas lost a place too.

`Unit::decimals` now follows Photoshop: whole pixels, one place for points
and percent, two for millimetres, centimetres and picas, three for inches.
`fmt_decimals` rounds half away from zero and drops trailing zeros, so a
value is never shown with more precision than it has: 0.7995 in reads 0.8,
never 0.799 or 0.800, and a whole number keeps no trailing .0. It guards the
zero-decimal case so 550 px cannot lose its zero.

The Info panel readout, Image Size, Canvas Size, New Document and the shape
dialog all take their precision from that one place, through the new
`widgets::value_field_prec` and `widgets::unit_decimals`.

Percent keeps its single place: it was not reported and reads better coarse.

* Pass the new decimals argument at the remaining value_field_in call (test)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:34:49 -04:00
0c907fb7f9 Properties ▸ Layer Mask: add Photoshop's Refine row (#2425) (#2639)
* Properties ▸ Layer Mask: add Photoshop's Refine row (#2425)

Below Density and Feather the mask section now offers Select and Mask…,
Color Range… and Invert, each dispatching the command the menu item does
(`select.selectAndMask`, `select.colorRange`, `image.adjustments.invert`)
and greyed by the same rule. Invert passes `"target":"mask"` itself rather
than relying on the shell's target routing, so it inverts the mask and can
never reach the layer's pixels (#1137).

Tests: the three controls are present and labelled in both themes and
follow the mask target; Invert inverts the mask pixel for pixel, leaves the
layer's own pixels untouched and makes one undoable step; the two dialog
buttons open the Select and Mask and Color Range dialogs. New "Refine"
label translated in the sixteen complete catalogs.

* Refine row: translate the button labels

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:30:23 -04:00
Gert Pellin - GPEandBrandon Thomas 9ccea32651 Layers panel: a dragged layer copies onto its own row and into the empty space below the stack (#1721) (#2525)
* Layers panel: a dragged layer copies onto its own row and into the empty space below the stack (#1721)

* rustfmt

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:23:22 -04:00
eab15ffdf6 Image warp: grab anywhere on the grid, Ctrl-click to cut, Warp in the transform menu (#2621)
* Image warp: grab anywhere on the grid, Ctrl-click to cut, Warp in the transform menu

Warp mode of Free Transform now works from anywhere inside the grid, not
only on its points:

- Press and drag on any part of the grid and the surface under the pointer
  follows it. The surrounding anchors bend with it, fading out with
  distance. The picture's border stays put unless the grab is made near
  it, and then the border bows through its own handles. The four corners
  never move.
- Ctrl-click (or Option-click) cuts the grid both ways at the pointer.
- Right-clicking a Free Transform box lists Warp after Perspective. The
  menu marks only the box's real current mode.
- Anchor and handle drags keep every grid line smooth through its anchors.
- Grid points are quieter: anchors are small, and a handle with its arm
  shows only near the pointer or while it is dragged.
- The preview draws the most displaced cells first and samples finer where
  the warp pulls hard, so a strong pull no longer shows as a comb.

Refs #214

* Warp options bar: Split, Grid and Warp controls with orientation and bend always shown, Reset, and the buttons right after the fields

The Warp bar now reads Split, Grid, Warp style, orientation and Bend / H / V.
The orientation and bend fields are always there and only act on a preset
style. A new Reset button puts the grid back to the default untouched one
while staying in warp mode.

Cancel and Commit sit right after the fields instead of at the far right of
the window. A bar too narrow for both pins them to the right edge and clips
the fields, so they stay reachable. The Free Transform bar shares the same
function and gets the same placement.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:22:46 -04:00
Ayan 76fa217669 Quick Selection: 3.5x faster CPU strokes with hybrid graph cuts (#2436)
* Speed up Quick Selection with hybrid graph cuts

* Record Quick Selection stroke benchmarks and regression coverage
2026-10-10 14:17:48 -04:00
ac279f6268 Keep the layer's place in the stack during Free Transform (#2439) (#2480)
The Free Transform preview drew the moving pixels over the whole
document, so a layer under others jumped to the top while the box was
open. Plain Normal layers over it are now composited once on their own
and drawn over the moving pixels; the canvas under the box leaves them
out. The box still moves on the GPU mesh with no per-frame composite.

Adjustment layers and other blend modes depend on what is under them,
so the first such layer from the top, and everything under it, stays in
the document under the box as before.

Co-authored-by: Simon Geljic <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:16:24 -04:00
225ccc0db1 Crop tool: Photoshop's default mode turns the image behind an upright box; Use Classic Mode (P), Auto Center Preview (#1919) (#2545)
* Crop tool: Photoshop's default mode turns the image behind an upright box; Use Classic Mode (P), Auto Center Preview (#1919)

The Crop tool's gear menu gets Use Classic Mode (P toggles it while the tool shows a box; off by
default, as in Photoshop) and Auto Center Preview (on by default). In the default mode the box
stays upright on screen: a drag outside it turns the image about the box's centre (Shift 15 deg
steps, angle readout), a drag inside moves the image under the box, the arrow keys nudge the
image, Straighten turns the image so the line is level, and with Auto Center Preview a drawn or
resized box is re-centred in the canvas. Classic Mode is the previous behaviour.

The crop model and the commit are shared: crop_rect + crop_angle and one image.crop {angle}.
crop_mode.rs only drives the view: while a frame is edited the view's rotation is -crop_angle,
pivoting so the box (or the pressed point) stays put, and the user's view (a Rotate View turn,
its centre) is put back on commit, cancel, leaving the tool or switching to Classic Mode. The
untouched default frame leaves an R turn alone. The options live in ToolOptions.crop_shield
(classic_mode, auto_center_preview; ui.set cropShield). ui.pointer reads a default-mode drag's
points in the view at its press, so an up where the last move was is idempotent.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Crop default mode: match Photoshop (view upright on tool pick, live Auto Center, image-turn readout, gear menu order) (#1919)

Checked by hand against Photoshop in its default (non-classic) mode:

- Picking the Crop tool turns a Rotate View (R) turn upright at once (about the view centre),
  and the view stays upright after Enter or Esc: the save/restore of the user's view is gone.
  Classic Mode still leaves the view alone (not checked against Photoshop).
- The angle beside the pointer while the image turns is the image's turn, clockwise positive.
- Auto Center Preview keeps a box being resized centred live: the handle follows the pointer,
  the box grows on both sides, and on the image the opposite edge (with Alt the centre) stays
  put. Computed from the press, so the view following the frame can't feed back into the drag.
- Gear menu order as Photoshop's: Use Classic Mode (P), Show Cropped Area (H), Auto Center
  Preview, no separator; Classic Mode greys out the other two. Show Cropped Area then acts as on
  and H does nothing (the stored choice is kept). German: Classic-Modus verwenden, Automatische
  zentrierte Vorschau.
- Crop preview past the canvas in a turned view: each band is drawn as its turned quad (the
  screen bounding box stretched band textures and spread the checkerboard). The white corners in
  the earlier offscreen snapshot were egui_kittest clamping the repeating checker texture; the
  real app shows the checkerboard in both modes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: regenerate scorecard for the crop default-mode row (#1919)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Crop default mode: the canvas keeps the view the Crop tool moves; a move starts from a centred box (#1919)

Tried by hand in the app: with Auto Center Preview on, a drag inside the box made the box wander
instead of moving the image, and a handle drag didn't keep the box's centre in place (after a
Space pan, too). Root cause: canvas_view works on a copy of the view for the frame and writes it
back at the end, so every camera change the Crop tool made during the frame (the image moving
with the pointer, the live-centred resize, Auto Center on release, the pivot while turning) was
undone, while the frame itself moved in the document. Tests driving tool_event never saw it.

- canvas_view notes the camera before the frame's input and, before writing its copy back,
  keeps a centre or rotation a tool changed meanwhile.
- With Auto Center Preview a move inside an off-centre box (after a pan) centres the box first,
  then grabs the image point now under the pointer.
- New tests/crop_default_mode.rs drives the full app UI with egui pointer and key events over
  several frames (offscreen wgpu): the box stays put while the image follows the pointer, and a
  resize after a Space pan keeps the box's centre fixed while the handle follows the pointer. Both
  failed before the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Crop default mode: a moved or resized box stays where it is on screen (#1919)

Checked in Photoshop: after a Space (Hand) pan the box isn't snapped back to the middle of
the canvas. A drag inside moves only the image, the box staying wherever it is; a resize keeps the
box's centre where it was at the press (also after a pan), and its release no longer pans the box
to the middle. Auto Center Preview still centres a newly drawn box on release and X still
re-centres. Classic Mode is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Crop tool: Classic Mode turns a Rotate View turn upright too (#1919)

Checked in Photoshop: with Classic Mode on and the view turned with Rotate View (R), picking
the Crop tool also turns the view upright, and the crop frame stays on the same image area. So the
Crop tool now drives the camera in both modes: default mode keeps it at -crop_angle (the box
upright), Classic Mode at 0 with the frame drawn with its own turn. Picking the tool pivots about
the view centre; commit and cancel leave the view upright in both modes, and P switching modes
never brings the R turn back. Auto Center Preview stays default-mode only.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Regenerate the scorecard; update the crop test's header

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

* Crop: snap a new box's corner inside the untouched default frame in both modes (#2549 with #2545)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:14:19 -04:00
f521e231c3 Free Transform: refuse a locked layer up front (#2585) (#2609)
Cmd+T (and Edit > Transform, the Move tool's transform controls) on a
layer locked in place opened the box and previewed every drag; only the
commit failed. Photoshop refuses before the box opens with "Could not
complete the Free Transform command because the layer is locked."

transform_cmds::locked_for_transform applies the commit's own rule (its
own position/all lock or an enclosing group's, a group's locked layers,
never the Background, which becomes Layer 0) and transform_tool::begin
asks it first, opening the alert in the Error dialog as the Move tool's
lock message does. Translated in every catalogue.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:10:37 -04:00
3092d3b6f9 Tests: line-tables-only dev debuginfo, nextest GPU group, faster loop docs (#2204) (#2414)
- [profile.dev] debug = "line-tables-only", matching CI's CARGO_PROFILE_DEV_DEBUG; halves link
  time and PDB size for test binaries.
- .config/nextest.toml: optional cargo-nextest runs with the GPU tests capped at 4 concurrent
  processes (unlimited ones time out in wgpu).
- AGENTS.md / docs/development.md: iteration commands (--lib --tests, --test <file>, nextest,
  doctests separately). CI is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:03:30 -04:00
AyanandBrandon Thomas 0a102eb920 Pixelate: CMYK Facet 13.7x faster, Lab halftone 5.0x faster (#2588)
* Reuse Facet quadrants and halftone dots without changing pixels

* Record full-image Pixelate timings and exact-output checks

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:03:03 -04:00
mroxsoandhighperfocused d59d2b509f Channels: tint single colour channel views on the canvas with Show Channels in Color (#2385) (#2574)
Interface > Show Channels in Color only reached the Channels panel
thumbnails (#1986); the canvas overlay hard-coded in_color = false, so an
isolated RGB/CMYK channel stayed gray while its thumbnail changed color.
channel_view::ensure now reads the preference (the view cache key already
carries the flag, so toggling rebuilds the texture), and a hidden Lab
Lightness goes neutral instead of black under the preference, so an
isolated a/b channel tints over mid gray like Photoshop. Lightness itself
stays grayscale (Adobe's exception).

Verified with the offscreen snapshot example: RGB green canvas goes
(128,128,128) -> (0,128,0) and CMYK cyan -> (0,176,232) when the
preference is on; unit tests cover render(), the Lab exception and the
cache key.

Co-authored-by: highperfocused <[email protected]>
2026-10-10 14:02:38 -04:00
36dbceed80 Search: an exact name match ranks first (#2160)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:01:27 -04:00
texjer 78f32cac26 Dialogs: Tab walks the text fields only, as in Photoshop (#1285)
In Photoshop, Tab in a dialog goes from one text field to the next (Hue →
Saturation → Lightness; Name → Width → Height → Resolution) and never stops
on sliders, dropdowns, checkboxes or buttons; the field reached has its text
selected. egui's Tab walked every focusable widget in creation order, so the
first Tab in Hue/Saturation landed on the dialog's title bar.

field_tab.rs: the dialog frame takes Tab / ⇧Tab itself, fields register as
they are laid out (widgets::number_edit and the New Document name) and focus
moves to the next one. The focus request is deferred a frame so egui's own
select-all on focus fires (requesting focus after layout never reads as
"gained focus"). New Document opens with the name selected, so typing
replaces it and Tab goes straight to Width. After an arrow-key step the
number field's text stays selected, so typing replaces the value.
2026-10-10 14:01:17 -04:00
3a29be7f5e Colour swatches open PhotoCraft's Color Picker: filter dialogs, Preferences, Note, Count, artboard (part of #2144) (#2571)
These swatches opened egui's compact popup. color_picker_ui::request_field
lets a dialog ask for the picker on any field (a key or a JSON pointer);
the Count and Note colours go through count.setGroup and a noteColor
target, the artboard background through layer.artboard.set. The Count and
Note swatches also treated their sRGB colours as linear.

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:01:06 -04:00
cebdbb21ea fix(ui): unify Spectrum menu spacing and hover contrast (#2323)
* fix(ui): unify Spectrum popup padding and hover contrast (#2187)

* fix(ui): unify Spectrum popup padding and hover contrast (#2187)

* fix(ui): unify Spectrum popup padding and hover contrast (#2187)

* fix(ui): unify Spectrum popup padding and hover contrast (#2187)

* fix(ui): unify Spectrum popup padding and hover contrast (#2187)

* fix(ui): remove unused menu token after sharing popup style

* test(ui): cover Spectrum popup hover contrast and theme isolation

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:00:56 -04:00
fa33712dd3 Speed up Iris Blur without changing output (#2578)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:00:53 -04:00
JeremyandClaude Opus 5.5 7e46557283 Color panel: the field and hue strip fill the panel height, like Photoshop (#2455)
The pro Color panel drew its saturation/brightness field at a fixed 120 pt, so a taller
panel left empty space under the hex/RGB readout. The dock now passes the group body height
to the field, which takes it less the readout (and a second readout line in a narrow dock).
Never below 120 pt; a non-finite height falls back to it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:00:46 -04:00
caa43fa3f6 Keep plug-in menu rows under Filter › Plug-ins in every language (#2540) (#2627)
* Keep plug-in menu rows under Filter › Plug-ins in every language (#2540)

plugin_ui::insert_menu_items built the insertion path from translated
strings (tl!("Filter"), tl!("Plug-ins")), but MenuItem::path is the
untranslated English path from a command's engine spec: the renderer
groups rows by it and translates only at display time. Under any
language whose catalog translates those menu names (ja, fr, ...) the
plug-in rows got a path no menu node matched, so installed WebAssembly
plug-ins were never drawn. Take the path from the "Install Plug-in…"
item the rows sit above instead, and cover it with a test that installs
the invert.wasm fixture under a Japanese language scope.

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 14:00:36 -04:00
alexanderalberandBrandon Thomas ee4373521e Ctrl+Tab and Ctrl+Shift+Tab switch to the next and previous document (#2340) (#2353)
* Ctrl+Tab and Ctrl+Shift+Tab switch to the next and previous document (#2340)

Photoshop's document-cycling keys, on macOS too (Cmd+Tab belongs to the
system). They follow the tab order and wrap around at either end, and they
reach the documents even while a field or widget has the keyboard focus,
since nothing edits with Ctrl+Tab. Plain Tab still hides the panels.

generated with Claude Code

* Contributors: add my name for the About window

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 14:00:26 -04:00
edd9fe12d7 Skip distant TSL-off stroke layers during composition (#2473)
* Skip distant TSL-off stroke layers during composition

* Narrow the TSL-off stroke shortcut: shape outlines count, other clipping bases keep the empty skip

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:58:04 -04:00
865c8a15f8 fix(ui): toggle color swatches when selecting or deselecting a layer mask (#2560)
* fix(ui): toggle color swatches when selecting or deselecting a layer mask (#2420)

- Call sync_mask_targets() when handling ui.maskTarget / ui.vectorMaskTarget
  clicks in the Layers panel, Channels panel, and control channel
- Automatically target the layer mask when creating new adjustment or fill layers
- In tools.defaultColors, reset to mask default colors (white/black) when a
  mask is currently targeted
- Add unit test in engine and UI harness test in mask_thumbs_ui

* rustfmt

* Rasterize prompt test: target the fill layer itself, now that a new fill layer targets its mask

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

---------

Co-authored-by: huudinh1401 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:55:43 -04:00
Elvis33LEandBrandon Thomas ff9663bc9a EXR parts: import parts, channel groups and an AOV precomp as layers (#2338)
* EXR parts: import parts, channel groups and an AOV precomp as layers

file.exrParts lists what an EXR holds for a chooser - flat parts, prefixed
channel groups (diffuse.R/G/B), Cryptomatte layers as information;
file.openExrParts imports the chosen ones as the layers of one document
(F16 lossless as F32, linear sRGB tagged), with an optional additive
beauty precomp summed from the light-path AOV set (direct/indirect
diffuse+specular, emission; transmission/sss/volume when present) that
fails naming the missing AOVs. The codec gains decode_exr_channels for
exact named channels of one part; io shares the image_surface band writer.

File › Open EXR Parts… picks an EXR and opens a plain command form: a
checkbox per part and channel group (labels are the data's names with
size and channel count), a both/left/right view dropdown, and the
precomp checkbox. Cryptomatte layers ride along as non-rendered data for
the control channel. Form fields gain __labels for verbatim data-driven
labels; the new strings are catalogued in all sixteen complete languages.

* Regenerate parity and scorecard docs

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 13:51:06 -04:00
David MannandClaude Opus 5.5 7c18c68e24 Crop tool: W x H x Resolution mode resamples the crop to a size and resolution (#2443) (#2483)
image.crop takes optional targetWidth/targetHeight (px), resolution (ppi)
and resample: the cropped area is resampled to exactly that size (the
Image Size code path, now shared) and the resolution set, in the same
undo step, also after a turned crop. The Crop options bar gets
W x H x Resolution with unit-aware W/H fields, a px/in or px/cm
resolution, Photoshop's built-in size presets, Front Image (and I) and
Clear; the frame holds W:H while drawn or resized, X and the swap button
swap W and H.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 13:46:31 -04:00
ab15e4ea96 Curved shapes no longer report bounds 1 px too wide (#2537) (#2641)
* Drop f32 round-off coverage in the vector rasterizer (#2537)

Each row's cell accumulator is prefix-summed in f32. A left boundary adds
coverage and the right boundary subtracts it, but for curved or off-grid
edges the two don't cancel exactly, leaving ~1e-7 coverage in the pixel
just past the shape. Shape layers wrote the fill colour there (alpha
rounding to 0 at 8/16-bit, ~1e-7 at 32-bit), so content bounds and
shape.info reported curved shapes one pixel too wide.

Snap coverage below 1e-6 to 0 when writing a row. That is under half a
16-bit step, so 8- and 16-bit coverage values are unchanged; only the
residue pixels go away. Selections, vector masks and strokes share the
rasterizer and get the same fix.

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

* Rasterizer: NaN coverage becomes 0, not full (#2537)

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:41:51 -04:00
14599472af Re-render shapes when an artboard move grows the canvas (#2389) (#2623)
Shape layers render their pixels clipped to the canvas. Moving an
artboard (layer.artboard.set or layer.translate) re-rendered its shapes
before fit_canvas grew the document, so a shape moved past the old edge
came out missing or cut off until another shape edit refreshed it.

fit_canvas now re-renders every shape whenever it changes the canvas
size, which covers all its callers.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:41:47 -04:00
Harrison O'Connor-Hoover 0a7b8cab54 Create new documents by double-clicking presets (#2290) 2026-10-10 13:40:51 -04:00
Harrison O'Connor-Hoover ca3718e7b5 Route menu Cut through the active type selection (#1448) 2026-10-10 13:40:40 -04:00
Simon f5de46e60a Menus: Reveal in Finder names the platform file manager off macOS (#217) (#2586)
* Menus: name the platform file manager in Reveal in Finder off macOS

The command opens Explorer on Windows and the desktop's file manager
(xdg-open) on Linux, but the Smart Objects menu item and the document
tab menu still said "Reveal in Finder". menus::reveal_label picks
"Show in Explorer" on Windows and "Show in Folder" on Linux and the web;
macOS keeps "Reveal in Finder". The menu catalogue (docs/parity.md),
the engine command labels and the command ids stay unchanged.

The help search, command palette and keyboard shortcut list read the
relabelled menu item, so they follow. Every complete language gets both
new labels, with the localized Windows name for Explorer.

* scorecard: platform shortcut hints and Reveal label are done (UI-217-19)
2026-10-10 13:40:30 -04:00
reese e81d5fd4ec fix(ui): nudge number-field digits 1 pt up so they look centred (#1322) 2026-10-10 13:40:20 -04:00
Nigel Ihza Firdausy f8744c6b81 fix(ui): dim unchecked layer style effect labels even when selected (#2288) (#2321) 2026-10-10 13:40:10 -04:00
62aa421d75 Keep menu separators out of the command palette (#2479) (#2632)
The palette fuzzy-matched every menu item against its label and menu
path, separators included. A "---" row under File or Filter matched
queries like "f" on its path and filled the results with blank rows.
Skip separators the same way Help > Search already does.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:40:00 -04:00
Elvis33LE b75267899d DNG: lossy compression 34892 (baseline JPEG tiles) (#2021)
* DNG: lossy compression 34892 (baseline JPEG tiles)

Adobe's lossy DNG (compression 34892) decodes: each tile is a complete
baseline JPEG (8-bit luma at tile size), decoded by zune-jpeg into the
tile geometry (a mismatch is an error), widened 0xFF -> 0xFFFF like
Photoshop's read of 8-bit CFA data. Predictors and the rest of the
pipeline are unchanged.

testgen grows LossyJpegTiles (image-rs encodes the Q60 fixtures behind
the testgen feature), so CI runs a decode oracle: a smooth ramp stays
within a few quantisation steps of the 8-bit source, and a tile cut
mid-stream fails cleanly.

* Lossy DNG: check the tile JPEG header before decoding; zune deps go workspace

decode_headers() runs and the (width, height) == (s.w, rows) check fires
before decode(), so a hostile SOF cannot allocate the decoder's buffers
(up to 16384x16364) before the size mismatch is caught. The mismatch has
a regression test that swaps a 32x32 tile JPEG for a 16x16 one. zune-core
and zune-jpeg move to [workspace.dependencies] next to image and flate2.

* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)

* Lossy DNG: 3-sample LinearRaw (YCbCr) decodes to RGB; tiles size-check

Real lossy DNGs are 3-sample YCbCr LinearRaw, not single-channel CFA:
the tile now decodes through zune-jpeg's YCbCr-to-RGB output when
SamplesPerPixel is 3 (gray stays for CFA), the decoded length is checked
against the tile's sample count, and other sample counts say unsupported.
The generator writes RGB tiles for 3-sample specs. A LinearizationTable
maps the decoded samples in the develop stage as before - nothing to add
in the sensor. Test: a 3-sample ramp with 64-step channel separation
(surviving Q60 chroma quantisation) decodes within the quality band.
2026-10-10 13:39:57 -04:00
Valentin KleinandClaude Opus 5.5 c908f76827 Dropdowns: open lists start at their widest option's width (#2607) (#2634)
egui takes a plain row's frame stroke off its padding but draws a hovered
row with the stroke, so a row grows by two stroke widths under the pointer.
The popup keeps the widest width it has seen, so scrolling the widest option
under the pointer widened the list by 2 pt. The shared dropdowns now size the
open list to the widest option as drawn hovered from the first frame.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:39:50 -04:00
fdf5cd668b Push neighbouring dock groups when dragging a splitter (#2573) (#2624)
Dragging a dock splitter only traded height with the next expanded group,
so it hard-stopped once that group hit its minimum. resize() now shrinks
the groups on the side the splitter moves into in turn, nearest first,
each down to its minimum (skipping collapsed groups, the filler taking its
share implicitly), and grows the group on the other side by what they
gave. Works both ways; the column height stays the same.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:39:36 -04:00
Jason 7e6fc93aa9 Close a document tab with a middle click (#2570) (#2637)
Middle-clicking a document tab closes that document, as in a browser. Both
tab strips share one rule (`tab_close_clicked`): the x, or a middle click
anywhere on the tab, including over the x. It routes through the same guarded
`file.close` as the x and the context menu, so a document with unsaved
changes still gets the Save / Don't Save / Cancel prompt.

Opening-file tabs are untouched: their x still cancels the job.
2026-10-10 13:39:27 -04:00
alexanderalber 1c4a0d12da Image Size and Canvas Size open on Width, selected (#2329) (#2357)
The filter dialogs open on their first number since #1757: typing replaces
it and Enter applies it. Image Size and Canvas Size draw their own bodies
and were left out; they now go through the same once-per-dialog focus,
moved into dialogs::first_field_focused.

generated with Claude Code
2026-10-10 13:39:17 -04:00
Roaldo Sulejmani 30bf920d75 Windows: keep the custom title bar's window borderless (#2246) (#2401)
winit 0.30 gives an undecorated window WS_CAPTION | WS_BORDER and hides the
non-client area by spreading the client rect over it (rust-windowing/winit#2419).
On some Windows systems the compositor still reserves that frame, so the content
is drawn offset by the caption and border while input stays at the client origin:
black bars at the window's top-left and a pointer that no longer matches what it
points at.

Strip WS_CAPTION | WS_BORDER from the window (keeping WS_THICKFRAME and the window
buttons, so edge resizing and Aero snap keep working) and recalculate the frame,
restoring winit 0.27's undecorated behaviour. The guard runs every frame from
raw_input_hook, so a style winit restores on a flag change (maximize, restore, …)
is taken off again. Decorated windows (the system title bar preference) are left
alone.
2026-10-10 13:39:07 -04:00
Jason 352e1cf168 Opening card: a long file name no longer pushes Cancel out of the card (#2423) (#2444)
The card painted a fixed 340×132 rect and laid its rows out with no height
constraint, so a title that wrapped (long names, long translations) pushed
the percentage and Cancel onto the backdrop. The title and message are now
single-line truncated labels (the full text on hover), and the card is
painted after its rows are laid out, growing from the 132 pt minimum to fit
them. It stays centred at the height it needed last frame, settling in one
repaint.

The regression test fails on the old layout (the title wrapped to three
lines) and asserts the title is one line and both the title and Cancel lie
inside the card.
2026-10-10 13:38:57 -04:00
Valentin KleinandClaude Opus 5.5 0f6d3dfea1 Paste: a web Ctrl/Cmd+V key press and its paste event are one paste (#2638) (#2643)
On the web eframe forwards the Ctrl/Cmd+V key-down and then the browser's
paste event (Event::Paste when the clipboard holds text) in the same frame.
clipboard_keys turned the event into a second press, so edit.paste ran twice.
A clipboard event right after its own command key press now counts as that
press; the same goes for Ctrl/Cmd+X with Cut and Ctrl/Cmd+C with Copy.
Desktop is unchanged: egui-winit never sends both.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:38:48 -04:00
David MannandClaude Opus 5.5 6f5e16a0c4 Filter Gallery: preview at the scale OK applies (#2502) (#2524)
Below 100 % the preview filtered every k-th source pixel with unscaled
params, so strokes, grain and textures showed several times too large.
It now runs the stack at full resolution on the visible region (the
filters read their reach around it, and noise and textures are seeded by
document position, so the pixels equal the same crop of the applied
result), then averages k x k blocks down to the zoom.

On the desktop the render runs on a worker thread with a progress bar in
the zoom bar; the last preview stays up and moves with the view, a stale
render is cancelled once the settings settle, and closing the dialog
cancels it. Web and inline sessions render synchronously.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 13:38:39 -04:00
0a18937573 Text fields: copy, cut and paste through the Mac menu's key equivalents (#2568)
A text field copies and pastes on egui's Copy / Cut / Paste events, which egui-winit makes from
the window's key. With the native menu bar, AppKit takes ⌘C / ⌘X / ⌘V as Edit › Copy, Cut or
Paste's key equivalent whenever that item is enabled, and the key reaches egui as a plain key
press, which a text field ignores. So pasting text into a field (a dialog's text input, a panel
search box) worked only while PhotoCraft's own clipboard was empty and Edit › Paste disabled.

While typing, ⌘C / ⌘X presses become Copy / Cut, and a ⌘V press asks the window for the
clipboard text, which arrives as a Paste event. egui-winit never sends these presses itself
(it sends the events instead), so this changes nothing without the native menu. It covers the
type tool's editor as well as egui text fields.

Co-authored-by: Trade-Mottoes <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:38:33 -04:00
0384b1af6d Warp Text: the dialog opens with the layer's warp and previews it live (#218) (#2539)
* Warp Text: the dialog opens with the type layer's current warp

Type > Warp Text... always started at Arc with Bend 50, whatever warp
the layer had, so OK without a change replaced a Flag warp with Arc.
The dialog is now the schema dialog of type.warpText: it starts from
the shown type layer's style, bend, distortions and orientation (None
for a layer without warp, as in Photoshop) and keeps the targets it
opened on (selected type layers or the inline edit). Remembered dialog
values never override the layer's warp.

* Warp Text: the dialog previews the warp live on the canvas

The Warp Text dialog showed its result only after OK. It now has the
schema dialogs' Preview checkbox and live canvas preview; Cancel leaves
the document as it was and OK stays one history step.

The preview of a large document runs on a reduced proxy, but Warp Text
renders the type again from the layer model, at full size: the preview
showed the text k times too large and out of place. Warp Text runs on
the full-size copy and its result is reduced instead.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:38:17 -04:00
Erica FrancoandBrandon Thomas 1e2194a054 fix(ui-egui): ui.key with ctrl does not trigger shortcuts off the Mac (#2430)
* fix(ui-egui): ui.key with ctrl does not trigger shortcuts off the Mac

`ui.key` and `ui.pointer` built egui's modifiers with `command` and `ctrl` as
independent fields. Off the Mac they are the same physical key: egui asks for
both to carry the same value and matches shortcuts on `command`, so a request
with only `ctrl` set produced a state a real key press never produces and
matched nothing. `ui.key {"key":"N","ctrl":true}` replied ok and did nothing,
while `command: true` opened New Document.

One `modifiers_from` helper now serves both: off the Mac the two flags are
paired, on the Mac they stay apart as ⌘ and Control. Callers already sending
`command` are unaffected.

* contributors: add my own entry (GitHub profile name)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 13:38:12 -04:00
c287ecabc5 Fixed a bug where if you select a mask and hit the delete key, it deletes the image (#2635)
* Fixed a bug where if you select a mask and hit the delete key, it deletes the image

* Delete on a targeted mask: fill the selection with the background colour; delete the mask only with no selection

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:33:53 -04:00
Nigel Ihza FirdausyandBrandon Thomas 8d13aff784 fix(layers): preserve Background behavior across merge, duplication and canvas resize (#2419)
* fix(layers): keep Background semantics across merge, duplication and canvas size

Preserve destination locks in Merge Down, share the normal Background-copy rule with multi-layer duplication, and convert a Background to a paintable layer when extending its canvas transparently.

Add depth-aware regression coverage and ordinary-layer controls for #2380, #2381 and #2384.

* refactor(layers): copy lower-layer locks without cloning Copy data

* Format background_layer_consistency test

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 13:26:11 -04:00
28ed1c1147 Handle allocation failures in large pixel operations (#2530)
* Make large pixel operations allocation-fallible

* Warp: drop the fixed 400 MP cap; fallible reservations already cover large transforms

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

* Adapt #2530 to main: fallible steep-Distort warp, Result from mask paste in float moves; regenerate scorecard

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:26:07 -04:00
David MannandClaude Opus 5.5 d456f6701e Filters: full-resolution preview for Add Noise, Wind, Extrude, Tree, the Filter Gallery and more (#2063) (#2494)
A sweep of every previewed command (default params plus small and large
pixel sizes, on smooth, sharp and noisy images) compared the 1/4 proxy
preview with what OK applies. Still mismatched after #2207:

- Fixed pixel features, now previewed at full resolution: Add Noise,
  Reduce Noise (fixed kernel radii), Custom (5x5 kernel), De-Interlace
  (scan lines), Trace Contour (1 px lines), Wind (fixed streaks), Extrude
  (depth in px), Tree (6 px branch segments), all 47 Filter Gallery
  filters, and Indexed Color / Bitmap when they dither.
- Lens Correction's perspective horizontal/vertical and Tree's and
  Picture Frame's size are relative, not pixels: no longer divided by k.
- Negative lengths (Offset left/up, Displace) were clamped to +0.1.
- Shadows/Highlights' radii were not scaled.
- Mosaic rounds its cells: k now divides the cell size evenly.

The new every_preview_shows_what_ok_applies test checks all previewed
commands (about 3 s); sweep_all_previews (ignored) prints the table.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 13:25:59 -04:00
743d81307b Fix/2361 scrub layer labels s20261010 t083444 z og3kyw (#2397)
* fix(layers): scrub opacity and fill by dragging labels (#2361)

* test(layers): compare translated scrub labels as text

* test(layers): query egui label roles for scrub gestures

* cargo fmt

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

* Layer label scrub test: find the text label by its value on the popup arrow's row

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:24:54 -04:00
401357306b Shift-click New Group groups the selected layers (#2561) (#2613)
The Layers panel New Group button always ran layer.new.group, so
Shift-click made an empty group too. Photoshop documents Shift-click on
that button as grouping the selected layers (same as Layer > Group
Layers). Shift-click now runs layer.groupLayers; a plain click still
adds an empty group, as in Photoshop.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 13:20:12 -04:00
Manuel MontotoandClaude Opus 5.5 0140ffdf9d fix(ui): keep menus responsive on documents with thousands of layers (#2454)
With a 3,593-layer PSD open, an open menu rebuilt its 809 items every
frame in about 700 ms. Each item's enabled/checked state looks up the
active layer several times, and Document::layer built the whole layer
list (one allocated path per layer) for every lookup.

- Document::layer and path_of search the tree directly: 265 us -> 23 us
  per lookup on that file, 700 ms -> 87 ms per menu build.
- An open menu reuses its items between frames, rebuilding when the
  native menu's state hash changes or on a click or key press. The hash
  now also covers a running background job.


Claude-Session: https://claude.ai/code/session_01TABvQWhsoDmc14uLCnkBJJ

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:17:38 -04:00
bfbc9903d5 cms: accept ICC DeviceLinks whose output isn't a 3-channel PCS (#2348)
A DeviceLink's header PCS field holds its output colour space
(ICC.1:2010 §7.2.7), so a CMYK→CMYK link has a 4→4 AToB0. The LUT
channel check hard-coded 3 outputs and rejected such files as invalid,
so users were told a well-formed profile was corrupt and never reached
the "DeviceLink profiles cannot be used" error.

Check DeviceLink LUTs against the header's output space instead, and
only let a Lab DeviceLink through link_stages when its tables end in
3 channels, so a Lab→CMYK link gets the unsupported error rather than
building a mismatched transform.

Fixes #2337


Claude-Session: https://claude.ai/code/session_01X5k8jSoLtGswanmHBipHER

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:16:32 -04:00
d166450c77 Hue/Saturation: Colorize at 0% saturation gives grey, not a 25% tint (#2343)
Colorize floored saturation at 25% on both the CPU (compose::adjust::hue_saturation) and the
GPU shader, so dragging Saturation to 0 with Colorize ticked still tinted the image. Photoshop
leaves only the lightness at 0%. The floor is gone from both paths, so 1-24% now tint
progressively too.

The floor also stood in for Photoshop's 25% starting saturation when Colorize is switched on.
The dialog's checkbox already sets that; the hueSaturation params now do the same when a call
turns Colorize on without giving a saturation, so {"colorize": true, "hue": 30} still tints.

Fixes #2326.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:16:26 -04:00
MrCloudy2andClaude Opus 5.5 4760f75b4d Blend modes: Fill below 100% works inside the blend for Photoshop's special eight (#2552)
* Blend modes: Fill below 100% works inside the blend for Photoshop's special eight

In Photoshop, Fill and Opacity are the same for most blend modes, but not
for eight: Color Burn, Linear Burn, Color Dodge, Linear Dodge (Add),
Vivid Light, Linear Light, Hard Mix and Difference. PhotoCraft treated
Fill as Opacity for all of them, so a Difference layer at 50% Fill gave
(150,90,110) where Photoshop gives (150,60,10).

Measured on Photoshop 27.11 (scripted, 24 cases at Fill 25/50/75% with
two colour pairs): below 100% Fill, Photoshop pulls the layer's colour
toward the mode's neutral colour (black for Color Dodge, Linear Dodge,
Difference; white for Color Burn, Linear Burn; 50% gray for Vivid and
Linear Light) and blends that at full strength; Hard Mix becomes
clamp((b - f(1 - t)) / (1 - f)). Opacity still mixes the result in.
That reproduces 31 of 32 values within 1 level (Hard Mix at 75%: 3).

CPU (psblend::composite_fill, the final blend of plain pixel, shape and
type layers) and GPU (Kernel::Blend takes Fill in the spare uniform slot)
agree; Dissolve keeps Fill as coverage. Layers with effects, adjustment
layers and groups keep the old behaviour for now.

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

* GPU: composite_fill with a single blend_rgb call, so fs_blend stays small

The first version added a third inlined copy of blend_rgb (every blend
mode) to fs_blend: composite_g's two plus blend_rgb_fill's. Windows CI
(software WARP adapter) then timed out rendering the UI in
arbitrary_rotation_preview, which passes on main; a larger shader
compiles noticeably slower there.

composite_fill now handles every case itself with one blend_rgb call
(Fill as coverage for normal modes, the special-eight blend, Lab Normal
mixing, the text gamma space) and is fs_blend's only composite path,
leaving it with fewer inlined copies than on main. Same results: the 35
GPU parity tests pass.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:16:20 -04:00
43a2cfdf6d Bevel contour: drop the glow-style Range stretch (#2498)
The bevel Contour element mapped the height through contour(min(v / range, 1)),
the glow transfer. At the default 50 % range that flattened a Linear contour to
a bevel half as wide as Photoshop's: on a real 18 px smooth inner bevel at
136 % depth the top-edge highlight was gone 15 px in, where Photoshop's is
still 0.19 at 16 px. The contour alone matches that file (bad pixels 0.38 % ->
0.03 %, max error 63 -> 25 / 255) and lowers the bad pixels of every contour
oracle (bevel-inner-contour-ramp-peak 25.9 -> 21.8 %, -double-ring-aa-r70
25.2 -> 21.0 %, bevel-pillow-chisel-hard-gloss-and-contour 11.4 -> 7.2 %);
the other 23 bevel oracles are unchanged. What Range does instead is open.

CPU (compose) and GPU (fx) share the new bevel_contour_lut.

Co-authored-by: Justin Campos <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:16:14 -04:00
MrCloudy2andClaude Opus 5.5 2c163bf73d Legacy Brightness/Contrast: brightness first, then contrast around 128, as in Photoshop (#2615)
* Legacy Brightness/Contrast: brightness first, then contrast around 128, as in Photoshop

With Use Legacy on, PhotoCraft applied contrast first and then added
the brightness, so any setting that moves both sliders came out wrong:
brightness +40 with contrast +30 mapped mid-grey 128 to 168, where
Photoshop gives 185 (up to 17 levels darker across the midtones).

Measured on Photoshop 27.11 (scripted, a gray ramp through seven
settings): Photoshop adds the brightness, then scales contrast around
128/255. Contrast 80 keeps 128 at 128; the 0.5 pivot moved it to 130.
With both changes every measured value matches within 1 level, on the
CPU and in the GPU adjustment kernel.

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

* Legacy B/C: brightness after contrast when contrast is lowered

The corpus caught it: psd-tools' brightnesscontrast_legacy_grayscale.psd
(brightness -23, contrast -26) matched Photoshop with the old order and
was 6 levels off with brightness first. Photoshop adds the brightness
before contrast when contrast is raised (measured: +40/+30 maps 128 to
185) and after it when contrast is lowered. The measured cases with
lowered contrast had brightness 0, so they could not tell.

CPU and GPU kernel (brightness after contrast goes in p0.w). The io
corpus oracle is back at its floor; the 34 GPU parity tests pass.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:15:36 -04:00
xsvm b077cc3a18 Extended filters: an unknown option is a bad-params error (#2228 follow-up) (#2548) 2026-10-10 13:15:30 -04:00
David MannandClaude Opus 5.5 c1eac9969b Render: Tree and Picture Frame accept whole numbers sent as floats (#2501) (#2521)
Filter > Render > Tree... and Picture Frame... failed from their dialogs
with "invalid type: floating point `1.0`, expected u32": the schema
dialog sends every range value as a JSON float, and TreeSpec/FrameSpec
deserialize baseTreeType, lightDirection, frame, lines and seed as u32
through serde. The render commands now round finite floats for those
integer fields (and Flame's seed) before deserializing, like the
engine's `int` helper does for other commands; negative or too-large
values are still refused as invalid parameters.

Tests: a render_cmds regression test (whole floats draw what integers
draw, fractions round, hostile values fail) and a filter_dialog sweep
that opens every schema dialog with its defaults and runs OK and the
preview, failing on any float type error. Only Tree and Picture Frame
failed it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 13:15:24 -04:00
ericandhideoneric 5b051861bc fix(blend): keep 32-bit Hue/Saturation/Color/Luminosity above 1 (#1735)
- Hue, Saturation, Color and Luminosity no longer clip 32-bit documents to 0..1, so values above 1 survive in the CPU compositor (blend_rgb_hdr now uses blend_rgb_within at f32::MAX) and in the GPU shader (under the existing F_HDR flag). 8/16-bit results are unchanged apart from rounding at white.
- ClipColor returns the grey when the luminosity is outside the in-gamut range, instead of dividing by max − lum rounding noise. The old formula turned colours inside out (Luminosity of a grey of 2 over (0.5, 0.25, 0.125) gave (1.0, 2.32, 2.99)), and CPU and GPU disagreed (Hue over a grey of 2: CPU 1.0, GPU 2.82).
- Tests: 8/16-bit inputs match the ISO 32000 formula, HDR cases in compose, psblend::with_hdr nesting, and a CPU/GPU parity test across depths.

Co-authored-by: hideoneric <[email protected]>
2026-10-10 13:15:18 -04:00
B?! d1a062e044 AF.3: recognize current Affinity artboard properties (#2355) 2026-10-10 13:14:59 -04:00
Fabian GhenofandClaude Opus 5.5 141c80d3e8 Open file pickers for Replace Contents, Export Contents and Convert to Linked (#2438)
From the menu these Smart Object commands failed with "pass `path`": no file
dialog was wired to them. Replace Contents now asks for a file (by contents,
so it works on the web too) and updates every instance; Export Contents and
Convert to Linked ask where to save, suggesting the contents' own file name.

The engine gains replace_contents_bytes and contents_of for the UI to call.
Relink to File is left to #2416, which adds its picker.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:14:52 -04:00
Jason 3bc6860e72 Save and export dialogs remember where each document was last saved (#1826) (#2421)
`pick_save` now records the folder of every path the user chooses in a save
or export dialog, per document, and starts that document's next bare-name
dialog (exports, an untitled Save As) there. Precedence: a caller's explicit
directory, then the remembered folder, then beside the document (#1866),
then the shell's default. Save As of a saved document still suggests its own
path. The memory is session-only, keyed by DocId, pruned of closed documents
at the next dialog, and not fed by the web's download-name answers, which
carry no folder.
2026-10-10 13:14:46 -04:00
Harrison O'Connor-Hoover 7cd42907e5 Reject colliding data-set export filenames before writing (#2433) 2026-10-10 13:14:39 -04:00
2d6154a69b Keep Duotone ink colours when converting to RGB, CMYK or Lab (#2383) (#2614)
convert_document treated a Duotone document as plain gray: it converted
the stored gray pixels through the gray profile and dropped the inks, so
the result was neutral gray instead of the inked look the canvas shows.
Fill, text and effect colours stored in Duotone mode were not converted
at all.

Before converting to a colour space, a Duotone document is now inked
into sRGB (Duotone::render, the same model the display uses, with the
inks' sRGB colours), then converted through photocraft-cms to the
destination profile like any RGB document. Grayscale still keeps the
underlying gray. Works per layer at every depth; undo restores the
Duotone document.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:13:52 -04:00
MrCloudy2andClaude Opus 5.5 76e3cbd010 TGA: a 32-bit Targa opens with its alpha as "Alpha 1", as in Photoshop (#2225) (#2482)
Opening a 32-bit Targa turned its alpha into layer transparency: the
colours of transparent pixels were hidden, and since #2157 only an alpha
channel becomes a Targa's alpha on save, a TGA opened and saved again
lost its alpha.

Measured on Photoshop 27.11 (scripted, files built byte by byte): a
32-bit Targa opens as an opaque Background plus an "Alpha 1" channel
holding the fourth byte, also when the descriptor declares 0 alpha bits;
the stored colours stay; a 24-bit Targa has no channel. PhotoCraft now
opens Targas the same way, so they round-trip with #2157's export.

Image sequences (video layers) still treat a TGA frame's alpha as
transparency, as footage does: load_frames moves the channel back.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:13:46 -04:00
F4LCONandBrandon Thomas ae03e7cd66 OpenEXR/HDR export: linearize grayscale documents too (#2386) (#2448)
* OpenEXR/HDR export: linearize grayscale documents too

Fixes #2386. The EXR/HDR branch of export_flat converted RGB images to
linear sRGB but left gray ones as stored, so an sGray #808080 was written
as 0.502 instead of 0.216. Gray images are now converted from their
profile (sGray when untagged) to linear gray. An untagged grayscale
OpenEXR opens tagged linear gray (an EXR Y channel is linear luminance),
and the open policy keeps that profile without a mismatch prompt, as it
already does for linear sRGB.

* contributors: add itsF4LCON

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 13:13:40 -04:00
f8efd089ad Keep the layered original when saving a flat copy (#2550, #2579) (#2611)
Save As to a flat format always made the written file the document's
file: a layered or PSD-backed document saved as JPEG was renamed to the
JPEG and marked saved, so Ctrl+S then opened Save As with JPEG chosen,
and closing no longer asked about the unsaved layers. app.save on the
desktop did the same for any format, unlike the headless server (#1547).

Save As now writes a copy when the flat file can't hold the document
(anything beyond one plain raster layer, or the document already lives in a layered
PSD/PSB/.pcraft/OpenRaster/TIFF file), as Photoshop does. A flat
document not in a layered file still becomes the flat file. app.save
only retargets for layered formats, through a helper shared with the
headless server (file_cmds::layered_extension).

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:12:41 -04:00
d4b9f06891 Quit and Close All: answering No closes that document (#2547) (#2610)
* Close each document as it is answered when closing several (#2547)

Quit, Close All and Close Others asked about each unsaved document but
only removed it from the prompt's list; nothing closed until every
document was answered. After "No" the answered tab stayed open (and
often active), so it looked like the same file was being asked about
again.

Now each answered document closes right away (after its save, for Yes)
and the next one asked about becomes the active tab, as Photoshop does.
The parked command still runs at the end: quitting is allowed and Close
All closes the clean rest even if its original target already closed.
Cancel stops there and keeps the unanswered documents open. Single-file
Close and Revert are unchanged.

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

* Show the first document asked about when closing several (#2547)

The first question of a Quit, Close All or Close Others prompt now
switches to the tab it asks about too, so every question shows its own
document. Before, the tab active when the command ran stayed up for the
first question, often a different file with the same name.

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:12:35 -04:00
MarekSpodBiedry 93629daedc Float the layer mask, not the layer, when the mask is targeted (#2468) (#2486) 2026-10-10 13:12:20 -04:00
Nigel Ihza Firdausy 24fbf4a84f fix(document): restore layer comps when duplicating documents (#2429)
* fix(document): keep layer comps valid across document duplication

Remap saved comp and Last Document State references when cloned layers receive new IDs, including historical states and nested groups. Drop obsolete comp metadata in merged-only copies instead of carrying references to removed layers.

Add focused engine regressions for #2382 and related copy workflows.

* style(test): apply rustfmt layout to layer-comp regression tests
2026-10-10 13:12:15 -04:00
MarekSpodBiedry 10571be5e2 Let Edit > Fill and the fill keys fill an adjustment layer's mask (#2456) (#2471) 2026-10-10 13:12:09 -04:00
Kaustubh TripathiandClaude Opus 5.5 099aa57db5 fix(vector): Fill Path stays inside the active selection (#2387) (#2410)
Fill Path painted the whole work path even when a selection excluded
part of it. The path's coverage (after anti-aliasing and feather) is now
multiplied by the selection mask, so rectangular, elliptical and
feathered selections all limit the fill. With no selection the whole
path fills, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:12:04 -04:00
e667483820 fix(codecs): exports drop the source's EXIF thumbnail (#2147, #2373) (#2450)
Exports copied the source EXIF with only Orientation and resolution
patched, so a camera JPEG's embedded thumbnail (IFD1) came along. Finder,
Android and upload pickers show that thumbnail instead of decoding the
image, so an edited photo kept its original preview.

export_exif now clears IFD0's next-IFD pointer, unlinking IFD1. Nothing
else moves, so every other offset stays valid; JPEG, PNG, WebP and the PSD
EXIF resource all go through it.

Co-authored-by: vivekprojects-GIT <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:11:59 -04:00
Rene Leonhardt 5290930e4a fix: Preserve CMYK solid and gradient fills through PSD round trips (#2628)
* fix(compose): Preserve native CMYK fills through PSD round trips

Share depth-quantized ink samples between rendering and PSD caches.
Use the document profile for RGB fill colours and preserve valid imported
caches. Uncached CMYK fills use CPU rendering.

Cover all sample depths, gradient styles and tiled renders. Fix the real
16-bit gradient round trip and raise the psd-tools floor from 307 to 308.

* perf(raster): Reuse exact 8-bit CMYK conversions within rows

Cache the previous ink bytes and ICC result per row, with alpha decoded
independently. Keep the 16/32-bit conversion loops unchanged.

Matched 1 MP reads improve by 77% for smooth ramps, 33% with dithering
and 10% for random colours. Bitwise regression coverage includes all
depths, alpha and tile boundaries.
2026-10-10 13:11:54 -04:00
Kaustubh TripathiandClaude Opus 5.5 d9a9ce5f4d fix(vector): Stroke Path refuses pixel-locked and fully locked layers (#2388) (#2408)
Stroke Path only honoured the transparency lock, so Brush, Pencil and
Eraser painted on a layer whose pixels (or everything) were locked,
directly or through a parent group. It now rejects those destinations
with the same error as Fill Path and ordinary painting, leaving the
layer unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:11:51 -04:00
MarekSpodBiedry f96c0d1f13 Remove tool: an empty layer is a no-op instead of running the fill (#2435) (#2447)
* Remove tool: refuse an empty layer instead of running the fill (#2435)

* Remove tool: an empty layer is a no-op, as the other retouch tools (#2435)
2026-10-10 13:11:46 -04:00
8fd4aa4cc2 fix(psd): an unedited save writes Photoshop's Txt2 back unchanged (#2374) (#2427)
* fix(psd): an unedited save writes Photoshop's Txt2 back unchanged (#2374)

Since #1517 every PSD save with type layers rebuilt the document's Txt2
block: it replaced everything under /1 with the text-object list, emptied
objects no layer names, and cut each kept object's style runs down to the
auto-kern mode alone. Photoshop then refused to open an unedited re-save
("Could not complete your request because of a program error").

build_txt2 now starts from the file's own block: the other /1 keys and
unnamed objects are kept, a kept object's style runs keep every style key
and only get /11 set (split where a mode changes inside a run), and a block
whose content comes out unchanged is returned byte for byte.

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

* test(psd): Photoshop type files keep Txt2 byte-for-byte on an unedited save (#2374)

Corpus test over corpus/photoshop/text: import, export, re-import, and
compare the Txt2 global block. All 56 Photoshop-authored type files pass;
the previous build_txt2 fails on the first.

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

---------

Co-authored-by: vivekprojects-GIT <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:11:42 -04:00
Kaustubh TripathiandClaude Opus 5.5 1a49942b63 fix(engine): list running jobs before ended ones in jobs.list (#2395) (#2411)
Session::jobs_with_recent is documented as "running jobs followed by the
last few that ended", but chained the ended jobs first, so jobs.list and
MCP jobs_list returned a finished job ahead of a running one. Running
jobs now come first, as documented.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:11:40 -04:00
MarekSpodBiedry 99570a7538 Layer commands accept an explicit layer with none selected; Deselect Layers is a no-op when nothing is selected (#2457) (#2481) 2026-10-10 13:11:35 -04:00
MarekSpodBiedry e80fe372b0 Keep surrounding spaces when renaming a layer (#2459) (#2472)
* Keep surrounding spaces when renaming a layer (#2459)

* rustfmt: keep rename_layer's name on one line (#2459)
2026-10-10 13:11:29 -04:00
MrCloudy2andClaude Opus 5.5 878c3eaf90 Select › Modify › Border: Photoshop's soft band, 2r wide, not a hard r-pixel ring (#2564)
Border built a hard band r pixels wide (expand r/2 minus contract r/2).
Photoshop's Border is soft and twice as wide: measured on Photoshop
27.11 (scripted), Border 4 on a 32 px square gives 63, 127, 191, 255 |
255, 191, 127, 63 across the edge, and the same along the inside of a
corner; PhotoCraft gave 0, 0, 255, 255 | 255, 255, 0, 0.

A pixel D away (centre to centre) from the nearest pixel on the other
side of the edge is now selected 1 - (D - 1) / r, using the existing
exact distance transform. That matches every sampled pixel within 1.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:09:21 -04:00
MrCloudy2andClaude Opus 5.5 96ee4f911b Select › Modify › Smooth: a hard threshold, as in Photoshop, not soft edges (#2557)
Smooth box-averaged the selection and then applied a contrast curve
((v - 1/2) * 4 + 1/2). That left a band of half-selected pixels along
every edge: a straight edge of a 32 px square came out 158 instead of
255 after Smooth 8, and a fill showed a soft grey border.

Measured on Photoshop 27.11 (scripted): Smooth 8 on the same square
gives only 0 and 255; straight edges stay exactly in place and the
corner is cut where the 17x17 box average crosses 1/2. Smooth now
thresholds the box average at 1/2, which reproduces every sampled
pixel.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:09:17 -04:00
MrCloudy2andClaude Opus 5.5 81ed59b0e0 Selection feather: the radius is the Gaussian's sigma, as in Photoshop (#2529)
Select › Modify › Feather (and the marquee, lasso and path feather
options) blurred with sigma = radius / 2, so a feather came out half as
wide as Photoshop's. Measured on Photoshop 27.11 (scripted): a 32×32
rectangle feathered 4 px matches sigma = radius within 2 levels along
the edge; sigma = radius / 2 was up to 71 levels off. The mask feather
(Properties › Feather) already uses sigma = feather, fitted on the
psd-tools corpus, so both feathers now agree.

The two margins sized for the old reach grow with it: Modify's
canvas-bounds padding (3 sigma = 3 r) and Fill Path's render area.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:09:13 -04:00
Harrison O'Connor-Hoover e11ffc0ae8 Preserve the Zoom drag anchor in rotated views (#2583) 2026-10-10 13:09:08 -04:00
Valentin KleinandClaude Opus 5.5 5bb768b8f1 Spot Healing: the drag trail shows the dabs the heal covers (#2364) (#2642)
The Spot Healing Brush's drag preview stamped a hard round footprint at the
base brush size, so a soft brush and pressure/shape dynamics did not show,
while the heal itself uses the brush engine's dab coverage. The trail now
renders the same coverage (`stroke_coverage`'s StrokeRenderer, brush built by
the new shared `retouch_cmds::stroke_brush`), including the press's lone
first dab via `tail_preview`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:09:05 -04:00
Valentin KleinandClaude Opus 5.5 0426b1bb09 Canvas: right-click outside the image opens the pasteboard colour menu (#2462) (#2633)
Right-clicking the pasteboard (outside the document bounds) now opens
Photoshop's pasteboard menu: Default, Black, Dark Gray, Medium Gray,
Light Gray, Custom and Select Custom Color…. The rows set the existing
Preferences › Interface canvas colour through `prefs.set`; Select Custom
Color… opens the Color Picker and OK stores and uses the custom colour.

Painting tools keep their Brush Preset picker / right-button erase, the
Pen keeps its path menu, text editing and Free Transform keep theirs;
the Move tool falls back to the pasteboard menu where no layer is hit.
The control channel's `ui.pointer` secondary click opens it the same
way, `ui.context.choose` picks rows (authorised as `prefs.set`).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:09:01 -04:00
Valentin KleinandClaude Opus 5.5 28edd59339 Zoom tool: double-click zooms to 100% (#2508) (#2527)
* Zoom tool: double-click zooms to 100% (#2508)

Double-clicking the Zoom tool in the toolbar now selects it and runs
view.actualPixels, as in Photoshop, next to the existing Hand tool
double-click (fit on screen). With no document open it only picks the tool.

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

* contributors: add vangel76 (sync GitHub name)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:08:57 -04:00
Valentin KleinandClaude Opus 5.5 0c3c6cdd8d Tools: a tool key brings back its group's last-used tool (#2608) (#2625)
Pressing a tool shortcut from another group always picked the group's
first tool (L gave the Lasso even after the Polygonal Lasso was used).
The UI state now remembers the last tool used in each shortcut group,
and the key picks it, as in Photoshop. Applies to every group.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:08:53 -04:00
Valentin KleinandClaude Opus 5.5 fbb1316831 Move tool: Auto-Select picks a type layer inside its text bounds (#2370) (#2591)
layer.pickAt hit-tested every layer by the alpha of the pixel under the
pointer, so a click in the gap between a type layer's letters picked the
layer under it. Like Photoshop, Auto-Select (and Cmd-click) now hits a
type layer anywhere inside the bounds of its rendered text. The
right-click layer list still lists only layers with pixels there.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:08:49 -04:00
David MannandClaude Opus 5.5 e7576cd997 Move tool: Ctrl/Cmd+arrow nudges the layer from any tool (#2474) (#2556)
In Photoshop, holding Ctrl (Cmd on macOS) is the temporary Move tool for
most tools, so Ctrl/Cmd+arrow nudges the selected layer 1 px, with Shift
10 px, and with Alt duplicates first. PhotoCraft only nudged with the Move
tool itself, and egui's consume_key never ignores an extra Ctrl/Cmd, so
Ctrl/Cmd+arrow did nothing anywhere.

- hold_keys::cmd_nudges: Ctrl/Cmd+arrow nudges wherever Ctrl/Cmd is the
  temporary Move tool, with the Move tool, and with the selection tools
  (moves the selected pixels). Not during Free Transform, text editing, or
  an in-progress polygon or Magnetic Lasso border.
- move_mods::arrow_keys adds Command to its patterns in that case.
- nudge decides on floating the selected pixels as the Move tool.
- Direct Selection keeps Ctrl/Cmd for itself, like Path Selection.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 13:08:45 -04:00
Valentin KleinandClaude Opus 5.5 87068253c6 Move tool: outline follows the moved layer (#2413) (#2565)
View > Show > Layer Edges was painted before the frame's tool events
were read, from the session document. Two bugs came from that:

- The frame in which Auto-Select picks a layer on the press still
  outlined the previously selected layer, because the pick ran after
  the outline had been drawn.
- During a live Move drag the moving pixels come from the preview
  document at the drag offset, but the outline used the committed,
  unmoved bounds. It stayed where the layer started until the release,
  so it trailed behind and was visibly off on fast drags.

The canvas now reserves the outline's z-slot where it was drawn and
fills it after the tool events, so it outlines the newly picked layer.
The rect is offset by move_ui::shown_offset, which is the offset the
image was drawn with this frame. That keeps the outline in lockstep
with the pixels. The moving set (the move targets plus their
descendants) is computed once per preview revision, so there is no
per-frame scan.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:08:40 -04:00
Valentin KleinandClaude Opus 5.5 4d7e5c1ef0 Marquee: keep one-pixel rows and columns on release (#2391) (#2562)
The live preview (ants and W/H readout) showed the whole-pixel rectangle
from marquee_px, but finish_gesture deselected any rectangle narrower or
shorter than 2 px. That threshold stood in for "was this a click?" because
floor/ceil turns a click between pixel edges into a 1x1 rectangle, so a
deliberate one-pixel column or row, and a thin drag replacing an existing
selection, ended with no selection at all.

Release now commits exactly marquee_preview_px, which decides a click by
screen distance (shape_dialog::is_click at the view zoom) and drops only
zero-area rectangles; the preview draws no ants when the release would
deselect. A plain click still deselects, as in Photoshop.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:08:36 -04:00
Harrison O'Connor-Hoover ac90ca2b1a Capture marquee selection modifiers at mouse press (#2558) 2026-10-10 13:08:31 -04:00
Harrison O'Connor-Hoover f55bc2d697 Fix snapping when drawing a new crop inside the default frame (#2549) 2026-10-10 13:08:27 -04:00
Nigel Ihza Firdausy 13d89378d8 fix(properties): expose equal-gap layer distribution (#2349) (#2398) 2026-10-10 13:08:22 -04:00
41d54036fc fix(selection): constrain Polygonal Lasso segments with Shift (#2352) (#2396)
* fix(selection): constrain Polygonal Lasso segments with Shift (#2352)

* Polygonal Lasso: make the Shift constraint helper visible to its tests; cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:08:18 -04:00
MarekSpodBiedry 8c2e16fb4a GPU canvas: re-upload a layer or mask texture when its default pixel changes (#2422) (#2446) 2026-10-10 13:08:14 -04:00
MarekSpodBiedry 0158fa82e1 Clip warp tiles at the inverse horizon so steep Distort commits keep every pixel (#2271) (#2500) 2026-10-10 13:08:10 -04:00
Max Murphy 8587c55c78 Free Transform: the Hand tool no longer steals a resize drag (#2302) (#2327)
* Free Transform: the Hand tool no longer steals a resize drag (#2302)

* Free Transform + Hand: only the selected Hand yields, and only in the primary canvas (#2302)

* cargo fmt
2026-10-10 13:08:06 -04:00
Max Murphy 3e000bc1d5 Canvas: UI Scale must not magnify the image (#2121) (#2333)
Interface > UI Scale is egui's zoom_factor, folded into pixels_per_point
with the display scale. The canvas divides View::zoom by the full
pixels_per_point (PhotocraftApp::point_zoom) and the GPU shader
multiplies it back, so 100% is one document pixel per physical display
pixel at every UI scale -- fixed by #1978 (Canvas: zoom is physical at
any display scale), which the report predates (v0.5.0). No behaviour
change; the report is stale.

Lock it down with a rendered-output regression test
(tests/ui_scale_canvas.rs): a black document is drawn through the real
app on the GPU and CPU canvas at UI Scale 100/125/150/200% and zoom
100/200%, and its on-screen size is measured in the pixels. It stays
60x60/120x120 physical px at every UI scale, and fails when the canvas
stops using point_zoom (75x75 at 125%) or when the GPU shader stops
multiplying by pixels_per_point (48x48 at 125%). The comments on
View::zoom, PhotocraftApp::ppp and canvas_view now name UI Scale too.
2026-10-10 12:22:49 -04:00
Brandon ThomasandClaude Opus 5.5 2b79c7c25d automation: test that root look-alike siblings and sub/../.. climbs are refused (#2216 follow-up) (#2528)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 11:53:35 -04:00
7722172585 Add OpenRaster (.ora) import and export (#2257)
Open and save .ora with pixel layers (any offset), nested groups, pass-through
groups, names, visibility, opacity, edit/alpha locks and blend modes (svg:* plus
Krita's krita:* names), at 8 and 16 bit. Save applies pixel masks, renders
text/shape/fill/smart layers to pixels and reports what OpenRaster can't hold.
Wired into File > Open/Save, the CLI, automation/MCP, the web picker and the
desktop file associations. Checked against 34 Krita 5.2.9-authored files
(opt-in krita_corpus test) and Krita re-opening our exports.

Co-authored-by: Scott Campbell <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 08:40:35 -04:00
texjerandBrandon Thomas 05e0b1f8a5 kys import: first launch asks before importing; edit.keyboardShortcuts importKys (#1987 review) (#2251)
- The live set found beside Photoshop is offered, never applied unasked: an
  "Import keyboard shortcuts?" prompt names the set and how many keys it
  changes, with Import / Don't Import. Either answer is recorded in
  dialogs.photoshopShortcuts, so it is asked once; ui.inspect shows the
  pending offer as kysOffer.
- Parsing and label matching moved to the engine (photocraft_engine::kys), so
  edit.keyboardShortcuts {"importKys": <xml>} imports a set from agents, the
  CLI and MCP; the app's dialog uses the same plan with its menus' labels.
- "The keyboard shortcut set was not imported: {error}" is translated.
- .kys input over 4 MiB is refused before it is read as text.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 07:12:24 -04:00
4e8aaec643 Addition : Brush popup better UI (#2217)
* Brush name/tip/stroke cards

+ locale

* brush popup scale ui

scale brushes

* align fix + tests

* test fix

* ci fix

* ci fix 2

* don't scale text

* smaller width for tip+name

* scale + spacing + better UX

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 07:04:52 -04:00
AyanandBrandon Thomas d99562f038 Compute exact octagonal mask shifts with bounded workspace (#2154)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 06:46:06 -04:00
ae96037672 Expose native shape stroke controls with staged previews (#2218)
* Expose native shape stroke controls with staged previews

* Document shape stroke defaults in MCP control schema

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 06:42:52 -04:00
291f30da55 EXR: Cryptomatte layers list, decode and hash to the specification (#2180)
* EXR: Cryptomatte layers list, decode and hash to the specification

Cryptomatte (specification 1.2) carries per-pixel object and material
IDs with coverage in <layer>NN.red/.green channel pairs, announced by
cryptomatte/<key>/name|hash|conversion|manifest header attributes - the
ID channels Arnold, V-Ray, Redshift, Mantra/Karma and Cycles write next
to the beauty pass.

cryptomatte_layers lists every layer from the headers alone (name, key,
channels, the embedded manifest parsed and capped at 16 MiB, a sidecar
pointer); decode_cryptomatte returns the per-pixel (ID, coverage) pairs
above zero, sorted by descending coverage; cryptomatte_id hashes names
the reference way (MurmurHash3 x86-32, the exponent fixed away from
denormals/inf/NaN, read as f32 bits) - verified against the official
test vectors - and cryptomatte_key/cryptomatte_preview_color expose the
metadata key and preview colour. Files with deep parts are refused, like
the other flat part decoders.

* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)

* codecs: Cryptomatte decode checks the layer size without truncating casts

Count each pixel's pair list in the allocation check too.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 06:40:49 -04:00
Sir StarchandBrandon Thomas a737281fad Distance fields on threads: bevel size changes 2-3x cheaper (#2119) (#2162)
* Distance fields: the Euclidean passes run on threads, results unchanged (#2119)

The chiselled bevel fields (#2006) and the precise glow fields are built
from an exact Euclidean distance transform, a few sweeps over the whole
map that all ran on one thread. They now run on scoped OS threads, in
groups of rows or columns handed out from a queue: edt_nearest (the
column pass, then the row pass), sharp_edges (rows), refine_edge_normals
(one edge pixel each) and the final per-pixel maps of the bevel and plain
Euclidean fields. Not rayon, as for the blurs (rows_par): maps are built
from inside rayon tiles, where a waiting worker steals other tiles (#276).
Maps under 65536 pixels stay on the calling thread, wasm always does.

Every unit is computed from its own inputs, in the same arithmetic as
before, so the fields are bit for bit the old ones whatever the thread
count: 216 fingerprints (bevel in and out, plain in and out, bevel_maps on
bars at 0 to 150 degrees, thin bars, discs, rounded rectangles, soft,
patchy and half-opaque masks, 40 stars, tiny and 1 pixel maps) are equal
at 1, 7, 16 and the default threads, and equal to the code before.
edt_nearest returns the nearest pixel as u32 (it was usize), which takes its
working memory from 20 to 16 bytes per pixel; a map past 2^32 pixels or a slice that does
not match its size gets "far" distances instead of a panic.

CPU compositor, 12 MP, bevel size change (ms, bevel_bench), main -> this:
ellipse 474 -> 217 (size 8), 990 -> 318 (250); text 747 -> 214 (8),
1110 -> 267 (250). A distance_field call on a 256x256 map goes from 2.2 to
1.8 ms, on 512x512 from 8.0 to 2.8, on 2048x2048 from 99 to 15.

Tests: par_units hands every unit to the closure once with its own
slices (short last unit, no second slice, units of different sizes, 1 to
64 threads); the nearest-pixel transform equals itself on 1, 2, 3 and 8
threads on awkward sizes and returns a seed at the distance it reports;
degenerate sizes; sharp edges and the four Euclidean fields are bit-equal
on 1, 2, 5 and 16 threads on discs, rounded rectangles, bars at several
angles, patchy opacity and an edge on the border. The nested-rayon
deadlock test (#276) now has a chiselled bevel layer in its document.

* GPU effect maps: the Euclidean fields are one window, not bands (#2119)

A bevel size change rebuilds the distance fields over the whole effect
region (the region grows with the size). The GPU path cut that into bands
of max(128, halo) rows, each computed from a window grown by the halo,
which for the bevel fields is reach + 10: at size 250, 262-row bands from
windows of about 786 rows. Every pixel went through the transform and the
edge refinement about three times, a 12 MP region gave about 12 tasks for
32 threads, and the two bevel fields ran one after the other.

The Euclidean fields (chiselled bevels, precise glows) now come from one
window, and distance_field spreads its own passes over the threads. A
rebuild is the whole region, which is also what the CPU compositor
computes, so the GPU canvas gets exactly the CPU's field: bands resolved
ties between equidistant edge pixels per window, and on a rebuild they
differed from the whole region by up to 0.07 px (ellipse) and 1.3 px (600
discs) at size 250, on a few dozen pixels in 12 MP. A partial update (a
brush dab) still computes the damage grown by the halo, from one window.
The chamfer fields (shadows, strokes, spread) are a sequential sweep and
keep the bands, and so does wasm, where there are no threads to spread
over and bands keep the memory small.

RTX 4080 SUPER, release, 12 MP, ms per size change on the GPU canvas
(bevel_bench from #2118, p50, median of 3 runs, before #2006 / main /
this), ellipse: 90 / 103 / 98 (8), 162 / 188 / 119 (50), 286 / 446 / 219
(250); text: 83 / 139 / 113, 140 / 215 / 130, 222 / 489 / 165. Alone in the
process (--no-cpu) ellipse at 250 is 407 -> 157 and text 468 -> 169.
36 MP ellipse at 250: 779 -> 382. Peak RSS: 12 MP at 250 1604 -> 862 MiB,
36 MP at 250 2987 -> 1726 MiB; at size 8 on 36 MP it is 163 MiB more
(1380 -> 1543). Precise glow fields on 12 MP: 46-84 ms -> 26 ms at reach
52-252 (32 ms at 12).

The window test now checks that the whole region is bit for bit the CPU's
field, and that a too small halo is noticed on a part of the region.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 06:38:33 -04:00
Harrison O'Connor-Hoover ec03c602b4 Apply Type formatting to selected text layers (#2266)
* Apply Type formatting to selected text layers

* Name the multi-layer formatting test case type
2026-10-10 06:09:54 -04:00
401abc48a9 Content-Aware tools: Photoshop's behaviour, measured (#2202)
* Content-Aware tools: Photoshop's behaviour, measured

Photoshop 25.4 was measured as a black box (synthetic and public-domain
images, results read back exactly) and the content-aware family follows it:

- Fill (inpaint): coarse to fine with the coarser level's matches scaled up
  and voted with the finer level's own pixels, the coarsest level filled
  from the hole's edge inwards, a shallower pyramid. The fill is pieces of
  the image copied pixel for pixel with narrow seams, as Photoshop's;
  before, it was visibly blurred. Pixels have roles (hole, context,
  source, void), so excluded areas stay context instead of being filled.
- Colour adaptation: Image Melding's per-patch gain and bias, limited per
  level, instead of a low-frequency membrane pull.
- Sampling window: Photoshop's square of side 4·√(max(w,50)·max(h,50))
  centred on the selection, slid inside the canvas; Edit › Fill,
  Rectangular, Auto, Delete and Fill and Spot Healing use it.
- Auto sampling area: what looks like the selection's surroundings (local
  colour and contrast, one min cut), fitted to Photoshop's overlay.
- Patch Tool: a Content-Aware mode (Structure, Color, Sample All Layers).
- Content-Aware Move: Structure 3..7 alike, a band about a patch wide;
  Color as a gain/bias limited to about 0.04 per step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Remove tool: keep its own sampling margin (fill_cmds::sampling_margin is gone)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 06:08:47 -04:00
Harrison O'Connor-Hoover e7df11d770 Load Thai fallback fonts for native UI text (#2280) 2026-10-10 06:06:39 -04:00
37985c0c61 Filters: preview at the scale OK applies (#2063) (#2207)
The filter dialog previewed large documents on a 1/k proxy and divided
pixel-sized params by k. Filters whose features have a fixed size in
pixels (Ripple, Diffuse, Oil Paint, Flame, Fibers, Mezzotint) came out
k times larger in the preview than in the result, and params divided
below their minimum were clamped (Pointillize 5 px cells -> 12 px).

filter_dialog::preview_factor picks the proxy factor per command: 1 for
the fixed-size filters, otherwise the largest factor up to k at which no
pixel-sized param falls below its range minimum.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 06:05:15 -04:00
Books 7198a94164 automation: accept absolute paths beneath the automation roots (#2176) (#2216)
doc_open, doc_save and the other automation file paths only took
forward-slash relative paths, so an agent passing C:/Users/me/a.png with
a C:/ root was refused. An absolute path is now compared with the
root's absolute path as text: if it lies beneath the root, the root's
components are stripped and the rest goes through the same checks as a
relative path and is opened through the same directory capability.
Nothing is resolved on the filesystem, so traversal, prefixes, device
names and link escapes are refused as before; absolute paths outside
the root, or with no root granted, are still refused.

Fixes #2176.
2026-10-10 05:53:23 -04:00
Sean LaveryandBrandon Thomas 478acf70cc Expand arithmetic expressions across numeric inputs (#2252)
* Expand arithmetic expressions across numeric inputs

* Test arithmetic and scientific notation in indexed color counts

* Drop the numeric_fields example (it wrote PNGs to /tmp; the widget tests cover the behaviour)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 05:50:58 -04:00
kubuntugamerandBrandon Thomas 4c06b9fe4d fix(engine): adjustments on a smart object record smart filters (FILE-215-4); strokes honour doc CMYK (#2229)
On a smart-object layer, Image > Adjustments now records an editable
smart filter instead of committing disaster.

  - new engine helpers:
    - adjust_cmds::has_adjustable / adjust_as_smart_filter route
      image.adjustments.<kind> onto the source, with the selection as the
      filter mask, through smart_cmds::add_smart_filter
    - brush/eraser/pencil/live preview strokes now render inside
      with_cmyk_space(doc_cmyk_space(session)), so painted colours follow the
      document profile the same way the compositor does
  - docs/scorecard.md: FILE-215-4 partial -> done
  - tests: adjust_cmds/tests + brush_cmds/tests, plus panic_hunt and
    the lib suite all green on the committed tree

Known follow-up, already filed:
  - FILE-215-5 rows: double-clicking an adjustment smart-filter row is still
    a no-op. smart_ui::open_editor only proceeds when
    filter_dialog::has_dialog returns true, and has_dialog accepts
    filter.*, a fixed non-adjustment set and PREVIEWED - but not
    image.adjustments.* (those open through adjust_dialog::open, which isn't
    wired into the smart-filter row).

This is one commit on top of the latest upstream/main (rebased to fix the
stale fork ancestry that was blocking PR creation).

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 05:49:07 -04:00
Brandon ThomasandClaude Opus 5.5 29d5f791d5 Regenerate docs/scorecard.md (TOOL-213-19 from #2193) (#2404)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 05:20:57 -04:00
JohnnYoruandBrandon Thomas 897f9f37ad Add the Remove tool with non-local patch completion (#2193)
* Add the Remove tool with non-local patch completion

Tool::Remove (J flyout) paints a hole that paint.remove fills from its
surroundings as a background job, in one undo step. A stroke around an
object also removes what it encloses; the selection limits the hole.

The fill (photocraft_algo::nonlocal) follows Newson, Almansa, Gousseau and
Pérez (IPOL 2017): texture features in the patch distance, onion-peel
initialisation, match fields upsampled between levels and a best-patch
final copy, plus gradient-domain hiding of edge and interior seams.
Sparse holes are filled in tiles. inpaint::complete (Content-Aware Fill,
Spot Healing, Content-Aware Move) is unchanged.

On 20 holes in public-domain photos (remove_quality) the fill keeps 0.89
of the texture it replaces, against 0.54 with the Wexler/PatchMatch
completion. 24 MP (remove_bench): 100 px ring 120 ms, 1000 px ring 2.5 s.

* Translate the Remove tool's label and tighten comments

Adds "Remove Tool" to every UI catalog, following each one's pattern for
tool names and its existing word for Remove. Corrects comments that no
longer matched the code: the benchmark's description, the tile and margin
minimums, and the reasons given for two parameters.

* Put the Remove tool first in the J group

The J flyout's slot now shows Remove until another J tool is picked, and
J from another group selects it; repeated J goes on to Spot Healing Brush,
Healing Brush, Patch, Content-Aware Move and Red Eye.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 05:19:11 -04:00
414b7b9e4b fix(ui): complete system appearance selection and styling (#2168)
* feat(ui): follow system appearance with a System theme

Follow the OS appearance through the existing egui input while retaining System as the saved theme choice. Apply the resolved palette to both egui styles and custom surfaces, and keep document surround settings independent.

Cover launch and live transitions, persistence, manual themes, menu checks, the Interface picker and Home contrast. Validate engine/UI suites, adversarial commands, wasm, layering, parity and the AutoReview gate. Strict Clippy reports pre-existing findings documented in the PR.

Refs: storytold/photocraft#2074

* Keep the document surround on the displayed theme in Sync with system (drop the dark-surround pinning)

---------

Co-authored-by: coygeek <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 05:14:41 -04:00
Harrison O'Connor-HooverandBrandon Thomas 607a8542f5 Keep duplicated Smart Objects linked to shared contents (#2222)
* Keep duplicated Smart Objects linked to shared contents

* Share Smart Object source update code

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 05:05:56 -04:00
Jason XiangandBrandon Thomas e05deac124 fix(engine): reject invalid core-filter parameters (#2228)
* fix(engine): reject invalid core-filter parameters

* test: cover filter validation through CLI and automation

* fix(ui): preserve filter preview minima during downsampling

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 04:57:56 -04:00
b203e7c295 Window edges: easier to grab, theme-safe cursors, and no lost hover after a resize (#1581) (#2205)
With the custom title bar the app draws its own resize zones. On Linux
they were hard to use:

- Edges asked for `ew-resize` / `ns-resize`, which some cursor themes
  (capitaine-cursors) don't ship: X11 fell back to the core font's big
  arrow and KDE Wayland showed the plain arrow, so the edge was hard to
  find. Edges now use the one-sided cursors (`w-resize`, ...), as GTK
  and KDE window borders do; corners keep the diagonal ones, which those
  themes draw in the same style.
- The edge zone grows from 5 to 8 pt (about a native resize border).
- KDE Wayland keeps the button release of an OS resize, so egui kept the
  edge pressed: hover stopped until the next click, and egui-winit never
  re-sent the resize cursor the compositor had replaced. The cursor came
  back only on every other press. After the hand-off the press is now
  ended in raw_input_hook, and the first motion afterwards shows the
  default cursor for one frame so the resize cursor is sent again.

Tested live on KDE Plasma (Wayland and XWayland).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 04:54:10 -04:00
Sir StarchandBrandon Thomas 7b3ded0f8b Layers menu and layer commands follow the selection, not the active layer (#2244) (#2254)
* Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243)

Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

* Layers menu and layer commands follow the selection, not the active layer (#2244)

Availability of Rasterize, Create/Release Clipping Mask, Add Layer Mask and Paste/Clear Layer
Style was decided by the active layer, so one selection was greyed or not depending on which of
its layers happened to be active (with the Background active, Create Clipping Mask and Add Layer
Mask were greyed although they would have run on the other layers), and Rasterize Layer was
enabled for layers it then refused ("nothing to rasterize"). Several of these commands also only
ever acted on the active layer, whichever layer the menu was opened on.

- Add Layer Mask (Reveal/Hide All, Reveal/Hide Selection), Paste Layer Style and Clear Layer Style
  run on every selected layer that can take them (not the Background, layers that already have a
  mask, layers without a style), in one history step. An explicit `layer`, or a single selected
  layer, behaves as before.
- `enable_rules::selection_targets`: availability of those commands, Create/Release Clipping Mask
  and Rasterize is judged by the selection.
- Rasterize Layer is enabled only for type, shape, fill and Smart Object layers, which is what the
  command converts.
- Items with no single target stay greyed for several layers: Blending Options, Copy Layer Style,
  Quick Export, Export As and the per-layer mask items. A multi-layer menu has one stable mask item.

Tests: engine integration (mask, paste and clear on a selection, errors, unchanged single-layer
paths) and a property test over 20 selections x clipboard state x 7 commands: an enabled item runs,
and for several layers a greyed one would have changed nothing. It fails on the old rules for
["R1", "BG"] with the Background active, the case from the bug report.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 04:18:42 -04:00
dd2ce3da33 GPU startup: CPU mode on Linux falls back to Vulkan to present the window (#1337) (#2206)
CPU rendering mode limited the window's wgpu instance to GL on Linux.
With NVIDIA's EGL the GL adapter can't present to the window (wgpu:
"EGL says it can present to the window but not natively") and llvmpipe
isn't offered, so the adapter selector found nothing and every start
exited with "no graphics adapter found", on X11 and Wayland alike. The
user couldn't reach Preferences to undo it.

CPU mode now creates the instance with GL and Vulkan. GL adapters still
rank first (llvmpipe, then hardware GL), so nothing changes where GL can
present; Vulkan is used only to draw the window when no GL adapter can.
Image compositing stays on the CPU.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 04:12:17 -04:00
TropicalandBrandon Thomas d4ec2db0a7 feat(ui): allow toolbar to scroll (#2238)
* feat: allow toolbar to scroll

* fix: assign the toolbar ScrollArea "toolbar-columns"

* chore: remove unused slots variable

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 04:04:26 -04:00
8d23c62272 Pen pressure curve: Preferences › Tools shapes how pen pressure reaches every brush (#2195)
* Pen pressure curve: Preferences › Tools shapes how pen pressure reaches every brush

Brush phase 6a (TOOL-213-11, #610). Every pen paints its pressure straight through, so artists
with a heavy or a light hand, or a tablet whose response feels off, had no way to adjust it.

- `tools.pressureCurve`: [input, output] points, linear by default, settable with `prefs.set`
  (validated: 2..=16 pairs within 0..1).
- `prefs::PressureCurve` reads it as a monotone cubic (Fritsch–Carlson), so a firmer press never
  gives less pressure. It sanitises the points itself (finite, clamped, sorted, one per input,
  capped, outputs made non-decreasing; flat beyond the ends), because a whole-section update or a
  hand-edited file can store anything.
- The stylus maps every pen sample's pressure through it before any brush sees it, so the live
  stroke, the commit and the journal agree. A mouse stays at full pressure.
- Preferences › Tools draws a curve editor under Use Tablet Pressure: drag a point, click to add
  one, right-click to remove one, with Linear, Soft and Hard presets. Translated in all 16 catalogs.

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 04:00:10 -04:00
265f10b91d Fix SVG selection and import detection (#2224)
* Fix SVG import detection and configurable group depth

* Fix malformed SVG error routing and formatting

* Refresh scorecard for SVG depth preference

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 03:58:08 -04:00
Sir StarchandBrandon Thomas 1b6150e3fc Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243) (#2253)
Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 03:55:43 -04:00
TropicalandBrandon Thomas 071fe4590b fix(ui): mode buttons respect two-column toolbar (#2241)
* fix: mode buttons respect two-column toolbar

* fix: draw menu indicator on window mode button

* fix: syntax mistake in `paint_toolbar_menu_indicator`

* fix: pass tokens to paint_toolbar_menu_indicator

* refactor: rename paint_toolbar_menu_indicator to paint_toolbar_flyout_indicator

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 03:49:22 -04:00
Brandon ThomasandClaude Opus 5.5 fc42f81f42 Web: leave the JPEG XL DNG decoder out of the wasm (25.1 MB -> 24.3 MB) (#2233)
* Web: leave the JPEG XL DNG decoder out of the wasm (0.88 MB, 23.1 MiB now)

The 0.6.0 web build went over packaging/web/package.sh's 24 MiB gate (26,980,503 bytes on
782bcb67). Today's landings added 1.34 MB; the biggest single item was jxl-oxide (#2047, JPEG
XL-compressed DNG): ~0.6 MB of jxl_* code plus brotli-decompressor and tables, 0.88 MB after
wasm-opt. 0c72d954 brought the wasm back to 25,144,413 bytes (21 KB under the gate) by deflating
catalogs and fonts; this takes it to 24,262,088 bytes (23.14 MiB, 904 KB of headroom).

jxl-oxide is now a native-only dependency of photocraft-raw. On wasm32 a JPEG XL DNG segment
returns RawError::Unsupported ("can't be opened in the browser version of PhotoCraft; open it
in the desktop app"), like the other cfg-gated web features; every other DNG compression and
raw format still opens in the browser. Native builds are unchanged.

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

* xtask i18n-coverage: read the deflated catalog! registry entries

0c72d954 changed the LANGUAGES registry from include_str!("xx.tsv") to catalog!("xx"), so
`cargo xtask i18n-coverage` failed with "language `ja` has no include_str! catalog". Parse the
new form (the macro argument is the catalog's file stem).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 03:40:35 -04:00
Nigel Ihza FirdausyandBrandon Thomas 91a2b58725 fix(ui): remove the gap beneath Spectrum document tabs (#2265)
* fix(ui): remove Spectrum document tab-to-canvas spacing (#2188)

* scrollbars test: compare the centred view within float error

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 03:40:16 -04:00
6a39da98c8 Move tool on a locked layer: Photoshop's message (#2249)
* Move tool on a locked layer: Photoshop's message

Dragging with the Move tool (or Cmd with another tool) on a layer it can't
move showed nothing: the Background or a position-locked layer with no
selection, or a locked layer whose selected pixels can't float. Photoshop
puts up "Could not use the move tool because the layer is locked." when
the drag starts (a plain click says nothing); move_lock.rs does the same
with the Error dialog, translated in every catalogue. The float lock rule
moves to float_cmds::locked_for_float so the UI can ask before the press
goes anywhere.

Also in Dutch and Ukrainian. The Cmd-with-a-painting-tool case lands with
the Cmd-as-Move change, whichever merges second.

* Fix merge: close the lock-message test

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 03:39:40 -04:00
Sir Starch 1f84f19caf Hand tool: an open hand and a fist as the pointer on Windows, and the fist while panning everywhere (#2196) (#2221)
* Hand tool: an open hand and a fist as the pointer on Windows, and the fist while panning everywhere (#2196)

Windows: winit maps CursorIcon::Grab and Grabbing to the four-arrow move cursor, so the Hand
tool never showed a hand. tool_cursor::hand now hands the OS an outlined bitmap hand (open) and
fist (dragging), drawn from signed distance fields like the brush tips, through the same
set_cursor_image path. macOS and the Linux themes keep their own grab cursors.

All platforms: the tool-cursor block of canvas_view is inside `else if primary`, which the Hand's
own drag branch skips, so Grabbing was dead code and the pointer was a plain arrow while panning
(Hand tool, Space and the middle button). The drag branch now sets the fist.

Also used by the Hand pointer under a dialog and the Camera Raw preview's pan.

* tool_cursor tests: the Hand's pointer on Windows is now a bitmap (#2196)

brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes pinned the old
behaviour (no bitmap, CursorIcon::Grab once the Hand is selected), which is the four-arrow move
cursor on Windows. It now expects the open hand's bitmap.
2026-10-10 03:15:00 -04:00
texjerandBrandon Thomas b37bff987c Cmd held is the Move tool with the painting tools (Cmd+Alt duplicates) (#2248)
Photoshop switches to the Move tool while Cmd (Ctrl off the Mac) is held
with every tool but the Hand, Rotate View, Zoom, Crop, Slice, Path Selection,
shape, Pen and Type tools. #896 did this for the selection tools per press; this
does it for the rest (Brush, Eraser, Gradient, Clone Stamp, Eyedropper, ...).

hold_keys gains Temporary::Move (a modifier, not a rebindable key); the
canvas sets PhotocraftApp::tool_override for the frame and the tool state
machine, the Move tool's selection check (move_ui::moves_selected_pixels)
and the tool modules that gate on the tool read active_tool(). The held-tool
latch now lasts through the release frame, otherwise the Move's Up went to
the selected tool. So Cmd-drag with the Brush moves the layer, Cmd+Alt-drag
duplicates it first (move_mods), Cmd-drag with a selection floats the
selected pixels, and Cmd-click picks the layer under the pointer.

Off the Mac, Cmd+Alt is Ctrl+Alt, which the brush tools keep for the
on-canvas resize (brush_resize, also Alt+right-drag there): with a brush
tool Ctrl+Alt-drag resizes, with the other tools (Gradient, Eyedropper...) it
duplicates. Tests pin both layouts on any host. docs/control-protocol.md
describes `command` on ui.pointer events.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:44:27 -04:00
Harrison O'Connor-Hoover 935c94b5d3 Add pixel mask density and feather controls to Properties (#2240) 2026-10-10 02:43:34 -04:00
Krzysztof CieślikandBrandon Thomas a0c18f5f75 Tools: double-clicking a layer mask launches Select and Mask with preference (#204) (#2161)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:35:51 -04:00
texjer 95006995f6 Selection cursors: Photoshop's badges for cutting, copying and dragging the selected pixels (#2250)
Over a selection the cursor now says what a press does, as in Photoshop:
the move cursor where a marquee press moves the outline (#1428, unchanged),
the arrow with scissors where Cmd (or the Move tool) cuts and moves the
selected pixels, the arrow with a second arrow where Alt lifts a copy, a
hollow arrowhead while they are dragged, and a plain arrow over the floating
piece. canvas::selection_cursor decides, drawn as icon badges beside the OS
arrow (icons::cursor_badge), white with a dark outline.
2026-10-10 02:24:22 -04:00
Harrison O'Connor-HooverandBrandon Thomas 97f37feaec Default layered flat-image saves to PSD (#2234)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:23:57 -04:00
eb20f83654 Masks: target a new layer mask and keep separate colour pairs for mask and pixels (#2166) (#2200)
* Masks: target a new layer mask and keep separate colour pairs for mask and pixels (#2166)

Photoshop keeps one foreground/background pair for layer masks and one for
layer pixels and swaps them when the edit target changes; adding a mask
targets it, with white/black. PhotoCraft never touched the colours when a
mask was targeted, and adding a mask left the pixels targeted.

- engine: ToolState::target_mask swaps in the other target's pair
  (mask pair starts at white/black, colours picked on either side are kept).
- ui-egui: sync_mask_targets applies the effective target (mask targeted,
  active layer has a mask, composite channel, no Quick Mask) after every
  command, every frame and every control request; the Layer Mask add
  commands target the new mask.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Fix merge: close mask colour-pair test before the deletion tests

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:20:48 -04:00
Nigel Ihza Firdausyandtiya nur alfiyanti 1e7c38b98f Expose Clone Stamp opacity and flow and apply them to strokes (#2256)
Co-authored-by: tiya nur alfiyanti <[email protected]>
2026-10-10 02:20:27 -04:00
Krzysztof CieślikandBrandon Thomas b4044a5c0c Workspace: wire open_documents_as_tabs preference (#204) (#2177)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:18:05 -04:00
Harrison O'Connor-Hoover 2db1b26d9d Preserve quoted multiline variable CSV records (#2242) 2026-10-10 02:17:09 -04:00
26d40dbd0f TGA: save the document's alpha channel as 32-bit alpha (#2124) (#2157)
Flat exports ignored the document's alpha channels, so a Targa saved
from a document with an "Alpha 1" channel had no alpha. Photoshop 25.1
(checked via scripting) writes a 32-bit Targa's alpha from the
document's alpha channel when there is exactly one (spot channels
skipped), composites layer transparency over white, writes an opaque
alpha when there are several channels, and never adds alpha to
grayscale Targas. TGA export now does the same; 16-bit documents are
written as 8-bit Targa with the channel's values.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:11:44 -04:00
Tanish Manku f2836b0475 Soft brushes paint soft strokes and thin lines stay even: a Gaussian-like tip and area coverage for tiny tips (#2167)
Brush phase 1 (owner-approved option A of the brush audit):

- Soft tips fall off as (1 - t²)⁴ from the hard core to the edge, a Gaussian-like curve that
  tracks exp(-4.6 t²) but is exact on every platform and reaches 0 at the edge with a flat tangent.
  The old smoothstep kept so much of the tip near full strength that overlapping dabs (Flow builds
  up, as in Photoshop) turned a soft stroke's edge to half the tip's width. It now keeps about two
  thirds of it, at the default 10 % spacing, with no ripple along the stroke.
- Computed tips under 3 px radius are rasterised by covered area (a 4×4 grid per pixel, each
  sample anti-aliased over its quarter pixel) instead of at pixel centres: a 1 px dab's total
  coverage no longer swings 1.00–1.17 with its sub-pixel position, so thin lines don't bead.
- The default spacing stays 10 %. Photoshop's documented 25 % beads soft strokes with either
  curve; it waits for Photoshop-authored reference strokes.
2026-10-10 02:10:39 -04:00
26cc6ed6bc Confirm layer deletion only from the Layers trash button (#2197)
* fix(ui): confirm deletion only from the Layers trash button

* Trash confirmation: Alt-click deletes without asking (Photoshop); drop docs page and screenshot

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

---------

Co-authored-by: kotulsky <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 02:10:24 -04:00
2c15f13f02 scorecard: Marquee modifiers and brush resize drag are done (TOOL-214-2, TOOL-213-10) (#2261)
* scorecard: Marquee Shift, Alt and size readout are done (TOOL-214-2)

The row still said canvas.rs passes false for Shift (#208), but #208 is
fixed: marquee_corners applies Shift (square or circle) and Alt (from the
centre), the W x H readout follows the drag, and marquee_tests.rs covers
all three.

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

* scorecard: on-canvas brush resize drag is done (TOOL-213-10)

The row said only [ ] resize the brush, but brush_resize.rs (#297, #370)
implements Photoshop's drag: Alt+right-drag, or Control+Option-drag on
macOS, sets diameter and hardness with a circle and readout, and is
covered by nine tests.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:04:18 -04:00
LuniiyaandBrandon Thomas 9c79871c6b Preferences: the dialog grows with the window (#2155)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:03:37 -04:00
8b1bb0fd05 fix(engine): select neighbouring layer after deletion (#2189)
Co-authored-by: kotulsky <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 02:01:55 -04:00
8a80430c40 Layers panel: press-drag-release picks a fill or adjustment layer (#2071) (#2172)
Photoshop's "Create new fill or adjustment layer" footer button opens its
menu on the mouse press, so one gesture can press it, drag onto an item and
release to choose it. PhotoCraft used egui's Popup::menu, which only opens on
the release (click), so choosing needed two clicks.

A small reusable press_menu helper (the menu bar's press-drag gesture from
#669/#775, for a single popup button) now drives the adjustment button and the
identical fx (layer style) footer button: the press opens the menu, the release
that ends the opening click keeps it open, releasing on an item chooses it, a
release on nothing chooses nothing, and a secondary click still opens it. The
gesture belongs to the popup whose button started it, so several press menus in
one frame don't end each other's gestures. The items dispatch the same
commands as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:57:43 -04:00
TropicalandBrandon Thomas 4aa511bb05 fix(ui): avoid drawing panel borders twice (#2226)
* fix: don't draw panel borders twice

* fix: avoid drawing caption buttons over titlebar border

* fix: remove manually painted separator on statusbar

* titlebar test: caption buttons stop above the panel's separator line

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:57:01 -04:00
44636cb6f2 Canvas: clip the Free Transform preview to the canvas (#2027) (#2201)
The transform overlay drew the moving pixels as a textured mesh with the
canvas painter, whose clip is the whole viewport, so a box moved or
scaled past the canvas edge showed its pixels over the pasteboard.
Photoshop never shows pixels outside the canvas; only the box and its
handles reach over the pasteboard.

Build the preview mesh (Free Transform/perspective grid and Warp) in
document space and clip each triangle to [0, 0, width, height] before
mapping it to the screen. Clipping in document space stays exact under a
rotated or flipped view; a mesh fully on the canvas is drawn unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:51:47 -04:00
Harrison O'Connor-Hoover 34382a65ca Ignore hidden RGB when Magic Wand selects transparency (#2273) 2026-10-10 01:51:28 -04:00
bf1036a273 Layers panel: Opacity and Fill share one right-aligned column (#2085) (#2173)
In the pro theme the Fill row zeroed the horizontal item spacing for the
lock icons on the whole row, so the "Fill:" label touched its field while
"Opacity:" kept the normal gap and sat 8 pt further left. Scope the zero
spacing to the lock icons only, so both labels end the same gap before
fields that share one right edge, as in Photoshop. The narrow-panel check
that drops the "Lock:" text now counts the restored gap.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:51:26 -04:00
kmnkv1andBrandon Thomas effee8cdc3 i18n(ru): translate layer style effects (#2210)
Stroke was Мазок; effect names were untranslated. Names follow Photoshop's Russian UI.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:43:46 -04:00
kmnkv1andBrandon Thomas f10c83425c fix(layer style): wrap Make/Reset Default buttons (#2209)
Translated labels (ru) are wider than the page column and overlapped the effect list.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:43:42 -04:00
39c97c9be0 Keep the selection when smart selection finds nothing (#2235)
* Keep the selection when smart selection finds nothing

Select Subject and Object Selection run heuristics that often miss on busy
photos. In Replace mode an empty result cleared the selection, so the Select
Subject button looked dead and even destroyed existing work. apply() now
preserves the selection and reports changed:false in every mode.

The UI also answers visibly instead of staying silent: empty smart-selection
results set an info status suggesting what to try next, and a click with
Object Selection falls back to a Quick Selection dab at the brush size
instead of doing nothing.

* Translate the smart-selection status messages; drop needless borrows (clippy)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 01:42:28 -04:00
5e481cde02 Layers: mask thumbnails update after Invert and other whole-mask edits (#2117) (#2164)
Thumbnail and bounds caches key on surface_fingerprint, which hashed only
tile coordinates and Arc pointers. A fresh Reveal All mask has no tiles;
Image > Adjustments > Invert (and other whole-mask edits) only flips its
default pixel, so the fingerprint stayed the same and the Layers panel kept
the stale white thumbnail. The fingerprint now includes the default pixel.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:27:41 -04:00
Michał Kosior 183122f676 Fix hyperlink in credits panel to use crate::links::open instead of the unreliable ui.hyperlink_to (#2146) 2026-10-10 01:26:48 -04:00
a9267e199d Fix layer mask toggle action labels (#2156)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:13:06 -04:00
David MannandClaude Opus 5.5 9482e0c710 Smudge: use the Brush Settings spacing for retouch strokes (#2140) (#2171)
parse_brush, shared by Smudge, Blur, Sharpen, Dodge, Burn, Sponge, the
clone/healing tools and the History Brush, defaulted `spacing` to a fixed
25% instead of the session brush's spacing (Brush Settings > Brush Tip
Shape > Spacing). The UI never sends `spacing` for these tools, so the
Brush Settings value was ignored. Default to the session brush spacing,
as Photoshop does; an explicit `spacing` param still wins.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 01:10:12 -04:00
BenzizaandBrandon Thomas 634cfbf3c5 Preserve unchanged XMP translations in File Info (#2152)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 01:08:52 -04:00
Coy Geek f4bc463ee8 docs(adjust): correct Levels and Curves composition order (#2212)
Describe per-channel records before the master to match the compositor fixed in #1929. Clarify the CMYK black channel and the ignored Lab master.

Closes #975.
2026-10-10 00:59:39 -04:00
SawizzleandClaude Opus 5.5 6973fe4496 docs: register the MCP server from an installed release (#2185)
* docs: register the MCP server from an installed release

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

* docs: drop the third-party link; the Windows path is the default install folder

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 00:59:34 -04:00
Brandon ThomasandClaude Opus 5.5 0c72d95425 Web: store translation catalogs and bundled fonts deflated, unpacked on first use (wasm back under the 24 MiB gate)
The 0.6.0 web build was 26.9 MB, over packaging/web/package.sh's 24 MiB gate (Cloudflare's per-file
cap is 25 MiB, #198). The i18n .tsv catalogs (1.85 MB -> 0.64 MB) and the Inter/JetBrains Mono fonts
(1.52 MB -> 0.73 MB) are now deflated by build.rs and inflated on first use, so wasm is 25,141,080
bytes. No visible change. A catalog that fails to inflate falls back to English, a font that fails
falls back to egui's default font; neither panics. Headroom is only ~25 KB: the next size cut is
needed soon (fetching catalogs on demand would save another ~0.64 MB).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 22:11:13 -04:00
Shayan HorbankandBrandon Thomas e398cc80b8 Use shared Color Picker for Layer Style and text styles (#2015) (#2049)
* Use shared Color Picker for Layer Style colors (#2015)

* Use shared Color Picker for text style colors (#2015)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 22:03:51 -04:00
texjerandBrandon Thomas a81eaa4187 Keyboard Shortcuts: import Photoshop .kys sets; first launch takes Photoshop's live set (#1163) (#1987)
* Edit › Keyboard Shortcuts: Import Photoshop Shortcuts… loads a .kys set (#1163)

Photoshop saves a shortcut set as .kys XML: <command name="Gaussian Blur...">
rows with one or more <shortcut>Control+Opt+Cmd+G</shortcut>, then the tool
keys. The dialog's new button (and File › Open or a drop of a .kys) parses it
with roxmltree, matches each row to our commands by menu label, and fills the
dialog's overrides, so OK applies the set the way hand-made changes are
(a key moved to a command is taken from its old owner) and Cancel discards it.

Rules, from Photoshop's own ambiguities: a label in several menus (Levels…
under Image › Adjustments and Layer › New Adjustment Layer; All under
Select, Edit › Purge and View › Show) goes to the command whose default is one
of the row's keys, else the one with a default, else the first in menu order.
A row's first key is kept unless one of its keys is already the command's
default (Zoom In is Cmd++ then Cmd+=). Scripts, Bridge, the frames commands
and the macOS application menu have no command here and are counted in the
message. Tool keys are not imported: the tool letters are fixed.

* First launch beside Photoshop imports its live shortcut set; the Open dialog lists .kys

Photoshop keeps the set in use as Keyboard Shortcuts.psp in its per-version
settings folder (macOS ~/Library/Preferences/Adobe Photoshop <v> Settings,
Windows %APPDATA%\Adobe\Adobe Photoshop <v>\Adobe Photoshop <v> Settings),
the same XML a saved .kys holds. The desktop app hands the newest version's
file to the UI (Services::photoshop_shortcuts); at the first launch that has
no shortcut overrides yet, kys_import::auto_import applies it through the
Keyboard Shortcuts dialog's own OK path and posts a notice naming the set and
the count. The preference dialogs.photoshopShortcuts records the source, so
the import happens once; someone who already changed a shortcut keeps theirs.
Reset All to Defaults undoes it.

The Open dialog's filter now lists .kys (it greyed them out), and dropping
Keyboard Shortcuts.psp imports it like a .kys.

* kys import: Ukrainian and Dutch strings; tests follow Cmd+K and Cmd+F as the defaults (#1900)

* kys import: user-facing strings name no other product (#1985)

The button is Import Shortcuts…, its tip names the .kys format, the notice
says "Keyboard shortcuts imported" and names the set, and a file that is not
a set is "not a keyboard shortcut set (.kys)". A set without a name is called
by its file name. Comments, identifiers and the XML root tag keep the format's
own name.

* rustfmt: format panels.rs and prefs_ui.rs

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 21:55:43 -04:00
5ed094a940 ui: Panels::rail and Panels::menu_bar let an embedding app hide the icon rail and the menu bar (#1960)
The right dock's icon rail and the title bar with the in-window menus were
always drawn. An app that embeds PhotoCraft's UI and offers a simpler view, or
draws its own bar, could hide every other panel through `ui.panels` but not
these two.

Two Panels flags, both on by default and with `#[serde(default = "yes")]`, so
saved settings and the current UI are unchanged. `ui.set` accepts them like
the other panel flags. The title bar's caption buttons, where PhotoCraft draws
its own, go with it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 21:47:12 -04:00
16ad6e1079 Add font previews and keyboard cycling to font menus (#1746)
Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 21:43:59 -04:00
Max MurphyandBrandon Thomas 995c895c33 Notices: auto-hide after a delay, with Interface preferences (#2022) (#2100)
* Notices: auto-hide after a delay, with Interface preferences (#2022)

Non-blocking notices no longer stay on screen until closed: each card
hides itself after Interface › Notification › Auto Hide Notifications'
delay (new, default 6 s), and the timer pauses while the pointer rests
on the stack until it moves away. Two new Interface preferences,
notificationAutoHide and notificationDurationSeconds, are read by
notices.rs; errors still land in the status bar.

The decision lives in the pure notices::expire helper, unit-tested
without an egui clock; Notice.shown_at round-trips through the control
channel with serde defaults. Labels translated across the complete
catalogues. Regenerated docs/scorecard.md.

* Notices: repaint on expiry, and a fresh delay when a long hover ends (#2022)

Addresses review of the auto-hide change:

- When the last notice expires, `show` had already painted it and, with
  nothing left, requested no further frame, so the card lingered until
  unrelated input. It now requests a repaint in the frame that drops
  anything, clearing the stack at once.
- A stationary hover over a settled UI refreshed `shown_at` only on
  frames, so hovering longer than the delay then leaving expired the
  notice instantly. `show` now tracks the pointer leaving the stack and
  restarts the timers on that frame, so hovering never counts as elapsed
  time.

Regression tests: expiring a notice requests a frame to clear it;
leaving a long hover gives a fresh delay.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 21:38:03 -04:00
LuniiyaandBrandon Thomas 8c3b6f8e5f Appearance modes: sync with system, separate dark and light themes (#1981)
* Appearance modes: sync with system, separate dark and light themes

* Appearance button cycles Auto, Light, Dark

* Appearance: default stays Dark, follow the portal signal instead of polling, split out the Preferences resize

* Preferences: size both appearance cards for the five dark themes

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 21:34:49 -04:00
LuniiyaandBrandon Thomas 782bcb67fa Toolbar: Studio themes draw round colour chips with Switch Colors below (#2002)
* Toolbar: Studio themes draw large round colour chips with Switch Colors right below

* Toolbar: round chips keep Default Colors, follow Tokens::round_chips, lie side by side in two columns

* Theme: Solarized Dark and Adwaita use the Studio round colour chips

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 21:14:37 -04:00
cc584d9aff Add Adwaita and Adwaita Dark themes (+ brush chip fix on light themes) (#1823)
* Brush preset chip: draw the tip preview in the icon colour on light themes

The options-bar chip always painted the round tip white, which nearly vanished on Studio
Light's chrome (246 gray) and disappears entirely on a white header bar. Dark themes keep
Photoshop's white tip; light themes use their icon token.

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

* Add Adwaita and Adwaita Dark themes

Two opt-in themes that put the Studio layout in GNOME's libadwaita palette, so PhotoCraft
sits with the rest of a GNOME desktop: header bar, sidebar, card and view colours, the
GNOME blue accent (#3584e4), destructive and warning colours, 6 / 9 / 12 px radii, and a
neutral (not red) close button as in GNOME's title bars. The default theme is unchanged.

Wired through ThemeKind, the interface.theme preference (adwaita, adwaitaDark), Window ›
Theme and the control channel, with menu and preference labels in every complete-menus
language ("Adwaita" kept as the proper name). A test holds both themes to WCAG contrast:
7:1 for text and 4.5:1 for dim text on every surface that carries labels.

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

* i18n: translate the Adwaita theme names in Dutch and Ukrainian

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 21:08:12 -04:00
SilentYetiandClaude Opus 5.5 db0fb07862 Add native layered Paint.NET (.pdn) import (#1998)
Squashed onto main, with the maintainer's asks applied:
- Photoshop's blend menus (layers, brush, pencil, gradient, layer style) stay as they are;
  the Layers panel and Blending Options list a Paint.NET mode only for a layer that uses one.
- The six Paint.NET mode labels are translated in every catalog, and the blend-mode i18n test
  iterates layer_modes().
- pdn::pixels rejects truncated uncompressed (version 1) data before allocating its buffer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 21:05:00 -04:00
Brandon ThomasandClaude Opus 5.5 d3fa5e4c6f Bump version to 0.6.0 (from 0.5.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 21:02:23 -04:00
Max MurphyandBrandon Thomas 9c427a7a2c Themes: add Solarized Dark (#1136) (#2098)
* Themes: add Solarized Dark (#1136)

* Solarized Dark: ship the palette's MIT license notice (#1136)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 20:34:31 -04:00
Brandon ThomasandClaude Opus 5.5 69b1379781 prefs_ui: declare recovery_tests after the production code (fixes prefs_usage on main) (#2194)
#2115 put `#[cfg(test)] mod recovery_tests;` at the top of prefs_ui.rs, and the
prefs_usage test reads each file only up to its first test module, so every preference
read in prefs_ui.rs looked unread.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 20:16:52 -04:00
23fbfb9b3c Layers: delete targeted masks and edit Solid Color by thumbnail (#1997)
* Fix mask trash targeting and Solid Color Fill editing

* Fix Solid Color picker mode conversion and validate RGB ranges

* Apply rustfmt to Solid Color regression tests

* Fix Solid Color mode regression test using distinct RGB values

---------

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 20:15:29 -04:00
ZamandClaude Opus 5.5 6884809d02 Gradient Editor window: the Gradient tool's swatch opens a customizable gradient editor (#1983)
Rebased onto main as one commit (the PR's net diff); the preset-drop mapping now uses
ViewXform::active, which accounts for the display's pixels per point.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 20:05:26 -04:00
d502c5de27 ci(web): validate browser builds and wasm size on pull requests (#1689)
* ci(web): build browser release and enforce wasm gate on PRs

* docs(web): describe PR browser build and size checks

* CI web gate: run only when web-relevant files change (main always)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 20:05:15 -04:00
Ahmed Moussa 1cfc56db56 fix(Text): served fonts become script fallbacks on the web (#2109)
A host's font manifest can now list the scripts each font covers (`scripts` field). The first served family for a script the bundled fonts lack (Arabic, Hebrew, Devanagari, Thai, Japanese, Chinese, Korean) becomes that script's fallback. When a layout contains text in that script, it requests the family. The font database adds the family to the fallback stack once it arrives, and type layers drawn before then are re-rendered.

The web shell also fetches the fallbacks for the browser's languages (`navigator.languages`) at startup, so an Arabic reader gets the Arabic font before typing anything.

The manifest's SHA-256 is now turned into a Subresource Integrity value (`sha256-<base64>`) and passed with the font fetch, so the browser rejects any file whose bytes don't match.

Also adds a contributor entry for hamada147 to `contributors/people.toml`.

Signed-off-by: Ahmed Moussa <[email protected]>
2026-10-09 20:04:04 -04:00
1330899aca fix(web): persist imported brush libraries in IndexedDB (#479) (#2054)
* Persist browser brush presets through IndexedDB

* fix(web): clear resolved preset staging rejections

* web: translate the brush preset storage messages

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

---------

Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 19:58:25 -04:00
35f832f03a Brush panel: update-current-brush button, identity-based preset selection, tip-grid fixes (#2007)
* update brush button

* ui brush duplicated highlight fix

* formatting fix + locale

* i18n: translate "Update the current brush with these settings" in remaining complete catalogs

* Tip Shape picks the tip only; set_brush records the preset once accepted

- A click in the Brush Tip Shape grid copies the tip fields only (main's
  Photoshop behaviour: the dynamics sections stay); the picked preset is
  marked current with brush.presets.setCurrent instead of a whole-preset
  tools.setBrush, so identity-based selection still tracks it.
- tools.setBrush sets current_preset only after the brush merged and
  validated, so a failed call leaves no state behind; a reset still wins
  over a preset in the same call. Tested.
- brush.presets.save / brush.defineFromSelection append at the end again
  (insert-after-the-copied-brush had no Photoshop reference).

* Brush presets: forget the picked preset when the tool's brush swaps; no indexing in brush.presets.update

Each painting tool keeps its own brush (#218), so Update Brush could overwrite the
preset picked with one tool with another tool's brush.

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

---------

Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 19:56:41 -04:00
Tanish Manku 917fbcb006 Pen pressure follows every sample: moves in one frame no longer share the frame's last pressure (#2112)
Brush phase 3b. The stylus feed was one latest-value slot, so every pointer move in a frame
painted with the frame's last pressure, tilt and rotation: fast pen strokes got stair-stepped line
weights (#2046, #2036, #1776, #610).

- `StylusFeed` keeps the latest sample (for proximity, the eraser end and tilt) and also queues
  every sample in order (bounded to 1024). `set()` is unchanged, so the macOS, X11 and web
  sources, and a WinTab source later, need no changes.
- Each frame takes its pen samples in order: the feed's queue, then the touch forces (Windows).
  Pointer move k of n takes the pressure, tilt and rotation interpolated at its share of the
  frame, from the previous frame's last sample. Rotation takes the short way round. A mouse
  still paints at 1.
- A new stroke starts from this frame's samples, never from a sample an earlier stroke left behind.
- The stylus updates once per egui frame: the canvas runs once per view, and egui can run a
  frame in several passes, so a second update drained an empty queue.
2026-10-09 16:55:18 -07:00
Harrison O'Connor-Hoover 269d8fedc7 Keep startup responsive while recovering autosaved documents (#2115) 2026-10-09 19:50:53 -04:00
David MannandClaude Opus 5.5 03bb64a681 Brushes: keep the folder hierarchy of imported .abr files (#1851) (#2001)
* Brushes: keep the folder hierarchy of imported .abr files (#1851)

Photoshop CC brush files keep their Brushes panel folders in a `phry`
(preset hierarchy) section; the importer skipped it, so every preset of an
.abr landed in one flat group named after the file.

- photocraft-psd: `abr::parse` reads `phry` (a token stream of `Grup`
  {Nm, zuid}, `preset` and `groupEnd` objects) into `AbrFile::folders`, one
  folder path per preset. A damaged hierarchy never fails the parse: a stray
  groupEnd, more or fewer preset tokens than presets, folders nested deeper
  than MAX_FOLDER_DEPTH (32) or an unreadable section become warnings and the
  affected presets land at the top level. `write_v6_folders` writes `phry`
  back (`write_v6` is unchanged).
- photocraft-paint: `BrushPreset::folder`, the nested folder path inside its
  group (serde default, so older JSON loads unchanged).
- photocraft-io: the file's folders nest inside the group named after the
  file, as Photoshop's Import Brushes does.
- Preset store: each stored preset records its `folder`; group files written
  before this change load with every preset directly in its group (no
  rewrite). Paths from disk and from imported .pcpresets files are bounded.
- Commands: brush.presets.move, moveGroup, renameGroup and deleteGroup take
  an optional `folder` path to move a preset into a folder, or to move a
  folder among its siblings, rename or delete it. importAbr reports the
  number of folders kept.
- Brushes panel and Brush Preset picker: nested folders are drawn indented
  under their group (list and grid), open and close like groups, take
  dropped presets, reorder among their siblings by drag and drop, and have
  Rename/Delete in their context menu.

Checked against the 4-folder ABR Photoshop 25.1 exports of its default
brushes (8/6/6/8 presets): all 28 land in their folders, and a write/parse
round trip keeps them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Brushes: bound phry folder allocation; reword the move folder param

Review on #2001:
- read_phry now runs after every section is read, so it builds folder
  paths only for the presets in desc (a phry may precede desc), and the
  paths it holds are capped at 16 MB; presets past the cap import at the
  top level with a warning.
- brush.presets.move documents `folder` as folder names, so the
  automation filesystem-path classifier no longer flags it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 19:46:07 -04:00
Brennan StehlingandClaude Opus 5.5 85572c61c3 Save in the background so large saves don't freeze the window (#2017) (#2072)
* Save in the background so large saves don't freeze the window (#2017)

File › Save and Save As encoded and wrote the whole document on the UI thread,
so a multi-GB layered TIFF or PSB save left the window unresponsive for the
whole write, with no progress.

Saves now run as a background job: a new `Services::save_file` encodes and
writes atomically on a worker (the desktop app provides it), the document is
locked meanwhile, and the existing progress dialog shows the stage with Cancel.
Cancelling while encoding leaves the file untouched. The save is recorded when
the job ends, the unsaved-changes prompt waits for it, and a window close
during a save is held until it ends. Without the service (web, tests) saves
stay synchronous.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Background save: a save can't be cancelled once writing starts (#2072 review)

- `Services::save_file` now gets a `SaveCtl` and calls `commit()` right before
  replacing the file. Cancel and Esc are refused after that, so a "Cancelled"
  save can no longer replace the file while the document shows unsaved.
- A save's worker stays counted (`saving()`) until it returns, even when its job
  was cancelled: quitting waits for it, and another save to the same file is
  refused until it is gone.
- A failed or cancelled save no longer closes a window whose close was held.
- The pending reply carries the job id, and the job becomes `last_started`, so
  control and MCP clients can wait for the save.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 19:39:55 -04:00
Sir StarchandBrandon Thomas 819bd47bf1 perf: bevel_bench, the cost of changing a chisel bevel's size (#2006 follow-up) (#2118)
#2006 made a bevel size change dearer on purpose (+10-75% on the GPU canvas,
2x for CPU text bevels) and asked for a bench of it under perf/.

`bevel_bench` renders a 12 MP document with one Chisel Hard inner bevel on
an anti-aliased ellipse (a smooth outline) or on a text paragraph (many thin
edges). Each case warms the caches one pixel below the size, then times
refreshes with the size growing by one pixel each, so the GPU canvas
rebuilds its distance fields every time (it keeps them while the size
shrinks) and the CPU compositor misses its map cache. Sizes 8, 50 and 250,
GPU and CPU, with `--scene`, `--size`, `--reps`, `--no-cpu` and `--json`.

Registered in perf/budgets.toml as P52-P59 with no budgets (track only,
like P45 and P46): GPU for ellipse and text at 8, 50 and 250, CPU at 8.
docs/scorecard.md is regenerated; the rows read "not run" until the next
baseline.

RTX 4080 SUPER, release, 9 reps, p50 ms, before #2006 (3bc49f5e) -> after:
ellipse GPU 88 -> 105 (8), 169 -> 199 (50), 305 -> 460 (250); text GPU
119 -> 140, 141 -> 252, 238 -> 557; CPU text at 8 402 -> 797. Runs vary by
about 20% on this machine.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 19:39:45 -04:00
AyanandBrandon Thomas e93b30e60d Proximity Match: 1.5-3.8x faster stroke searches, smaller mask tables (#2101)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 19:34:26 -04:00
AyanandBrandon Thomas 3953d78378 Selection distance transforms: 7.6-10.6x faster and correct past 4K (#2093)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 19:28:33 -04:00
590c1496b4 Affinity import: special shapes and sample corpus (#1606) (#2035)
* Import Affinity issue 1606 special shapes

* Affinity corpus: give the #1606 sample ceilings a margin for font differences between machines

The samples' labels render with whatever fonts are installed, so the measured
differences move by up to 0.8 between machines; each ceiling is now the higher
measurement plus about 1. Also use as_chunks in on_white (clippy with --features corpus).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 19:27:35 -04:00
0e011a69dc feat(canvas): Caps Lock forces the precise crosshair for painting tools (#1758) (#2111)
* feat(canvas): Caps Lock forces the precise crosshair for painting tools (#1758)

* Caps Lock poll: back off for 5 s after an X error instead of reconnecting and warning every frame

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 19:25:36 -04:00
5630e112b4 Layer style colours keep the document's mode (Gray, CMYK) (#2103)
In a Grayscale or CMYK document, effect colours (overlays, shadows, glows,
strokes, satin, bevel, gradient stops) were stored as RGB and drawn
unconverted: a red Color Overlay in a Gray document was red. Effect::in_mode
converts them where effects are written: the effect commands, the Layer
Style dialog (replace), Paste Layer Style and style presets.

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 19:20:10 -04:00
510636f461 DNG: JPEG XL compression 52546 (DNG 1.7, Samsung Expert RAW) (#2047)
* DNG: JPEG XL compression 52546 (DNG 1.7, Samsung Expert RAW)

Decode JPEG XL-compressed DNG strips and tiles with jxl-oxide (pinned
=0.12.6), with geometry and channel checks, Limits/AllocTracker budgets
and catch_unwind around the decoder. Warn when a DNG carries a
ProfileGainTableMap (tag 52525), which is not applied yet.

Tests encode fixtures in-process with zune-jpegxl (dev-dependency only).

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

* raw README: list Deflate DNG beside JPEG XL after rebasing onto #1971

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 19:17:46 -04:00
David MannandClaude Opus 5.5 c161a7a5c9 Shape tools: a click opens Create Rectangle / Create Ellipse (#1175) (#2031)
In Photoshop a single click (no drag) with a shape tool opens a creation
dialog to type the exact size. PhotoCraft ignored the click: the drag
geometry was below 1 px, so finish_shape returned without doing anything.

- New shape_dialog module: Create Rectangle (width, height, four corner
  radii with a link toggle, From Center), Create Ellipse, Create Triangle
  and Create Polygon (sides, star ratio). Width and height show in the
  ruler unit and are stored in document pixels; values are remembered per
  tool. OK runs the existing shape.create command at the clicked point
  (or centred on it) with the options bar's fill and stroke; invalid
  sizes/positions are rejected with an error, never a panic.
- canvas::finish_gesture opens the dialog when the gesture never left
  the press point by more than 3 screen px; drags draw exactly as before.
  Line and Custom Shape keep ignoring clicks.
- vector_ui::create_shape shares the fill/stroke params with the drag path.
- Catalog entries for the new labels and titles in every complete language.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 19:14:31 -04:00
72d78f3067 Patterns paint in the document's colour mode (Gray, CMYK) (#1950)
An RGB pattern (every built-in one) showed in colour as a pattern fill or
Pattern Overlay in a Grayscale or CMYK document, unlike Edit > Fill, the
export, and Photoshop, which fills in grey but keeps the pattern's colours
(a Grayscale round trip brings them back).

pattern::pattern_rgba projects a pattern's pixels through the document's
mode when painted; the CPU prepared tiles and the GPU pattern textures use
it. Patterns themselves are unchanged.

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-09 19:12:45 -04:00
David MannandClaude Opus 5.5 c7287703a3 Crop tool: Show Cropped Area (H) and Crop Shield colour/opacity options (#1919) (#2128)
The Crop options bar gets Photoshop's gear menu (Set additional Crop options): Show Cropped Area
(H toggles it while the Crop tool shows a box; off hides everything outside the box behind the
canvas colour) and Enable Crop Shield with Color (Match Canvas, the pasteboard colour, or Custom…,
which opens the Color Picker), Opacity (default 75 %) and Auto Adjust Opacity (half the opacity
while the box is dragged). The shield used to be a fixed black at about 51 %. The options persist in
ToolOptions.crop_shield (older settings get Photoshop's defaults; a stray opacity is clamped) and
ui.set takes cropShield. Classic Mode and Auto Center Preview are left out: PhotoCraft always crops
in Classic Mode.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 19:10:06 -04:00
David MannandClaude Opus 5.5 35010f5138 Crop tool: Straighten button and ⌘/Ctrl-drag level the frame (#1919) (#2127)
The Crop options bar gets Photoshop's Straighten button (one-shot), and holding ⌘/Ctrl with the
Crop tool is a temporary Straighten. The drawn line (with an angle readout) turns the frame so the
line ends up level, or plumb when it is nearer vertical (the Ruler's straighten_angle convention),
and fits the frame to the largest of its proportions inside the image (Classic Mode). It is a
pending frame edit: Enter commits, Esc cancels. New logic lives in crop_straighten.rs.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 18:56:11 -04:00
Ian Swope 2caa58884c Free Transform: the preview follows edits made while the box is open (#1384) (#2099)
Changing the moving layer's opacity, fill or visibility in the Layers panel
during Free Transform was applied and kept on commit, but the preview showed
the session's starting state until then: the moving pixels kept their first
opacity and the document under them was the copy taken when the box opened.

follow_edits rebuilds that copy from the current document whenever its
revision changes (keeping the lifted pixels out) and takes the tint from the
layer's opacity x fill, or 0 when hidden. canvas::display_doc calls it before
reading the preview, so the canvas never caches a new revision with the old
preview. Lone mask/channel transforms and Transform Selection are unchanged.

Blend modes are still not shown on the moving pixels (an egui mesh blends
normally); that needs the compositor and is left for discussion in #1384.
2026-10-09 18:51:55 -04:00
f9ea13a857 EXR: multi-part files open the colour part; exr_info lists them all (#2116)
* EXR: multi-part files open the colour part; exr_info lists them all

A Maya/Arnold render writes one part per AOV. The decoder took part 0
silently, so a depth-only part failed the whole file with "no R/G/B or
Y channels". Now the part that looks most like a colour image opens
(RGBA > RGB > YA > a single channel, earliest wins), with a
DecodeWarning::MoreParts naming how many parts exist.

exr_info lists every part (name, view, size, channels, deep/tiled) from
the headers alone, and decode_exr_part decodes a chosen part — F16 parts
stay F16. Deep parts stay with the deep decoder: a file that has any is
refused here with a pointer to decode_deep_exr, by design.

* EXR: cap the parts' sizes together, move the chosen part out instead of cloning it

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 18:51:33 -04:00
zenjabbaandzenjabba 9d1713cfb6 fix(ui): show brush strokes live in mask view (#2078) (#2108)
Co-authored-by: zenjabba <[email protected]>
2026-10-09 18:44:34 -04:00
David MannandClaude Opus 5.5 596842666f Masks: mask button adds a vector mask instead of replacing the layer mask (#2075) (#2114)
The Layers panel "Add a mask" button always dispatched
layer.layerMask.revealAll/hideAll/…Selection, which replace the layer's
mask unconditionally. A second click (Alt or not) silently discarded a
painted mask; Alt-click only looked like "invert" because hide-all
replaced reveal-all.

Like Photoshop, the button now adds a layer mask when the layer has
none, a vector mask (Alt: Hide All) when it already has a layer mask,
and is greyed when the layer has both (or is a shape layer with a layer
mask). Its tooltip becomes "Add vector mask" once a layer mask exists.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 18:33:14 -04:00
zenjabba 48fc0279e7 fix(ui): keep 32-bit Preview Options within the window (#2081) (#2105) 2026-10-09 18:29:57 -04:00
zenjabbaandzenjabba 9200786605 fix(linux): disable unsupported startup notification (#2089) (#2097)
Co-authored-by: zenjabba <[email protected]>
2026-10-09 18:18:54 -04:00
Tanish Manku a680ed22c9 Brush strokes carry their timing: the first moves count, smoothing feels the same on any device, a held airbrush builds up (#2110)
Brush phase 3a (owner-approved brush overhaul):

- Every stroke point now carries a time (`timeMs`). egui's pointer events have none, so a frame's
  samples are spread evenly between the previous point and the frame time. Speed spacing, airbrush
  Build-up and Stroke Catch-Up worked from scripts but never from the canvas, because every point
  had time 0. The live stroke and the commit get the same times.
- A stroke started on the press takes its moves from the press on. The moves before egui
  recognised a drag (its 6 pt click distance) were dropped, which gave every stroke a straight
  start and lost the pen's pressure ramp-in.
- Smoothing pulls by stroke time (16 ms steps, fractional below that), not once per input event,
  so a 1000 Hz mouse and a 60 Hz pen feel the same. Without Stroke Catch-Up a paused pointer stops
  the brush, as in Photoshop.
- A pointer held still keeps the stroke's clock running only while the stroke changes with time
  (airbrush Build-up, or smoothing still catching up): it repeats the held point every 16 ms and
  asks for repaints, then goes idle.
2026-10-09 15:08:25 -07:00
Sir Starch f4c1048ead Chisel bevel: facets are flat at any edge angle (#1926) (#2006)
* Layer styles: chiselled bevel facets are flat at any edge angle (#1926)

Chisel Hard and Soft shaded the facets of an edge at 30, 60, 120 or 150
degrees in regular cross-stripes (a comb along the edge), while 0, 45, 90
and 135 degree edges were clean. The height of a chisel is a linear ramp of
the distance to the edge, and that distance was measured to the nearest
pixel centre of a thresholded mask: an oblique edge is a pixel staircase,
so the distance ripples with its period (0.19 px at 60 degrees), and the
shading is a gradient of it, scaled by depth.

Chisel bevels now measure to the edge itself. A sharp anti-aliased edge
pixel (partly covered, touching an empty and a full pixel) places the edge
by its coverage along its own normal (edge_offset inverts the area a
straight edge cuts off a pixel), and a pixel measures to the line of the
edge pixels around the foot of its perpendicular, not to the centre of the
nearest one (edge_distance). The ripple drops to 0.01 px. Hard-edged masks
and soft ramps have no sharp edge pixel and give exactly the plain fields.

The GPU compositor takes the bevel distance fields from the CPU through
new FieldKind::BevelInside / BevelOutside (the glow fields are unchanged);
distance_field takes the reach so values past it are not refined, and the
band halo of the bevel fields grows to reach + 9.

* Chisel bevel: edge normals from a line fit, exact at hard stretches (#1926)

A follow-up to the previous commit, from measuring how far it was from the
best it can do. The 3x3 coverage gradient gives an edge's direction to 1-3
degrees, and the error is systematic, so every distance tilted the same way
and left a 0.010-0.017 px ripple. With the true normals the ripple is 0.002
to 0.004 px, so the normal was the limit.

refine_edge_normals fits a line through the edge points (pixel centre moved
to its edge) of the sharp pixels within 3 px that agree to 25 degrees, and
takes the line's normal where the points really are on a line (spread along
it at least 0.7 px, off it at most 0.1 px). Corners and tight curves keep the
gradient's normal. On straight edges it reaches the ideal-normal floor.

The inside field now seeds from the empty pixels, as the plain field does,
and measures to the sharp pixel next to the nearest empty one, capped by the
plain distance (which never falls short of the true one). A circle's pole,
or a rounded rectangle on whole pixels, has a stretch of edge exactly on a
pixel boundary with no sharp pixel; the distance to the sharp pixels alone
was up to 0.29 px too long there. A mask without sharp pixels still gives
exactly the plain field.

Measured on anti-aliased shapes: ripple on straight edges 0.0105 -> 0.0038
(0.0172 -> 0.0021 at 75 degrees); a disc of radius 70, inside, worst error
0.29 -> 0.02. The band halo of the bevel fields is reach + 9 and the banded
test now sees a halo that is too small (the old one passed at reach + 3).

* Chisel bevel: sparse sharp-edge storage, no full-map copies (#1926)

Measured before sending the fix: the new
bevel fields made a bevel size change in the GPU canvas about twice as slow
(12 MP: 84 -> 161 ms per step), and nearly all of a step is the CPU effect
maps. The cost of the normal refinement was memory, not arithmetic: a
Vec<Option<SharpEdge>> is 16 bytes for every pixel, refine_edge_normals
copied the whole map twice for a few thousand edge pixels, and
local_coverage cloned the whole map and read nine pixels for each.

SharpEdges keeps a 4 byte slot per pixel and a short list of the edge
pixels. The candidates are found by a row by row pass over which pixels are
covered (an edge pixel is covered and has an empty neighbour), and the
coverage is worked out where it is needed by the same formula as
local_coverage. The refinement works on the list. Nothing else changes:
the fields are bit-identical (FNV fingerprints of 27 maps at 5 reaches and
of whole bevel_maps at 3 sizes, 216 of 216 equal), and the rendered PNGs of
the issue scene and a realistic scene are byte-identical.

Release, 12 MP ellipse, size 8, one thread: new fields 408 -> 351 ms (old
fields 290); the sharp pass 48 -> 19 ms and the PCA step 32 -> 1 ms.
GPU canvas, ms per size change, main / before / after: 12 MP ellipse
84 / 161 / 96, 300 stars 122 / 246 / 166, text 91 / 184 / 139, 36 MP
ellipse 295 / 522 / 336. What is left on edge-heavy content is the
neighbour search and edge_distance, which this commit does not touch.
2026-10-09 14:22:21 -07:00
zenjabbaandzenjabba 3bc49f5e1a Fix Smart Object conversion of selected layers (#1079) (#2052)
Co-authored-by: zenjabba <[email protected]>
2026-10-09 14:03:16 -07:00
kayrockscreenprintingandClaude Opus 5.5 0493d06aaa Edit › Stroke: whole opaque pixels, Center like Photoshop, and strokes on the canvas edge (#2025)
* Edit > Stroke: whole opaque pixels; Center puts the odd pixel inside; canvas edge strokes

Center expanded and contracted the selection by w/2, so odd widths left
half-transparent pixels on both sides (a 1 px stroke was a 2 px band at
half opacity), and a selection edge on the canvas border got no stroke
(Select All, Stroke Inside drew nothing). As in Photoshop, widths round up,
Center puts ceil(w/2) inside and floor(w/2) outside, and the mask is padded
so the canvas border is a selection edge.

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

* Edit > Stroke: a hard-edged selection strokes hard, matching Photoshop's curves

Photoshop's 6 px Outside stroke of a 14 px circle selection made without
anti-aliasing is reproduced exactly when a pixel is stroked whenever its
centre is less than width + 1 from a selected pixel's (our expand gave the
6..7 px fringe partial alpha). For a hard selection the band is hardened;
soft selections keep their soft edges.

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

* Edit > Stroke: the inside of a hard stroke matches Photoshop on curves

Photoshop's 3 px and 5 px Inside strokes of a 14 px hard circle, and its 6 px
one filling the circle, fit one rule: inside, a pixel is stroked when its
centre is less than width + 1/2 from an unselected pixel's (the outside rule
is width + 1). Straight edges are unchanged.

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 14:01:07 -07:00
kayrockscreenprintingandClaude Opus 5.5 1b8a68d45b Edit › Fill and Edit › Stroke: the colour opens the Color Picker (#2019)
* Edit > Fill: Color... and its swatch open the Color Picker

Choosing Contents: Color... opens PhotoCraft's Color Picker (as Photoshop's
Fill does), and the colour swatch opens it too instead of egui's compact
popup. OK sets the Fill colour, not the foreground; Cancel leaves it.

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

* Edit > Stroke: the colour swatch opens the Color Picker

A dialog body asks for the Color Picker on its colour field with
color_picker_ui::request; Fill and Stroke share it. OK sets the dialog's
colour, not the foreground.

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 14:00:31 -07:00
David MannandClaude Opus 5.5 51f9abaad7 Brushes: decode imported tips on demand, not at start (#1843) (#1949)
Opening the preset store decoded every imported tip at full resolution, and
each tip then lived twice: in the session's library and in the store's
snapshot of the last sync. A generated 600-brush library (16-bit tips,
800-2000 px, 2.27 GB decoded) cost about 4.5 GB after import and again after
every restart.

Library presets now hold their bitmaps as StoredTile references with a small
saved preview (at most 96 px): photocraft-paint gains TipShape::Stored and
Pattern::Stored (rendered with the full bitmap when loaded, else the preview),
and the store writes each tip's size and preview with its group. Opening the
store reads previews only; once a group is written its presets drop their full
bitmaps. A tip is decoded when its brush is used (picking a preset, a stroke
naming one, exporting) into a 256 MB least-recently-used cache. A loaded stored
tip serialises as `sampled`, so tool presets, actions and brush.get keep the
same JSON. Stores written before this change have no previews: their tips are
decoded once at the next start, eight at a time, and the groups rewritten. A
missing or corrupt tip file is now reported when the brush is used.

Measured with crates/engine/examples/abr_memory.rs on the 600-brush library
from crates/io/examples/gen_abr_library.rs (Windows, release):
after import 4565 -> 38 MB working set, after restart 4551 -> 20 MB,
after using 5 brushes 4560 -> 37 MB; store open 1.47 s -> 0.05 s.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 14:00:12 -07:00
zenjabbaandzenjabba 20a5f193f8 docs: describe actual raster format depths and decode limits (#520) (#2050)
Co-authored-by: zenjabba <[email protected]>
2026-10-09 13:59:13 -07:00
Roaldo Sulejmani 424a5d492f fix: remove Photoshop from user-facing strings (#1985) (#2008)
The app still showed the Adobe Photoshop name in several user-visible
strings. Per the product rule (user-facing text says PhotoCraft only),
none of these mention Photoshop now.

UI labels, tooltips and status messages:
- New Document preset "Default Photoshop Size" -> "Default PhotoCraft Size"
- "All shortcuts reset to Photoshop defaults." -> "All shortcuts reset to
  PhotoCraft defaults."
- "Import Photoshop brushes (.abr)" -> "Import brushes (.abr)"
- smart-filter fallback name "Photoshop filter" -> "Smart filter"
- File > Save As .psd/.psb filter "Photoshop" -> "PSD Document"

Errors and warnings:
- "this Photoshop filter isn't implemented in PhotoCraft" -> "this smart
  filter isn't implemented in PhotoCraft"
- smart_map: "has no PSD equivalent", "not a recognized filter", "its
  filter data is missing/unreadable", "this filter has no PSD equivalent"
- tiff_layers: "the PSD layer data...", "the PSD image resources..."
- multichannel: "32-bit Multichannel isn't supported"
- abr: "not a readable brush file (.abr)"
- gradient import: "not a readable gradient file (.grd)"

Translations: updated the changed tl! sources in all 16 catalogs and
dropped the stale "Load Photoshop brushes (.abr)" entries.

Installer / OS metadata: de-branded the nfpm description, the PSB MIME
comment, the AppStream summary/description, the macOS UTType
descriptions, the Windows ProgId Description and the desktop launcher's
Keywords. The Photocraft.PhotoshopDocument ProgId and the
image/vnd.adobe.photoshop MIME/UTType stay: machine identifiers,
changing them hurts file fidelity and Open-with registration.

Command params docs (agent-facing): dropped Photoshop from the
type_cmds kernPair text, the layer_menu_cmds Advanced Blending defaults
and the mask_view_cmds backslash-key text. The "photoshopManages" wire
value stays. Comments, format identifiers and the "photoshop" theme
alias are out of scope and unchanged.

The macOS native-menu shortcut resolution messages no longer say "as in
Photoshop" (Hide/Minimize).
2026-10-09 13:59:07 -07:00
David MannandClaude Opus 5.5 27906c05c1 Compose: bound effect memory on large documents (#1909) (#2076)
A layer with Transparency Shapes Layer off takes the whole canvas as its
effect region, so on a 1920 x 103675 page each such stroked layer built
~2.4 GB of effect maps, and a render held them for every layer at once.
Every edit's damage rect rebuilt them: 13 s and 11+ GB for three layers,
past 24 GB for ten.

When such a layer's effects are local (no gradient strokes, Precise
glows, chiselled bevels or noise) and it is composited within the
rectangle being rendered (each band of render_bands, or a damage rect),
build its maps over that rectangle padded by twice the effect reach, and
let them go when the next band starts. Composited over anything wider (a
group's effect shape renders its children over the group's whole region)
it keeps the whole region. The pixels are unchanged: a test renders every
local effect kind, masked and unmasked, inside a group with far-reaching
effects and clipped to a base with a wide glow, at 8/16/32 bits, in bands,
parts and tiles from a cold effect cache, and compares bit for bit with
the whole render.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:58:01 -07:00
zenjabbaandzenjabba 102e7ea891 fix(ui): apply distinct workspace preset layouts (#1120) (#2051)
Co-authored-by: zenjabba <[email protected]>
2026-10-09 13:57:56 -07:00
Alex 523d9257a8 Match New Document to clipboard in control API (#2045) 2026-10-09 13:56:24 -07:00
David MannandClaude Opus 5.5 6fba13cd1d History: Delete current state and New Document from the panel footer work (#1117) (#2039)
The History panel footer's trash and file-plus buttons discarded their
clicks. They now dispatch two new engine commands:

- history.deleteState {"state"?}: drops the state and every later one,
  as Photoshop's linear history does; the document returns to the state
  before and the deleted steps can't be redone. The oldest state can't
  be deleted (button disabled).
- history.newDocument {"state"?,"name"?}: a new document holding the
  document at that state, named after the state, with a fresh history.

Snapshots aren't implemented, so Create new snapshot is shown greyed.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:56:19 -07:00
David MannandClaude Opus 5.5 7567347b29 Dialogs: the first value is focused and selected; values persist (#1757) (#2040)
Like Photoshop, a schema value dialog (Select > Modify, Filter, Image >
Rotate Canvas > Arbitrary, ...) opens with its first number field focused
and its text selected, so typing replaces it and Enter applies it. It
also reopens with the values it was last applied with, kept across
restarts in prefs.dialogs and validated against the command's spec on
the way back in (ranges clamped, unknown choices dropped). Cancel and
failed runs remember nothing. Dialogs that show document state (Global
Light, Color Table, Proof Setup, Assign/Convert to Profile) and Define
Pattern keep their current behaviour.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:56:14 -07:00
David MannandClaude Opus 5.5 51598bb061 Text: fall back to Thai-capable fonts for missing glyphs (#1909) (#2030)
Thai typed in a font without Thai glyphs (any newly chosen font, which
drops the PSD PostScript name that found the original Thai face) fell
through the fallback stack to .notdef: none of the candidate families
cover Thai on Windows (Segoe UI), and Arial Unicode MS is rarely
installed. Add the platform Thai families (Leelawadee UI / Leelawadee /
Tahoma, Thonburi / Sukhumvit Set, Noto Sans Thai) to the candidates,
ahead of the CJK and last-resort fonts, as Photoshop's missing glyph
protection substitutes an installed font. parley picks the fallback per
cluster, so base consonants and their marks stay in one font.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:56:09 -07:00
Dan HiornsandClaude Opus 5.5 9966b534cd About window: fixed table headers, aligned columns, fitted tabs (#2000)
Help > About > Contributors and Models: the table header stays put while rows scroll, columns are measured once so sorting no longer shifts them, counts are right-aligned and every column fits the 700 pt window. The grab bag drops its stray separators and spaces rows evenly, and Models shows <1% for a model with commits and is as tall as its rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:36:44 -04:00
SimonandClaude Opus 5.5 72cabac616 Layers panel: the scroll wheel over the blend-mode menu steps through the modes (#1961)
The wheel over the Layers panel's blend-mode dropdown (closed or open) steps to the next or previous mode, one per notch and one undo step each, stopping at the ends, as in Photoshop. Fractional wheel deltas add up to whole notches, and the wheel there no longer scrolls the panel (#1747).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:34:01 -04:00
optimalbyteeandClaude Opus 5.5 cc0441f9ae Save As: the document takes the saved file's name (#2018)
A successful Save As (UI, automation or headless) renames the document to the written file, so its tab and title stop saying Untitled. The name is file identity, not a history step: undo and redo keep it. Failed writes, Save a Copy and exports leave the name, path and dirty state alone, and a deferred save no longer steals the selected tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:30:52 -04:00
RaghavandClaude Opus 5.5 45b22e7403 GPU: gradients with coincident stops fall back to the CPU compositor (#2005)
The 4096-entry ramp LUT interpolated across a hard stop, showing grey where the CPU export is
white. Gradient fills, Gradient Maps and gradient effects with coincident colour or opacity stops
now return Unsupported so the canvas uses the CPU oracle. Fixes #974.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:30:42 -04:00
Elvis33LEandClaude Opus 5.5 08ca445782 DNG: deflate compression 8 with the TIFF predictors (#1971)
Integer DNGs with zlib-compressed strips or tiles (compression 8) now decode, with TIFF predictor 1
and 2 (wrapping at the sample's container). Output is capped at the tile size; floating-point
predictor 3 and lossy DNG keep their unsupported errors. flate2 was already a workspace dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:29:05 -04:00
shmuelbiller1andClaude Opus 5.5 f1413ea12e Outer Glow stays behind a see-through fill (#1964)
Outer Glow now gets the same fill-dependent knockout as Drop Shadow, on CPU and GPU, so a Fill 0%
shape with a glow no longer paints the glow across its interior. Fixes #1953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:26:38 -04:00
Brandon ThomasandClaude Opus 5.5 11a2b72455 Prefs audit: Export Clipboard reads as wired again (#2069)
The prefs_usage scan treats everything after a file's first #[cfg(test)] as test code; the channel-tint tests from #1986 sat above export_os_clipboard and hid its read. The test module moves to the end of ui-egui's lib.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:23:11 -04:00
UnemarsandClaude Opus 5.5 9da40a1c7a Quick Selection: Photoshop's behaviour, measured and fitted (#1966)
Quick Selection is now one min cut around the brush footprint (Boykov & Jolly 2001; coarse to fine for
large windows, Lombaert et al. 2005), with costs fitted to Photoshop 25.4's masks measured as a black box
on 105 synthetic and public-domain cases (mean IoU 0.94 synthetic, 0.83 paintings), at Photoshop's
512x512 working-resolution rule. Replaces the colour-model flood, which selected far too much.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:21:27 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 bdbd43e63f Color Lookup: Load 3D LUT opens the native file picker (#1947)
The Color Lookup properties' Load 3D LUT… entry opens the platform file picker (filtered to .cube, .3dl and .look, on desktop and web) and embeds the chosen LUT into that adjustment layer, replacing the typed-path field. A cancelled or invalid pick leaves the layer unchanged (#1749).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:20:13 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 a4fbb1245d Levels applies each channel before the master, like Curves (#1929)
Levels and Curves composed their master and per-channel records in opposite orders (#975). The
Photoshop-authored corpus decides it: channel first, then master. Levels now does that, bringing
psd-tools levels_rgb.psd from 0.447 to 0.012 max error; Curves (curves_rgb.psd passes) is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:19:29 -04:00
Gert Pellin - GPEandClaude Opus 5.5 24435de7bb Canvas: say when the desktop takes an Alt-click (#2003)
Desktops that move windows with Alt+drag (Cinnamon, MATE, Xfce, older KDE) grab Alt+left presses, so
the Alt Eyedropper and Clone Stamp source never got the click. The canvas now spots the grab and says in
the status bar what happened and how to get Alt-click back. Fixes #1658, #1659.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:14:38 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 748821864e Panels: Close and Close Tab Group on a dock tab's context menu (#1924)
Right-clicking a dock tab offers Close (hides just that tab; the last one hides its group) and Close Tab Group, as in Photoshop. Hidden tabs are kept by name in the dock layout, so they survive theme switches and workspace saves, and the Window menu brings a tab back (#1753).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:14:10 -04:00
Max MurphyandClaude Opus 5.5 27263bb16d Canvas: zoom is physical at any display scale (#1978)
View zoom now means device pixels per document pixel, so 100% shows one image pixel per physical
display pixel on a scaled (125%, 200%) display, as in Photoshop. Fit, Actual Pixels, pan/zoom anchoring,
scrollbars, the navigator, the pixel grid and every hit tolerance go through canvas_ppp / point_zoom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:10:49 -04:00
David MannandClaude Opus 5.5 32c918aaee Crop tool: overlay options (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O (#2026)
The Crop options bar's overlay button opens Photoshop's overlay menu: six overlays, Auto/Always/Never
show modes, and O / Shift+O to cycle the overlay and its orientation. The default is Rule of Thirds with
Auto Show Overlay, as in Photoshop. ui.set takes cropOverlay, cropOverlayShow and cropOverlayOrientation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:06:27 -04:00
Krzysztof CieślikandClaude Opus 5.5 0bf7a1e15f Preferences: wire Large Tabs, Export Clipboard and Show Channels in Color (#1986)
Large Tabs makes the document tab strip taller (34 px) with larger type; turning off Export Clipboard stops copies being mirrored to the OS clipboard; Show Channels in Color tints the Channels panel thumbnails (RGB from black to the primary, CMYK from white to the ink). Settings that do nothing: 48 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:03:44 -04:00
Brandon ThomasandClaude Opus 5.5 9b0c6c4d74 PSD import: a pattern-filled shape opens as a pattern fill layer (#2059)
Since #1917 the exporter writes pattern fill pixels; a vector-masked pattern fill then reopened as a
shape layer covering the canvas, failing the corpus round-trip floor (adjustment-fillers.psd).
Pattern-filled plain shapes now import as pattern fill layers with their vector mask.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:59:48 -04:00
David MannandClaude Opus 5.5 b8fe7dd5ee Crop tool: arrow keys nudge, X swaps orientation, W × H readout (#1958)
With a pending crop frame, the arrows move it 1 px (Shift 10 px) instead of the selection or layer, X
swaps it between portrait and landscape about its centre (with the options-bar ratio), and the frame's
W x H shows beside the pointer while drawing or resizing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:59:16 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 04b9db61e9 Filter dialogs remember their last applied settings (#1944)
A schema-built filter dialog reopens with the values it was last applied with, as in Photoshop. Only schema-valid numbers, choices and booleans are kept (no paths, document indices or free text), and a failed or cancelled dialog saves nothing (#1757).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:57:03 -04:00
David MannandClaude Opus 5.5 d0119dedbb Crop tool: a pending crop stays with its document (#1957)
A pending crop frame belongs to the document it was drawn on: New, Open, a tab switch or closing it
cancels the frame, so it never shows on or crops another document. While a crop is pending, the menus
Photoshop greys are greyed (checked item by item in Photoshop 27.11), and Image > Crop commits it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:54:01 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 a82408e3e2 Actions: Play runs the first action when none is selected (#1990)
The Actions panel's Play button falls back to the first action when no row (or a stale row) is selected, as Photoshop does, and a failed playback request now shows in the status bar instead of being dropped (#1951).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:53:33 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 25e349b048 Dialogs: the whole title band drags the window (#1945)
A dialog's drag handle spans the full title row plus a few pixels of padding around the title, so grabs slightly beside, above or below the title text move the window (#1921). The title keeps its height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:50:27 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 765495e53b Free Transform: rotation cursor and live angle readout (#1948)
Just outside a corner the pointer becomes a curved rotate arrow, and while rotating the angle is shown
next to it. Distort and Warp keep their cursors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:50:07 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 c02ffa9755 Spot Healing: Proximity Match samples the original pixels (#1968)
Proximity Match copied its translated patch within the buffer it was writing, so overlapping offsets
re-copied fresh output and left streaks. It now reads from the pre-heal pixels, with checked bounds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:46:58 -04:00
Reynold HuandClaude Opus 5.5 60bce1894b File Handling: wire Lowercase Extension through Save As (#1941)
Preferences > File Handling > Lowercase Extension is live: with it on (the default) a save path's extension is lowercased (CAT.PSD saves as CAT.psd); off keeps the user's spelling. Settings that do nothing: 51 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:45:20 -04:00
LloydNicholsonandClaude Opus 5.5 be446f89d3 Transparent PNG, TIFF and other flat images open as Layer 0, not Background (#1913)
A flat image with transparency (alpha channel or tRNS) opens as a normal, unlocked Layer 0, as in
Photoshop; opaque images still open as the locked Background. A lone Layer 0 saves back to PNG natively.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:43:14 -04:00
Reynold HuandClaude Opus 5.5 429b9fc6a5 Type: wire the Use Escape to Commit preference (#1927)
Preferences > Type > Use Escape to Commit is live: on (the default) Escape commits the type session as before; off, Escape cancels it like the Cancel button, as in Photoshop. Settings that do nothing: 52 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:42:05 -04:00
Reynold HuandClaude Opus 5.5 23c56c659e Type: wire Fill New Type Layers with Placeholder Text (#1935)
Preferences > Type > Fill new type layers with placeholder text is live: with it off, a Type tool click starts an empty layer (removed again if committed without typing). Settings that do nothing: 53 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:38:38 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 3eef2c6bb4 Type: imported PSD tabs render as whitespace, not a box (#1942)
A literal tab in PSD text shaped as .notdef. Layout now shapes it as a space, keeping byte, style
and caret offsets intact. Tab stops are still to come. Part of #1881.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:34:38 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 cc4a13b58d Paint: keep stylus pressure on quick taps (#1967)
A release that egui reports as both a drag stop and a click no longer replays the click over a live
stroke (which painted a second full-pressure dab), and the click fallback now uses the pen's pressure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:34:21 -04:00
MarekSpodBiedryandClaude Opus 5.5 f885991184 Menus: a submenu with every item disabled is disabled too (#1984)
A submenu header is enabled only while some item under it can run, so File > Export, Automate, Scripts and Import grey out while a crop is pending, as in Photoshop (#1976).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:33:39 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 54d7b07acf Convert to Smart Object keeps a pattern fill's pattern (#1937)
The embedded document built by layer_to_smart now carries the document's patterns, so pattern fills
and pattern effects keep rendering inside the smart object and after Edit Contents. Fixes #1867.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:33:20 -04:00
Brandon ThomasandClaude Opus 5.5 ee3ebfa748 Smart contents test: match the refused path by file name (Windows CI red since #1882) (#2042)
The refusal message Debug-quotes the path, which doubles backslashes on Windows, so
contains(path) never matched there. Matching the unique file name keeps the check.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 15:32:39 -04:00
kayrockscreenprintingandClaude Opus 5.5 8b29d6c2cd PSD import: warn when an adjustment layer can't be read (#1925)
An adjustment layer kept verbatim as Unsupported rendered as nothing without telling anyone. Import
now warns, naming the layer, the adjustment and (for Color Lookup) the reason. Part of #1763.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:29:29 -04:00
Reynold HuandClaude Opus 5.5 b9f469546f Print Size honors the Screen Resolution preference (#1923)
View > Print Size divides the document's ppi into Preferences > Units & Rulers > Screen Resolution (72 ppi by default) instead of a hard-coded 72, and the setting is no longer hidden. Settings that do nothing: 54 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:29:25 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 d6802c62c0 Proximity Match: bound search radii to the image (#1969)
best_offset clamps the search radius and ring to the image extent and rejects zero or negative radii,
so hostile values no longer overflow i32 loops or search for billions of iterations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:29:16 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 3cae29abfa Grid: percentage spacing and snapping use each canvas axis (#1914)
A percentage grid now spaces vertical lines by the document width and horizontal lines by its height,
so rectangular canvases get proportional cells, and grid snapping uses the same per-axis steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:27:30 -04:00
kayrockscreenprintingandClaude Opus 5.5 2819f37033 PSD export: pattern fill layers keep their pattern in CMYK and Lab (#1917)
The PSD (and layered TIFF) exporter rendered fill layers with no patterns, so pattern fills were
written as transparent pixels; in CMYK and Lab those tiles survive and the reopened layer drew empty.
Pattern fills now render from the document's patterns. Round-trip test over 4 modes x 3 depths.
Fixes #1907.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:26:56 -04:00
kayrockscreenprintingandClaude Opus 5.5 19256a29f5 Fill layers keep their colours in the document's mode (#1915)
New Fill Layer (Solid Color, Gradient), the Gradient tool's live fill layers, gradient fill edits and Gradients panel presets store their colours in the document's mode (Color::in_mode / Fill::in_mode), so a fill layer in a Grayscale, CMYK or Lab document composites like the same colour painted with Edit › Fill, as Image › Mode already converted them. Fixes #1905; tests at 8, 16 and 32 bits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:23:07 -04:00
khhjoeandClaude Opus 5.5 01bdf39f1b gpu: write F32_MAX exactly so the compositor shader builds on WebGPU (#1982)
The decimal 3.40282347e38 is just above f32::MAX; naga rounds it but browsers' Tint rejects it, so
the web app had no GPU compositor. Now a hex literal, with a test that every decimal float literal in
the shader fits f32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:22:30 -04:00
3c5dc002d2 macOS: unsaved-changes prompt shows plain button labels (#1936)
On macOS the save prompt reads Save / Don't Save / Cancel without printed key letters, like the native alert; D, C, S, Return and Escape still answer it. Windows and Linux keep their mnemonics. Shortcut test from #1963 (arthurdeka).

Co-Authored-By: arthurdeka <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:22:26 -04:00
jason4shortandClaude Opus 5.5 b808780666 Paste: drop a stale image once the OS clipboard has moved on (#1912)
When the OS clipboard no longer holds the image we mirrored or imported (text or a file was copied
since), the session clipboard drops it, so Paste reports nothing to paste instead of a stale image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:20:38 -04:00
shmuelbiller1andClaude Opus 5.5 3e9359071c Native format: deflated ZIP entries read with an unbounded budget, no huge preallocation (#1956)
Reading a deflated .pcraft entry with max = usize::MAX no longer overflows the take() limit, and a
forged size in the ZIP header no longer reserves gigabytes up front (preallocation is now capped).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:19:43 -04:00
DmytroandClaude Opus 5.5 74eacf9860 i18n(uk): refine eyedropper and colour picker wording (#1974)
Ukrainian: clearer sampling terms (Sample, Sample Size), N × N averages, Color to Alpha and the web/print gamut warnings, plus the missing Transparency/Opacity Threshold rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:18:16 -04:00
ROSSA AIandClaude Opus 5.5 483712655f codecs: PNG export gives the same bytes on any core count (#1932)
The parallel PNG encoder now cuts deflate bands by a fixed byte budget instead of by thread count,
so the same image and options give identical files on any machine. Test covers 1, 2, 7 and 32 threads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:17:06 -04:00
shmuelbiller1andClaude Opus 5.5 decc7715b9 PSD: Optical kerning survives a round trip (#1517)
PSD export regenerates the document Txt2 block with one text object per TextIndex carrying each run's auto-kern mode (manual, metrics, optical), which EngineData's AutoKerning can't distinguish, so Optical no longer reopens as Metrics (#1348). A kept object with unchanged text keeps the file's extras (glyph pen positions); duplicate or out-of-range TextIndex values are renumbered and capped, so a crafted file can't size the save. io corpus tests stay at their floors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 15:09:10 -04:00
optimalbyteeandClaude Opus 5.5 6ae2a8498c Optimize Radial Blur sampling without changing output (#1499)
Radial Blur prepares its sample transforms once per filter (blur::radial::Plan) and shares them across output tiles, reading each bilinear tap once with channels kept together. Output is identical to the previous sampler at Draft, Good and Best, across colour models and depths (tests compare against the unchanged reference path); a failed table allocation falls back to that sampler. Measured on 24 MP RGBA8, Good, amount 1: Spin 66.1 -> 16.4 s, Zoom 19.2 -> 5.4 s (bench_radial example, docs/radial-blur-performance.md).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:41:52 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 d402631411 File > Export > Color Lookup Tables: export selected adjustment layers (#1493)
file.export.colorLookupTables takes scope all|selected or an explicit layers list and bakes only those top-level, visible adjustment layers, always in document stacking order; the dialog defaults to the selection when it is all exportable adjustments. Invalid selections and sizes are errors rather than silent fallbacks; LUT sizes outside 2-129 (the reader's limit) are now refused instead of clamped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:36:51 -04:00
optimalbyteeandClaude Opus 5.5 d651be1393 Speed up Mosaic with portable row reads (#1524)
Mosaic sums each four-channel cell by reading whole rows once with channel sums kept in registers, in the original addition order, so the output is bit-identical (including HDR values, -0.0, zero alpha and partial source bounds); 24-38% faster on three of the four measured cell sizes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:26:13 -04:00
Harrison O'Connor-HooverandClaude Opus 5.5 9e36825d2d Allow automation to edit embedded Smart Object contents (#1882)
Fixes #1833. Control and MCP may run Edit Contents, Convert to Layers and Save Contents on smart objects whose source is in memory (embedded, or a Photoshop UUID backed by embedded liFD data). Before any disk read the engine re-checks the source's stored path through the session's authorize gate, including nested sources re-rendered while unpacking, so external linked files stay refused; explicit layer targets work with another layer selected, and interactive linked-file access is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:24:58 -04:00
Minseo LeeandClaude Opus 5.5 9f23d65262 Type tool: list fonts' real style names, including numeric subfamilies (#1844)
The style menu now shows each face's actual OpenType subfamily (name 17, else 2) instead of a name guessed from its weight, so fonts whose faces share one weight (Yoon's numeric 20/30/40/50) list and render distinct faces: FontDb::select_named_face registers a runtime-only alias for the exact face, while the document keeps its public family and PostScript name. Variable faces still list the standard weights their wght axis covers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:12:49 -04:00
Harrison O'Connor-HooverandClaude Opus 5.5 cd559b387a Fix browser Save for Web downloads and stale export dimensions (#1857)
Save for Web gets a platform-writer entry point (web_cmds::save_for_web_with_writer) so the browser build downloads every image, spacer and HTML file side by side; the export is authorized before any picker opens, a failed write stops it, and only a complete export remembers its settings. Each document now opens the dialog at its own size instead of the last export's width, height and percent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 14:03:58 -04:00
Kostis PapadakisandClaude Opus 5.5 c0541baf6a Raw: decode uncompressed Fujifilm RAF, Bayer and X-Trans (#1872)
RAF's header, metadata records and Fuji IFD give size, packing (16-bit, 12-bit LSB-first,
14-bit in 32-bit words), layout, black, WB, crop and exposure bias; X-Trans and other
periodic non-Bayer CFAs go through a new edge-directed demosaic. Compressed and early
SuperCCD RAF stay unsupported (preview fallback). Tests use synthetic files only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:59:07 -04:00
kayrockscreenprintingandClaude Opus 5.5 82148e77f5 Filter previews compute off the UI thread; a stale one is cancelled once the slider settles (#1899)
In the desktop app every filter's live preview now runs on the preview worker (not just Indexed Color), so a slow one like Gaussian Blur at 1000 px no longer freezes the window. The worker hands the filter a JobCtx (Session::set_inline_job_ctx) and cancels a preview for older values once the dialog's values have been unchanged for 150 ms; during a drag the running preview is left to finish.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:52:24 -04:00
Gotya-OFFCIALandClaude Opus 5.5 4716d33372 Raw: cRAW black level without a tag; measured image areas (D800E, ILCE-7) (#1902)
Sony cRAW files with no BlackLevel tag take the 512 their tone curve gives the black
code instead of 0 (the ILCE-7 no longer develops washed out; the RX0 corpus oracle moves
to 512 too). cameras.rs gains measured image areas without a full profile: the Nikon
D800E and Sony ILCE-7 crop like the camera's JPEG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:51:43 -04:00
sunyd168andClaude Opus 5.5 0af072e82c SVG import: refuse deeply nested markup instead of overflowing the stack (#1901)
A pre-scan rejects element nesting past 512 levels before the XML parser recurses
(a 70 KB file of <g> aborted the process). .svgz is inflated by the importer with a
128 MB cap, so it can be scanned too and gzip bombs are refused; flate2 moves from a
dev-dependency to a dependency (already in the tree through usvg).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:47:17 -04:00
Shayan HorbankandClaude Opus 5.5 a342ab8820 Add centered and transformable painting symmetry axes (#1821)
Photoshop's Vertical, Horizontal, Dual Axis and Diagonal paint symmetry presets beside the
path-based symmetry: axes start at the document centre, Transform Symmetry moves and rotates
them (Shift snaps to 15°, Enter applies, Esc cancels), and all copies merge into one coverage
pass so overlaps don't build opacity. New paint.setSymmetry command and preset icons. Fixes #1648.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:44:24 -04:00
Reynold HuandClaude Opus 5.5 3ae44e3911 Document tabs: Reveal in Finder and drag to reorder (#1906)
The document tab's context menu gains Reveal in Finder (a new file.revealInFinder command that reuses the smart-object reveal helper and targets the clicked tab's document), and dragging a tab sideways reorders the open documents through document.move, with a drop line, in both tab strips. Flips UI-217-6 to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:38:40 -04:00
kayrockscreenprintingandClaude Opus 5.5 0299162db2 MCP: tool schemas without $ref, $defs or boolean schemas (#1845)
Every tool's input schema is rewritten once at startup: $refs inlined (use-site
descriptions win), $defs dropped, boolean schemas turned into {}, with a depth cap
for recursive definitions. ui_pointer events get a concrete schema. Strict providers
no longer reject the whole tool list. Fixes #1782.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:29:50 -04:00
kayrockscreenprintingandClaude Opus 5.5 63f9ca9d74 Control: shell dialogs are listed and driven as "shell" (#1811)
ui.inspect lists the open shell dialog (Workspace, Pixel Aspect Ratio, Show Extras,
32-bit Preview) with the id "shell", and ui.dialog.set/confirm/cancel accept it;
confirm goes through the automation authorizer and the dialog's shared OK. Fixes #1004.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:27:46 -04:00
David MannandClaude Opus 5.5 147ee7efa0 Crop tool: rotate the crop frame by dragging outside it (#1879)
Outside the crop frame the pointer is Photoshop's curved turn cursor and a drag turns the frame
about its centre (Shift: 15 degree steps) with an angle readout; handles resize along the turned
axes and a double-click inside commits. image.crop takes an optional angle: Image Rotation >
Arbitrary so the frame is upright, then the crop, in one undo step. Fixes #1792.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:27:27 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 0f2900da34 Edit > Stroke: a full options dialog with Fill's blending controls (#1477)
Edit > Stroke… from the menus opens a Photoshop-style dialog (Width, Color, Location, Blending:
Mode, Opacity, Preserve Transparency) remembering its last values; called with params it runs
directly. edit.stroke gains mode and preserveTransparency, blending through Fill's compositing
(blend_color_mask), and validates them. The Blending section is shared with Fill
(dialog_blend_ui.rs). Fixes #1463.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:21:21 -04:00
Reynold HuandClaude Opus 5.5 4ed4faee51 Command palette: recently run commands on an empty query (#1824)
menus::invoke records each successfully run command (deduplicated, newest first, last 8) and the palette lists them when its query is empty, falling back to the full list in a fresh session. Flips UI-217-7 to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:21:17 -04:00
Jason XiangandClaude Opus 5.5 5aaf0e4aae file.new rejects invalid parameters instead of falling back (#1878)
Unknown keys, wrong types, out-of-range sizes or resolution and unsupported
mode/depth/background now fail naming the parameter, before a floating selection is
committed or a document added; omitted fields keep their defaults. The renderer tests
now pass numeric depths (strings had silently fallen back to 8-bit). Part of #507.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:20:34 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 ffb62ec975 Pen: Ctrl/Cmd+Z undoes in-progress points (#1478)
While a Pen path is being drawn, Undo (its customisable shortcut or Edit > Undo) and Backspace/
Delete remove the last uncommitted anchor instead of undoing an earlier document step, as in
Photoshop; once the draft is empty, Undo works on the document again. Fixes #1466.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:18:00 -04:00
kayrockscreenprintingandClaude Opus 5.5 fdfa57f456 New Document starts from the last document's settings (#1869)
Fixes #1810. File > New remembers the size, resolution, mode, depth, background and display units its OK used (in the preferences' dialogs map, so they survive a restart) and starts from them next time, as Photoshop does; a clipboard image still sets the size, and corrupt remembered values are ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:15:35 -04:00
AntonandClaude Opus 5.5 66b022cddd Web: fonts served next to the app, loaded on demand (#1875)
A host lists fonts in fonts/manifest.txt beside index.html (craft-fonts' manifest format, paths
kept inside the site); the web shell lists those families in the font menus and fetches a family
when it is picked or a layout needs it, then re-renders the affected type layers without a history
step. No manifest changes nothing; package.sh copies one in with PHOTOCRAFT_WEB_FONTS_DIR. The
style menu also lists the weights of a variable font's wght axis.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:14:47 -04:00
kayrockscreenprintingandClaude Opus 5.5 54ff207004 Image › Adjustments: apply per tile in parallel (#1800)
adjust_surface now takes the layer's tiles and adjusts each one on its own rayon task, so a large layer needs one tile's buffers per thread instead of four full-layer copies. The result is bit-identical to the single pass for every adjustment kind, colour model and depth, with and without a soft selection (tiled_adjustment_matches_one_pass_over_the_content). Adds an invert scenario to large_image_bench.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:06:46 -04:00
JeremyandClaude Opus 5.5 7a5b0b5d45 Canvas: the CPU canvas pixel grid skips empty pixels, like the GPU canvas (#1891)
The CPU path drew the pixel grid over the whole view, including transparent areas, while the
GPU shader only lightens or darkens pixels with content. pixel_grid.rs applies the shader's
rule to the composite of the area in view, as merged segments cached per revision and padded
region (capped at 60k segments), sharing the zoom and strength constants with the shader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:04:27 -04:00
David MannandClaude Opus 5.5 45c5001dfd GPU canvas: convert changed layer tiles once each, in parallel (#1847)
Changed tiles are grouped by shared Arc and converted once each in parallel batches; a tile
shared by several coordinates (a solid fill, a cleared area, a non-zero default) is uploaded
once to a scratch tile and copied on the GPU, and new pages take the same path. 16-bit RGB and
gray tiles convert to RGBA16F through a lookup table. Select All + Fill on a 50 MP 16-bit page
refreshes in 15-19 ms instead of about 1.4 s. Fixes #1774.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:04:21 -04:00
AntonandClaude Opus 5.5 96cdff7454 Free Transform: edge handles scale proportionally, Shift stretches one axis (#1873)
As in Photoshop CC 2019 and later, dragging an edge handle now scales both axes by the same
factor about the middle of the box (Alt: about the reference point), and Shift stretches the
one axis. Use Legacy Free Transform restores the old behaviour for edges as well as corners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:04:14 -04:00
AlonandClaude Opus 5.5 9ba843290e Ctrl+T on a selected path transforms the path (#1871)
With Path Selection or Direct Selection and a path, Edit > Free Transform (and the Transform
items) frame the path's anchors and handles and commit one path.transform step instead of
moving pixels; it works on the Background too. Distort and Perspective, which an affine path
transform can't follow, report an error. Fixes #1770.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:04:08 -04:00
Jason XiangandClaude Opus 5.5 a4a2583280 Layer comps: an edited layout becomes the Last Document State (#1862)
Applying a comp after ordinary edits now backs up the edited layout instead of keeping
a stale one; cycling or reapplying unedited comps keeps the original backup. The check
runs only on comp application and respects each comp's capture flags. Fixes #919.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:04:07 -04:00
David MannandClaude Opus 5.5 7c2bd89e6a Canvas: edits that change no pixels (a lasso selection) don't recomposite (#1853)
Session::edit now reports empty damage when the step leaves every layer's pixels and the
document's compositing fields untouched (same_pixels: a bounded walk over the layer tree
comparing tiles by pointer), so a selection change no longer recomposites the whole canvas.
Adds a lasso_bench example. Fixes #1773.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:03:25 -04:00
rafi612andClaude Opus 5.5 f39abb70b9 System title bar: hide the in-app mark and title, show the file in the OS title (#1903)
With Preferences › Interface › System Title Bar (Windows/Linux) the OS draws its own icon and title,
so the in-app bar drops the brand mark and centred title; the OS window title follows the active
file ("name — PhotoCraft"), sent only when it changes. macOS and the web build are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 13:02:53 -04:00
kayrockscreenprintingandClaude Opus 5.5 83b0bcedbe Schema dialogs ignore notes after a spec; Selective Color's OK works again (#1886)
parse_spec reads only the spec's leading {…} object, so notes and → {…} results after
it no longer become junk fields. Selective Color's per-range arrays stop showing as a
number field, and its OK applies again (it failed since #1790 tightened the params).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:59:25 -04:00
David MannandClaude Opus 5.5 d7078f3623 Layers: a new empty layer no longer recomposites every layer (#1846)
step_damage pairs layers by id and steps over a layer only one side has when it draws nothing
(an empty pixel layer or group in Normal/Pass Through, no masks, effects or Advanced Blending,
not a clipping base), so New Layer, New Group and their undo/redo report no damage. Adds the
P51 perf scenario (new layer in the 150-layer document). Fixes #1771.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:59:12 -04:00
kayrockscreenprintingandClaude Opus 5.5 21649f6030 Filter dialogs: OK keeps the preview until the filter lands (#1825)
A filter that runs as a background job no longer flashes the unfiltered image after OK: the dialog's preview is marked committing and the canvas keeps showing it (canvas::committed_filter_preview) until the job lands, fails or is cancelled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:59:10 -04:00
Jason XiangandClaude Opus 5.5 175603a644 New adjustment and fill layers get a white mask (#1858)
As in Photoshop, a new adjustment or fill layer with no selection or active path gets
an enabled, linked reveal-all mask (sparse, no canvas-sized allocation), ready to paint,
in the layer's single undo step. Fixes #1768.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:48:26 -04:00
kayrockscreenprintingandClaude Opus 5.5 71e89906ee Dialogs: tall filter dialogs scroll; a moved dialog reopens in place (#1822)
Fixes #1829. A schema-built filter body sits in a scroll area capped to the window, so Flame's OK and Cancel stay on screen in a small window; a dialog the user drags is remembered per command (or per kind) in egui's persisted memory and reopens there, as Photoshop does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:48:07 -04:00
Neil VerschooreandClaude Opus 5.5 e7a83ca342 Add Dutch (nl) UI translation (#1877)
A complete Dutch catalog (nl.tsv) registered with the complete-menu languages (nl-NL and nl-BE
resolve to it) and in the coverage test, with the strings added on main since the PR opened and
Dutch bit-depth labels (8-bits, 16-bits).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:41:23 -04:00
kayrockscreenprintingandClaude Opus 5.5 99e8ac2d2a Fill: pick the Custom Pattern from its swatches (#1861)
Edit > Fill… with Contents: Pattern shows a swatch of the current pattern; a click opens a grid of every library pattern (current one outlined, names in tooltips and accessibility labels), as in Photoshop, instead of a dropdown of names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:31:53 -04:00
Dan HiornsandClaude Opus 5.5 13d50312bd Brush preset grid: centre the columns and indent every row (#1883)
The preset grid (Brush Preset picker and Brushes tab) wrapped with horizontal_wrapped, which
indented only the first row and left the spare width on the right. Rows are now laid out as
whole columns with the same centred margin (grid_columns, guarded against NaN/zero sizes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:29:07 -04:00
David MannandClaude Opus 5.5 dee737313a Masks: \ toggles the layer mask's rubylith overlay (#1809)
New command layer.toggleMaskOverlay, bound to \ as in Photoshop: shows the active layer's
mask as a 50% red rubylith over the composite and hides it again (view state, not a history
step; the mask stays the paint target). Disabled without a layer mask; a focused text field
keeps the key. Fixes #1765.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:28:55 -04:00
Jason XiangandClaude Opus 5.5 c645f951b0 Panels: clicking a tab of a collapsed panel expands it (#1868)
A single click on a tab of a collapsed panel group (or a pick from its overflow menu) now
expands the group and shows that tab, as in Photoshop; double-click still collapses. The tab
strip reports clicks through StripOut/CardResponse. Tested in both themes, with the
workspace locked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:28:49 -04:00
Jason XiangandClaude Opus 5.5 941b84a748 Save As and export dialogs start beside the document (#1866)
Export dialogs suggested a bare file name, so the save dialog opened wherever the OS chose.
pick_save now joins a bare suggestion onto the active document's folder (an explicit directory
or an untitled document is left as is), and the desktop dialog starts in the suggestion's folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:28:42 -04:00
kayrockscreenprintingandClaude Opus 5.5 d4884d33fa Rasterize Vector Mask keeps a feathered edge (#1807)
Layer > Rasterize > Vector Mask multiplied the hard vector mask into the pixel mask, dropping
the vector mask's feather (and the pixel mask's feather or density). When any of those shape
the edge, the pixel mask now becomes exactly the combined mask the compositor showed, with
feather and density baked in. Tested at 8, 16 and 32 bits against the composite. Fixes #992.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:28:36 -04:00
AnandandClaude Opus 5.5 fb23c18581 Placed layer no longer disappears when editing Smart Object contents (#1831)
Opening a just-placed Smart Object's contents switched documents while the Place transform was
pending, and end_if_left cancelled it, deleting the placed layer from the parent. When the
active document is that layer's linked child, the transform session now just ends and the
layer stays. Fixes #1797.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:28:14 -04:00
Stevan MarjanovicandClaude Opus 5.5 b81808f6bc Double-click the Hand tool to fit on screen (#1796)
Double-clicking the Hand tool's toolbar button selects it and runs View > Fit on Screen, as in
Photoshop; a single click only picks the tool, and with no document open the double-click is
harmless. Also credits the author in contributors/people.toml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:27:49 -04:00
David MannandClaude Opus 5.5 a42f7f0004 Crop tool: Enter crops to the selection's frame (#1806)
With a selection, the Crop tool's untouched default frame frames the selection's bounds, but
Enter on it did nothing (it was always treated as the no-op whole-canvas frame). It now only
skips the commit when the frame is the whole canvas. Tested at 8 and 16 bits. Fixes #1789.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:27:22 -04:00
Max MurphyandClaude Opus 5.5 dcf8ceef85 Rulers: the horizontal ruler keeps its ticks under View > Flip Horizontal (#1835)
With the view flipped, to_doc returns the ruler's ends reversed, so the tick loop ran zero
times. ruler_range normalises the visible range to (min, max) for both rulers. Fixes #1814.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:27:12 -04:00
MarekSpodBiedryandClaude Opus 5.5 d4634bb30f Layers panel: thumbnails of tall documents fill the thumbnail height (#1805)
The layer thumbnail drew the checkerboard over the whole square cell while the texture is the
document letterboxed into it, so tall (or wide) documents showed checker bars at the sides.
The checker and image are now drawn only over the document-shaped part (channels_panel::fit_thumb).
Fixes #1762.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:27:02 -04:00
Max MurphyandClaude Opus 5.5 1bccee1fda Desktop app: exit with status 2 when --control setup fails (#1834)
A bad control token, token plus token file, or a missing automation root printed an
error and exited 0, so a launcher believed the control server was up. Now exits 2,
like a bad --control port; subprocess tests cover the four paths. Fixes #1816.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:26:02 -04:00
kayrockscreenprintingandClaude Opus 5.5 749109e6fb Define Pattern asks for the pattern's name (#1850)
Edit > Define Pattern… now opens a Pattern Name dialog, prefilled with the name the pattern would get anyway (pattern_cmds::default_name, now shared with the command so the two can't drift), as Photoshop does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:22:50 -04:00
DmytroandClaude Opus 5.5 fee2240d59 Add Ukrainian UI translation (#1799)
A complete Ukrainian catalog (uk.tsv) registered with the complete-menu languages, with
one/few/many plurals (teens use many), uk-UA locale resolution, and the coverage test entry;
plus the strings added on main since the PR opened.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:18:31 -04:00
kayrockscreenprintingandClaude Opus 5.5 86a629939a RAW: check the orientation range before narrowing (#1839)
The five RAW readers cast the u32 Orientation to u16 before the 1..=8 check, so a LONG
65538 became 2 and rotated the image. A shared tiff::orientation checks the range on
the u32 first. Fixes #1817.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:14:28 -04:00
kayrockscreenprintingandClaude Opus 5.5 5012e4c32f Image Size reports the size it applied (#1836)
image.imageSize returns the document's size after the command instead of the requested
one, so Resample off (which changes the resolution only) no longer reports a size it
ignored. Tested at 8, 16 and 32 bits. Fixes #1815.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:13:12 -04:00
Max MurphyandClaude Opus 5.5 0050f29a85 Native format: reject a leading '+' in hex pairs (#1832)
u8::from_str_radix accepts a leading '+', so "+5" decoded as 0x05; unhex now requires
two ASCII hex digits per pair and fails as corrupt otherwise. Fixes #1818.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:03:52 -04:00
Reynold HuandClaude Opus 5.5 d4a5d974ac File > Open starts in the last-used folder (#1889)
Open dialogs (File > Open and the single-file pickers for Place, scripts, notes and presets) now carry the most recent file's folder as initial_dir, and the desktop shell hands it to rfd's set_directory. Flips UI-217-3 to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:03:31 -04:00
texjerandClaude Opus 5.5 e8b992a6f0 Preferences on Cmd+K, Search on Cmd+F (Photoshop's defaults) (#1900)
Edit › Preferences › General takes ⌘K and Edit › Search moves to ⌘F, as in Photoshop. Nothing
else was bound to ⌘F (Filter › Last Filter stays ⌘⌥F); the shortcut-capture tests follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:02:30 -04:00
xsvmandClaude Opus 5.5 9b69ba89aa image.mode.bitmap: an unknown halftone shape is a bad-params error (#1842)
HalftoneShape::from_id returns None for an unlisted id instead of silently falling
back to Round, and the command fails naming the valid shapes (part of #507).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:02:04 -04:00
Reynold HuandClaude Opus 5.5 a14e94617b Hue/Saturation: hue-following colour tracks on the Saturation and Lightness sliders (#1863)
The Saturation slider's track runs grey to the hue at full saturation and the Lightness track black, the hue, white; both recolour as the Hue slider moves, as in Photoshop. Flips UI-217-16 to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:56:04 -04:00
Reynold HuandClaude Opus 5.5 d7d5c6069c Test the dispatch panic guard end to end; mark REL-3 done (#1803)
A cfg(test)-only test.panic command drives Session::execute into the catch_unwind
guard in jobs::dispatch; the test asserts an internal-error Err, an untouched document
and history, and a working session afterwards. REL-3 flips to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:51:51 -04:00
Sir StarchandClaude Opus 5.5 d8d2df4fba camera_raw_scope: serialise the GPU tests with GPU_LOCK (#1856)
Concurrent wgpu instances segfault in libvulkan with NVIDIA and lavapipe both installed; the three
GPU-harness tests in camera_raw_scope now hold a GPU_LOCK, like canvas_16f. Fixes #1855.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:51:40 -04:00
Sir StarchandClaude Opus 5.5 9bd935498b GPU parity tests: give the one-step tolerance f32 slack (#1852)
Two results exactly one 8-bit step apart can land a few f32 ulps past 1/255 after premultiplying
and subtracting (NVIDIA, Hue/Saturation in adjustment_layers); allow 4 epsilon of slack. Fixes #1848.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:51:36 -04:00
Reynold HuandClaude Opus 5.5 ff1d933cb8 Scorecard: mark the BUG-205 and BUG-207 rows done; the fixes and their tests are on main (#1795)
BUG-205-1/2/3 (5a5facc, #242) and BUG-207-1/2/3 (e2444ce, #234) flip to done;
each row now names the test that covers it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:40:48 -04:00
Reynold HuandClaude Opus 5.5 7544bb1084 Scorecard: mark UI-217-2 done; the geometry round trip is tested (#1808)
UI-217-2 flips to done: b07a2e5b (#587) persists the window and dock width via eframe,
and window_and_panel_geometry_survive_a_restart asserts the round trip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:39:33 -04:00
BFlat 9823f4ea08 Merge pull request #1898 from storytold/community/1734-fxdrag
Layers panel: drag layer effects onto another layer, Alt-drag copies (#1734 carry)
2026-10-09 22:09:00 +07:00
BFlat 46c9b83ce1 Merge pull request #1897 from storytold/community/1706-corpus
xtask: corpus --all --update-manifest refetches every manifest (#1706 carry)
2026-10-09 22:08:56 +07:00
BFlat bf1795e490 Merge pull request #1896 from storytold/community/1462-labmix
Compose: restore the blend-space flags instead of clearing them after a tile (#1462 carry)
2026-10-09 22:08:53 +07:00
BFlat e45c79df3d Merge pull request #1895 from storytold/community/1636-pixls
Raw corpus: real camera files from raw.pixls.us, oracle-tested (#1636 carry)
2026-10-09 22:08:50 +07:00
BFlat 9a71884244 Merge pull request #1894 from storytold/community/1745-eyedropper
Eyedropper: Sample Size, Sample layers and Show Sampling Ring (#1745 carry)
2026-10-09 22:08:46 +07:00
a01a3eb2de Canvas zoom: Photoshop's range, ladder and ⌥-wheel step; no slide at the limit (#1718)
* Canvas zoom: Photoshop's range, ladder and ⌥-wheel step; no slide at the limit

Measured on Photoshop 25.4 (exact zoom read by script, view position by screenshot
registration):
- ⌥ + wheel: ×1.1 a notch around the pointer (was ×1.05);
- the zoom runs up to 12800 % and down to where the document's shorter side is 2 px; a wheel
  notch past a limit lands on it exactly and further notches change nothing;
- Zoom In / Zoom Out and a Zoom tool click step along 0.1 … 66.67, 100, 200 … 800, 1200, 1600,
  3200, 6400, 12800 %, from any zoom to the next rung;
- with Overscroll off, an image smaller than the view stays where zooming put it, inside the
  view, instead of being re-centred.

PhotoCraft capped the zoom at 3200 % without meaning to: the status bar's zoom field (and the
Navigator's slider) clamped it and wrote it back every frame. Past 3200 % each ⌥-wheel notch
zoomed around the pointer and the field pulled the zoom back, so the image slid towards the
pointer. All zoom paths now share `zoom_levels` (range, ladder, clamp), and the fields always
include the current zoom.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Zoom: Print Size, 200 % and Fit Layers on Screen clamp through zoom_levels too

Review of #1718: `view.twoHundredPercent` / `view.printSize` and `view.fitLayersOnScreen` still
clamped to 1-6400 % on their own. They now use `zoom_levels::clamp` like every other zoom path:
Print Size at 1 ppi gives 7200 % (it was cut to 6400 %), and fitting a 1 px layer stops at
12800 %.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Wheel zoom: a notch applies on the frame it arrives (from #1490)

Folded in from @Jeremy8776's #1490, as agreed in review: zooming reads this frame's wheel
events (`Input::raw`) instead of egui's smoothed delta, so each notch is one ×1.1 step on the
frame it arrives, with no easing over the next frames (his frame-by-frame recordings of
Photoshop). The smoothed tail neither zooms nor turns into a pan when Alt is released; panning
still uses the smoothed delta. Zoom with Scroll Wheel uses the same ×1.1 notch (assumed, not
measured).

The Filter Gallery's and Liquify's own preview zooms keep their 3200 % caps, now with a comment
saying why: they aren't the canvas.

Co-authored-by: Jeremy <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Jeremy <[email protected]>
2026-10-09 22:08:18 +07:00
buivanvu94 69f307afbb fix(ui): autoscroll Layers panel when dragging past top or bottom edges (#1728) 2026-10-09 22:08:13 +07:00
MarekSpodBiedry 5538f3920d Patch Tool: say to drag the selection after the lasso and after a click that moves nothing (#1715) (#1720)
The Patch and Content-Aware Move tools work in two steps: lasso the
area, then drag the selection. The first step only leaves a selection,
and a press and release inside the selection returns early because the
offset is zero, so neither step said what to do next.

After a lasso made with either tool, and after a drag inside the
selection that moves nothing, the status bar now says "Now drag the
selection onto another area". The string is translated in all 14
catalogs.
2026-10-09 22:08:10 +07:00
2b139ebd5c inspect: report each smart object's source (#1785)
document.inspect (and photocraft-cli info) now gives every smart object a
smartSource: embedded with its file name, or linked with a file path or,
for a PSD placed layer, the Idnt uuid its duplicates share. A script can
then tell duplicates of one placed file from independent contents, e.g.
to check that every location in a mockup template has its own art.

Co-authored-by: Juan Manuel Verges Solanas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 22:08:07 +07:00
Gert Pellin - GPE 310e46e6a0 fix(ui): one Cmd+V pastes one layer (#1638) (#1743)
On macOS AppKit runs Edit > Paste's key equivalent and takes the
key-down, but winit still delivers the key-up (it forwards key-ups
while Cmd is held). The native menu fed egui a press plus a made-up
release, and clipboard_keys treats a Cmd+V release without a paste
event as an image paste (egui-winit drops Cmd+V when the clipboard
holds no text). So one Cmd+V pasted three times: the menu's press,
its synthetic release, and winit's real release.

- native_menu: a key equivalent adds only its press, inserted ahead of
  its key-up when that is already queued; winit delivers the release.
- clipboard_keys: a Cmd+V key press that reached egui marks the paste
  as seen, so its release doesn't paste again; any V/Insert release
  clears the mark (releasing Ctrl before V left it stale and swallowed
  the next image paste on Linux/Windows).
2026-10-09 22:08:03 +07:00
Gert Pellin - GPE 58289b2822 fix(codecs): read the resolution from EXIF, XMP and Photoshop APP13; write it consistently (#1691) (#1744)
A camera JPEG (e.g. Nikon D3200) records its resolution only in EXIF IFD0, so it
opened at 72 ppi instead of 300, and saving wrote JFIF 72 next to the source's
EXIF 300.

Reading: a JPEG takes the first usable of APP13 Photoshop ResolutionInfo
(0x03ED), XMP tiff:X/YResolution, EXIF IFD0 X/YResolution + ResolutionUnit
(2 inch, also when absent; 3 cm), and only then the JFIF density. That is the
order Photoshop resolves them in; JFIF comes last because cameras write EXIF
only while many tools write a default JFIF 72. Other formats (WebP, HEIF, PNG
without pHYs, ...) fall back to XMP then EXIF when they have no resolution of
their own. All parsing is bounds-checked; malformed data reads as no
resolution.

Writing: JPEG, PNG, WebP, TIFF (XMP), PSD and Save for Web JPEG rewrite the
EXIF/XMP resolution to the image's: RATIONAL values in place, ResolutionUnit 2,
and an entry that can't be rewritten safely is removed from IFD0 without moving
any other data.
2026-10-09 22:07:59 +07:00
BFlatandClaude e39cacd895 Packaging: Windows MSI setup wizard (folder choice, progress, version, launch on finish) (#1864)
Double-clicking the MSI now opens WixUI_InstallDir without the licence page:
Welcome, install folder (remembered for upgrades in
HKLM\Software\PhotoCraft\InstallDir), Ready, progress, and a Finish page with
a ticked "Launch PhotoCraft" box (WixShellExec, unelevated). The pages name
the full version, pre-release tag included (photocraft.en-us.wxl).
package.ps1 draws the banner and side bitmaps from the app icon, so no WiX
stock art ships. msiexec /qn still installs silently. The release and ARM64
workflows install the WiX UI and Util extensions; packaging-lint checks the
.wxl. Ported from VectorCraft (storytold/vectorcraft@aa548d2).


Claude-Session: https://claude.ai/code/session_01AP7ThRN3kpNmjsuJUerJui

Co-authored-by: Claude <[email protected]>
2026-10-09 21:33:38 +07:00
Elvis33LEandClaude Opus 5.5 adc1f76df4 Indexed Color: integer counts, palette stops and asynchronous previews (#1741)
Indexed Color accepted fractional colour counts and computed its live preview on the UI thread.
Counts are now whole numbers in 2-256 with a linear slider snapping at 8/16/32/64/128/256, and on
native builds the preview runs on one background worker that coalesces slider drags and rejects
stale results. Nearest-colour lookup uses an exact kd-tree above 64 entries (identical indices,
256 colours at 4.2 MP: 3018 -> 1061 ms). Web and inline sessions stay synchronous.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:33:13 -04:00
Gert Pellin - GPEandClaude Opus 5.5 15737e122b Filter > Other > Color to Alpha (#1683)
A new filter that removes a chosen colour by unmixing it into transparency, as GIMP's and
Krita's Color to Alpha do: per pixel the most transparent opacity that keeps the unmixed colour
in range, shaped by transparency and opacity thresholds. It runs through run_filter (selection,
smart filters, Last Filter, one history step), floats the Background first and refuses a
transparency-locked layer; the filter dialog gains a colour parameter. Fixes #1576.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:13:42 -04:00
Claude ac9c7b057b Merge main into #1745 (catalogs: keep both blocks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 14:08:06 +00:00
StreetblockandClaude Opus 5.5 e2bb2bd681 Share concurrent cold mask builds (#1653)
Concurrent requests for the same cold combined mask each rasterized and feathered the whole
image. A per-key OnceLock slot now lets one caller build while the others wait for the same
tiles; different keys build independently outside the cache mutex, and the result is admitted
through the byte-budgeted cache with the generation taken at lookup, so a purge mid-build still
wins. The vector mask renders sequentially inside the slot so a Rayon worker can't wait on itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:05:01 -04:00
UnemarsandClaude Opus 5.5 0d10f11733 Toolbar colour chips, Color Picker and panel footers, as in Photoshop (#1729)
The colour chips sit centred in the toolbar with Default Colors and Switch Colors above them. The Color Picker gains L/a/b radios and the out-of-gamut and web-colour warnings, each with a click to take the nearest colour. Panel footers are a full-width bar with their own mask and adjustment glyphs, keeping the right-click footer menus from #1680.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:03:37 -04:00
StreetblockandClaude Opus 5.5 68dab88f9b Bound mask-cache memory and include it in Purge All (#1650)
The combined-mask cache was capped only at 64 entries, so large feathered masks could retain a
lot of memory, and Edit > Purge > All left them allocated. The cache is now also bounded to
512 MiB of accounted pixel bytes (result plus the pinned input), Purge All clears it and reports
its bytes, and a purge generation stops a build that started earlier from refilling the cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:00:00 -04:00
Brandon ThomasandClaude Opus 5.5 9b1c916f42 Japanese: command names match the menu terms (#1860)
About 50 command names from #1669 now use the same word as their menu item (ゆがみ, 演算, ぼかし(ガウス) …).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:57:56 -04:00
coolvittoandClaude Opus 5.5 214e062af8 Japanese: translate about 1,000 more UI strings (#1669)
Filter and gallery parameter names and choices, command, tool and menu names, hints, preferences, metadata fields and colour spaces, using Photoshop's Japanese terms (浅浮き彫り, チョーク・木炭, アンシャープマスク …).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:57:14 -04:00
Claude 42e60e3602 Merge #1636 into #1706: keep the pixls opt-in, fetch_all takes update
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:56:23 +00:00
Claude beb80bc5dc Carry #1636: document test-corpus --pixls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:56:22 +00:00
Claude 819651f3c0 Merge commit 'refs/tmp/p1745' into community/1745-eyedropper
# Conflicts:
#	crates/ui-egui/src/i18n/de.tsv
2026-10-09 13:55:40 +00:00
storybored379andClaude Opus 5.5 15e35cf605 MCP: shared conventions and cancellable Render Video (#1668)
The MCP server follows the crafts' shared conventions (FilmCraft #28): core tools
incl. render_preview, titles and annotation hints, strict argument keys (-32602),
live photocraft://document and photocraft://commands resources, and -32700
recovery from malformed JSON lines. Headless Render Video writes through the
granted write root, reports progress, stays ping-responsive and can be cancelled,
removing only the files it created. docs/mcp.md describes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:54:14 -04:00
Elvis33LEandClaude Opus 5.5 7533254791 Keep PNG text metadata through document saves (#1684)
PNG tEXt/zTXt/iTXt entries (and TIFF ASCII tags) were dropped on open with a warning. The
document now keeps them in Metadata.text, the .pcraft manifest saves them (older files still
load), and PNG/TIFF saves write them back; Metadata: None and embed_metadata=false strip them,
and PSD/PSB and palette PNG exports warn that they are dropped. XMP in a tEXt or zTXt chunk is
read as XMP too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:53:37 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 75e6bad453 Control: ui.inspect reports the open Brush Preset picker (#1672)
ui.inspect now has brushPicker: null while closed, {pos, list} while the Brush
Preset picker is open (list is its collapsed/filter/view/renaming state), so
agents can see what a right-click with a painting tool opened. Fixes #1558.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:50:01 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 8bc9861224 Control: ui.menu.invoke respects the modal menu policy (#1671)
A control-channel menu click could run an edit behind an open dialog (a rotation
under Image Size), which the native menu bar already refuses (#1354). The modal
gate moves to menus::modal_allows and ui.menu.invoke checks it; view navigation
stays live, engine.execute is unaffected. Fixes #1556.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:49:56 -04:00
Harrison O'Connor-HooverandClaude Opus 5.5 18bb712f43 Save/Close: finish layered TIFF writes before discarding documents (#1723)
Choosing Save in the close prompt for a layered TIFF only parked the save behind TIFF Options,
yet the prompt went on to close the document. The prompt now waits for TIFF Options and
advances only once the write succeeded and the document is clean; the save stays bound to the
document that asked even if the active tab changes, and Discard Layers and Save a Copy leaves
the original's path and unsaved changes alone, as in Photoshop. Fixes #1098.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:49:09 -04:00
Brennan StehlingandClaude Opus 5.5 9fee51c308 macOS: save to SMB shares that refuse a full flush (#1639)
sync_all on Apple platforms asks for F_FULLFSYNC, which SMB shares (ENOTSUP) and some virtual
file systems (ENOTTY) refuse, so every save there failed. atomic::sync_file falls back to a
plain fsync for exactly those errors, as Apple's removefile does; the automation workspace
writes use it too. rustix is already in the lock on Apple targets. Fixes #1336.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:36:03 -04:00
Brandon Thomas 74112064cc Merge main; de: Photoshop's German eyedropper terms (Aufnahmebereich, 1 Pixel, 3 x 3 Pixel Durchschnitt) 2026-10-09 09:32:53 -04:00
Brandon ThomasandClaude Opus 5.5 2e41caf897 ATTRIBUTION: keep the PngSuite row and one pixls row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:29:58 -04:00
Brandon Thomas 4b7db4088b Merge remote-tracking branch 'origin/main' into pr1636ff 2026-10-09 09:29:34 -04:00
Claude 0b958be52a Merge main into #1734 (effect-row eyes from #1664 kept beside the fx drag)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:29:19 +00:00
Claude a94dd19d79 Merge main into #1745 (German catalog)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:29:18 +00:00
ChandirasegaranandClaude Opus 5.5 f338454f36 Layers panel: the eyes on effect rows show and hide effects (#1664)
The eyes on a layer's Effects row and on each effect's row did nothing. As in Photoshop, the
Effects eye now hides or shows all of the layer's effects and an effect's eye just that one, each
one history step that keeps the settings, via the new layer.setEffectsVisible command. Fixes #1622.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:25:57 -04:00
kayrockscreenprintingandClaude Opus 5.5 1284e79d23 Selective Color and Color Lookup: a wrong-typed key is an error (#1790)
Selective Color and Color Lookup params with the wrong type (a string percentage, a short range
array, an unknown method, range or interpolation) were silently ignored, so layer.setAdjustment
kept the old values without saying so. They are now BadParams errors naming the key; null still
means not given. Fixes #936.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:23:34 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 c017b119ff Gradient tool: the options-bar swatch opens the Gradients presets (#1681)
Clicking the gradient swatch in the options bar did nothing; as in Photoshop it now opens a
popup with the Gradients preset browser, so a preset is picked without opening Window › Gradients.
Fixes #1651.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:17:21 -04:00
midasdfandClaude Opus 5.5 b5320e590e Preferences: System Title Bar says it applies at next launch (#1677)
On Windows and Linux the System title bar checkbox now carries the existing "Applies at next
launch." note, since saving it does not change the current window. Fixes #1661.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:17:05 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 681e7f3b89 Layers panel: right-click opens the fx and adjustment footer menus (#1680)
As in Photoshop, a secondary click on the Layers footer's Add a layer style and Create new fill or
adjustment layer buttons opens their menus too (a primary click still toggles them), and a small
arrow marks them as menu buttons. Fixes #1579.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:17:02 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 efa62efa0c Liquify: test that Alt-reversed strokes survive replay, undo and redo (#1675)
Follow-up to #1494: Alt captured at stroke-down records the reversed tool, a
later stroke records the selected one, and the field rebuilt from the strokes
matches after undo and redo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:11:07 -04:00
midasdfandClaude Opus 5.5 40f14d25e8 Plug-ins: keep the originating error across parallel bands (#1654)
When one band failed, its siblings stopped with a generic "another band failed"
error, which could win the collection order and hide the real cause. The abort
flag now carries the first error, so every band reports it; tests cover traps,
error codes and the instruction budget across parallel bands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:11:00 -04:00
Jason XiangandClaude Opus 5.5 c88dbd26cd Live strokes: reject out-of-range coordinates before the preview changes (#1652)
The live brush, pencil, retouch and dab previews fed pointer batches to the
stroke renderer unchecked, so a NaN or huge coordinate could hang the path
walker. Every push now runs the shared check_coords first and rejects the whole
batch without touching the preview; tests cover 8/16/32-bit and every live tool.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:10:53 -04:00
9b4562f2a0 Open dialog: upper- and mixed-case extensions on Linux and the BSDs (#1439)
The portal and zenity match rfd's *.ext globs case-sensitively, so IMG_0001.JPG or DSC_0001.NEF didn't show. On Linux and the BSDs each letter of an Open filter extension becomes a [jJ] class; Windows and macOS keep plain extensions.
Duplicate #1682 by @switch87 took the same approach.

Co-authored-by: switch87 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:10:04 -04:00
David MannandClaude Opus 5.5 347a0f9fc9 Gradient Map: pick presets from the gradient library (#1780)
The Gradient Map preset picker lists the session's gradient library by group with a swatch per preset, in the Properties panel and the dialog, as Photoshop's gradient picker does. Picking one writes its colour stops as one undoable edit.
Fixes #1588.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:09:51 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 616f206c5d Filter previews: only for the active document, and computable without a GPU (#1676)
A command dialog's preview is never shown in another tab, and the preview is computed even without a GPU texture, so headless tests can check it (90° Arbitrary rotation, Preview off, document untouched).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:09:45 -04:00
kayrockscreenprintingandClaude Opus 5.5 a0abb59044 View › Show › Target Path hides the path outlines (#1794)
Target Path (and Extras) now gate the work path, shape and vector-mask outlines and Direct Selection's paths; the Pen's path in progress stays visible.
Part of #1119.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:09:39 -04:00
kayrockscreenprintingandClaude Opus 5.5 cf8044bcb1 Equalize, Average and Clouds edit the targeted channel, not the layer (#1777)
These four ran through map_pixels, which always rewrote the active layer, so with an alpha
channel, the Quick Mask or a layer mask targeted they changed the layer and left the channel
alone. They now rewrite the targeted grayscale surface, as the core filters do. Fixes #935.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:07:43 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 864d9ce991 Selection outline: trace serially while background jobs run (#1690)
A long filter could occupy every worker in Rayon's global pool, and the marching-ants trace on
the UI thread then waited for those workers, so the job's Cancel control froze. While jobs run
the outline is traced serially; a test checks it matches the parallel trace. Fixes #1554.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:04:35 -04:00
Duke242andClaude Opus 5.5 2f3e1c0842 Pen: Esc ends the path and keeps it as the work path (#1787)
Esc while drawing with the Pen ended the path by throwing it away. As in Photoshop, it now ends
the path where it is, left open, and keeps it as the Work Path, the same as Return. Fixes #1769.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:04:22 -04:00
kayrockscreenprintingandClaude Opus 5.5 3e1407ba68 Liquify: a stroke segment walks only the dabs that can reach the field (#1736)
A segment to a far-off point walked one dab per spacing step along its whole length (about
3e14 for a point at 1e15). Only the steps whose dab can reach a field node are walked now; a
test checks the result is bit-identical to the full walk for every tool. Fixes #937.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 08:57:14 -04:00
bb3108cfd2 CI: no macOS on PRs or main; macOS is built and tested by releases (#1820)
The org has 5 macOS slots in total (GitHub Team), shared by every repo's CI and
release builds, and PR CI kept them full, so release builds queued behind it.
macOS now runs only in release.yml; a release that fails on macOS is fixed and
rebuilt.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 08:51:18 -04:00
kayrockscreenprintingandClaude Opus 5.5 dd41598d4c Retouch tools: test the stroke-point bound on all eleven commands (#1784)
The painting and retouch tools now share one check_coords helper for the ±1e6 point bound,
and a regression test sends far-off points to all eleven retouch commands at every depth.
Fixes #976.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 08:30:06 -04:00
StreetblockandClaude Opus 5.5 3a2dda1996 German: clearer terms for 30 UI strings (#1791)
Screen is Negativ multiplizieren, Perceptual is Perzeptiv, Blend If is Farbbereich, Oldstyle is Mediävalziffern and Protect Tones is Tonwerte schützen, as in Photoshop's German UI; the font weight Black no longer reads as a colour.
Tool names keep the catalog's -Werkzeug form, Black Point Compensation is Tiefenkompensierung, and Float in Window is In Fenster schweben lassen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 08:27:46 -04:00
初心YearthandClaude Opus 5.5 c63d04a09c docs: explain Windows source build prerequisites and app-control failures (#1692)
Document the MSVC toolchain and C++ build tools workload, and how to diagnose
Smart App Control blocking Cargo or build outputs (os error 4551), with a
pointer to the packaged release. Fixes #1585.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 08:04:05 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 351736ef6b docs(mcp): grant scoped file roots in headless registration examples (#1673)
The PHOTOCRAFT_AUTOMATION_*_ROOT variables are read only by the desktop app, so
headless photocraft-cli mcp/serve need the --automation-*-root flags. Say so,
and pass explicit roots in the .mcp.json and claude mcp add examples.
Fixes #1551.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 08:03:58 -04:00
Nigel Ihza FirdausyandClaude Opus 5.5 6b1075e240 docs(control): align method signatures with accepted parameters (#1674)
ui.set gains rotation, gradientBlendMode and gradientClassic in its signature
and the stale duplicate ui.set line goes; ui.menu.invoke documents params,
ui.pointer tool, app.save an optional path, and serve's doc.save tiffLayers.
Fixes #1550.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 08:03:30 -04:00
buivanvu94andClaude Opus 5.5 f7d8a328e4 docs(control): Preferences Apply and OK are refused over control (#1678)
The Preferences entry said ui.dialog.apply/confirm save the dialog, but the
automation policy refuses whole-preferences updates (SECURITY.md). Document the
refusal, what happens to the dialog, and prefs.set for single safe preferences.
Fixes #1555.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 08:03:06 -04:00
BFlatandClaude 89922792c5 Clarify that muda is a native menu crate, not Tauri (#1786)
muda is maintained by the tauri-apps org but is standalone: AppKit menus,
no webview or JS, so it fits the no-Tauri rule. Reword the comments that
called it "Tauri's menu crate".


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 18:53:59 +07:00
Claude 8cdea53ced Carry #1745: Current & Below keeps an active group's own contents
Document::walk lists a group before its children, so hiding everything after
the active layer hid a group's own layers and sampled transparent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 11:50:37 +00:00
Claude cc8d3c3f77 Merge main into #1462; in_blend_space restores LAB_MIX and HDR instead of clearing them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 11:49:32 +00:00
Elvis33LE 3bfaef668c pixls: usage and status text, per-file licence links, dedup, --changed for raw
The xtask usage line and the corpus status listing name --pixls and
corpus/pixls; the attribution row links each of the seven files' database
entries so the licence can be checked per file; -p raw no longer pushes
the crate into the list twice; and test-corpus --changed runs (and
fetches) the raw corpus when a file under crates/raw/ changed, matching
its CRITICAL entry.
2026-10-09 13:08:16 +02:00
Elvis33LE 5d0800df7a pixls corpus: in-house sha256 and pinned.rs, opt-in like the affinity corpus
The fetch lives in pinned.rs now, reusing its manifest discipline and the
xtask sha256 module (the sha2 dependency is gone). --pixls is opt-in: not
part of --all or the CI corpus job (CORPUS_CRATES no longer includes
photocraft-raw; test-corpus --pixls fetches and adds it), matching how the
Affinity corpus is handled. The usage line names the flag, and the
attribution row links each sample's folder in the database so its licence
can be checked per file.
2026-10-09 13:08:16 +02:00
Elvis33LE 6ce21609a2 raw corpus: real camera files from raw.pixls.us, oracle-tested
Seven public-domain files (raw.pixls.us), one per decode path: Canon
PowerShot SX50 HS as CR2 and as its DNG conversion, Nikon D3 uncompressed
NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2 (10480x7794,
crop 10368x7776), Olympus E-1 with a GRBG CFA, and the Olympus E-5 as
the documented known-unsupported packed-ORF case (still previews).

xtask grows --pixls (single-file downloads, the server redirects
/data/ to /download/), a sha256-verified manifest and a --update-manifest
path; photocraft-raw gains the corpus feature and oracle tests: format,
make/model, sensor size, CFA, black/white levels and the crop are exact,
developing yields the crop in 16-bit RGB, deterministic, with sane
range coverage.

Two real findings already documented by the tests: the PowerShot CR2 and
the RX0 ARW carry no as-shot white balance we read, so the grey-world
estimate brightens those scenes (the same camera's DNG develops blacks
to ~0). Reading the Canon and Sony maker-note WB tags is the follow-up.
2026-10-09 13:06:05 +02:00
BFlat 8e8e9f2c68 Merge pull request #1778 from storytold/community/1607-affinity
AF.3: native Affinity import (layers, artboards, vectors, text, images), carrying #1607 by @callmeBalloch
2026-10-09 18:00:53 +07:00
Elvis33LE 6cd2d61398 CR2: read ColorData's as-shot white balance as words (#1640)
* CR2: read ColorData's as-shot white balance as words

CANON_COLOR_DATA is an UNDEFINED entry: uints() hands its bytes back one
by one, but the WB_RGGBLevelsAsShot offsets count 16-bit words. Reading
word i at entry offset i*2 (file byte order, bounds-checked) finds the
quadruple at 0x47 in the corpus PowerShot SX50 HS: [1613, 951, 951, 1605]
-> [1.70, 1.0, 1.69], matching the white balance its own DNG conversion
neutralises (AsShotNeutral -> [1.58, 0.955, 1.666] through the color
matrix). With this the file's balance is used instead of the grey-world
estimate; the regression test pins the words and the missing estimate
warning. (The remaining brightness gap between the CR2 and the DNG of
the same camera is the CR2 path's exposure normalisation, to be looked
at next; the estimate was never what brightened the file.)

* cr2: drop the unused glob import in the corpus test

* CR2 WB: synthetic word-offset oracle, entry bounds, corpus test behind the feature

testgen writes ColorData as Canon does, UNDEFINED bytes that are 16-bit words
with the PowerShot's WB_RGGBLevelsAsShot at word offset 0x47, and the new
color_data_wb_as_words test checks the decoded balance in every CI run (the
corpus oracle stays behind the corpus feature and now panics when its file is
missing instead of silently skipping). word() reads stay inside the entry's
own byte count, and the shared inspect example moves back to the corpus PR.
2026-10-09 18:00:50 +07:00
McClabe c58d20bd39 Fix small caps rendering across fonts (#1644)
* fix: synthesize small caps across fonts

* fix: use native small caps when available
2026-10-09 18:00:47 +07:00
llduyll10andClaude Opus 5.5 efca9b270d Layered TIFF: keep blending ranges as bytes in Intel-order files (#1712)
Photoshop writes the per-channel blend-if ranges byte for byte in either byte
order (an II TIFF holds the defaults as 00 00 ff ff). The transcoder swapped
them as u32s, turning every range into ff ff 00 00, which hides every pixel of
the layer: a Photoshop layered TIFF opened with an empty canvas.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 18:00:43 +07:00
ericandhideoneric 60018dea69 fix(compose): don't clip Add and Divide at 1 in 32-bit documents (#1722)
- Linear Dodge (Add) and Divide keep values above 1 in 32-bit float documents, clipped only at f32::MAX. Integer depths still clip at 1.
- CPU: add psblend::HDR and generic::blend_rgb_hdr / blend_channel_hdr; render sets the flag per document depth.
- GPU: add the F_HDR pass flag, and compose.wgsl uses it for the same clip bound.
- Non-separable modes are unchanged.
- Tests: colour unit tests, compose depth tests, and the GPU parity test float_documents_blend_past_white.

Co-authored-by: hideoneric <[email protected]>
2026-10-09 18:00:39 +07:00
MarekSpodBiedry 68c2e21da8 docs: README tool count and list match Tool::ALL (#996) (#1710) 2026-10-09 18:00:32 +07:00
MarekSpodBiedry 088dbba090 fix(engine): prefs.set and prefs.reset apply the text blending gamma (#987) (#1709) 2026-10-09 18:00:29 +07:00
MarekSpodBiedry 66ab5012be fix(compose): a Precise inner glow from the centre is no longer blank (#966) (#1708) 2026-10-09 18:00:26 +07:00
MarekSpodBiedry 57be0f58d8 fix(ui): Keyboard Shortcuts dialog rejects a malformed overrides map instead of wiping shortcuts (#956) (#1707) 2026-10-09 18:00:22 +07:00
MarekSpodBiedry 2feaf055cc fix(xtask): the scorecard's performance summary no longer counts not-run scenarios as tracked (#947) (#1705) 2026-10-09 18:00:19 +07:00
MarekSpodBiedry 1fd600d3ad fix(automation): bridge doc_save without path writes back like headless (#946) (#1704) 2026-10-09 18:00:16 +07:00
MarekSpodBiedry 4f33c81524 fix(io): keep a PSD Anno block that cannot be read instead of deleting it on save (#944) (#1703) 2026-10-09 18:00:12 +07:00
MarekSpodBiedry 8a31f2431b fix(io): a CMYK Channel Mixer using the black source stays raw in CMYK documents (#940) (#1702) 2026-10-09 18:00:09 +07:00
MarekSpodBiedry 1db0dde9a4 fix(engine): Move tool Auto-Select clicks through fully locked layers (#1641) (#1701) 2026-10-09 18:00:05 +07:00
MarekSpodBiedry a4c4a02364 fix(ui): the Navigator keeps its image during a live paint stroke (#1631) (#1699) 2026-10-09 17:59:58 +07:00
MarekSpodBiedry 549baa778f fix(ui): the Photoshop-style status bar shows messages with no document open (#1567) (#1698) 2026-10-09 17:59:55 +07:00
MarekSpodBiedry 5cfbf89917 fix(engine): layer bounds drop the area a selection transform moved pixels out of (#1097) (#1697) 2026-10-09 17:59:51 +07:00
MarekSpodBiedry 1433c3c520 fix(engine): Layer via Copy and Cut deselect, as Photoshop does (#1096) (#1696) 2026-10-09 17:59:48 +07:00
MarekSpodBiedry 3e3e6278d7 fix(control): ui.inspect reports the documented view preferences (#1003) (#1695) 2026-10-09 17:59:45 +07:00
MarekSpodBiedry 864021994c fix(scorecard): mark the #204 preference rows done; their readers, allowlist and test exist (#1002) (#1694) 2026-10-09 17:59:41 +07:00
Claude Opus 5.5 acc27e0fd0 Merge remote-tracking branch 'origin/main' into fix/1112-lab-mix-restore
# Conflicts:
#	crates/compose/src/lib.rs
#	crates/compose/src/tests.rs
2026-10-09 06:57:07 -04:00
Gert Pellin 65f30dd9e3 feat(eyedropper): Sample Size, Sample layers and Show Sampling Ring (#1649)
The Eyedropper options bar now has Photoshop's Sample Size (Point Sample,
3 by 3 ... 101 by 101 Average), Sample (Current Layer, Current & Below,
All Layers, All Layers No Adjustments, Current & Below No Adjustments)
and Show Sampling Ring.

- engine: new `document.sampleColor {x, y, size, sampleLayer}`
  (sample_cmds.rs). Averages the square centred on the pixel, clipped to
  the canvas, alpha-weighted, in the document's colour model (Gray, Lab;
  CMYK documents use their composite RGB, since the profile round trip
  is not exact) and rounded to the document's bit depth.
- ui: eyedropper_ui.rs options bar; state.rs ToolOptions
  eyedropper_size / eyedropper_sample / eyedropper_ring (serde). The
  size is shared with the painting tools' Alt-click eyedropper, the
  Curves / Color Picker eyedroppers and the Info panel readout.
- control: ui.set eyedropperSampleSize / eyedropperSample /
  eyedropperRing; documented in control-protocol.md and the MCP schema.
- i18n: new strings in all 14 catalogs (plus the missing Russian hint
  the tl! scan had not reached).
- scorecard: TOOL-1649-1.
2026-10-09 12:28:14 +02:00
Claude 20275685b1 io/affinity: as_chunks for the mask's grey extraction (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:19:17 +00:00
Claude 30aa0f974f affinity: as_chunks for the constant-size chunk loops (clippy 1.99 chunks_exact_to_as_chunks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:18:57 +00:00
MarekSpodBiedry 6ba1419286 Layers panel fx drag test: measure the drop target before the mutable drag borrow (#1719) 2026-10-09 12:18:32 +02:00
MarekSpodBiedry b692ed451c Layers panel: drag layer effects onto another layer to move them, Alt-drag copies (#1719)
Effect rows only took clicks and the fx badge was part of the layer row, so
dragging an effect or the fx badge picked up nothing (or the whole layer).

As in Photoshop, dragging the fx badge or the "Effects" row onto another
layer moves all its effects there (replacing that layer's style, like Paste
Layer Style), dragging one effect row moves just that effect (replacing an
effect of the same kind), and holding Alt on release copies instead. The
badge takes the drag the way the eye does, so clicks still select the row.
The drop is one history step through a new engine command,
layer.layerStyle.transferEffects (no menu entry, like Show All Effects).

Tests: the engine command (copy, move one, move all, undo, bad params) and a
Layers panel test that Alt-drags an effect row and drags the fx badge.
2026-10-09 11:54:19 +02:00
MarekSpodBiedry fee28f3a1b xtask corpus: pass the pinned corpus without an explicit deref (clippy) (#948) 2026-10-09 11:34:11 +02:00
Claude 2515fa7cee Linux display server: compile read_display_server on Linux (and tests) only
macOS and Windows clippy rejected it as dead code (-D warnings): its only caller in main.rs is
#[cfg(target_os = "linux")]. Also puts read_rendering_prefs' doc line back above it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:12 +07:00
Claude 770f6d49b8 scorecard: regenerate for the display-server preference (141 settings)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:12 +07:00
Gert Pellin a132e37ee1 Linux: display server preference (X11 / XWayland) so file drops work
On a Wayland session dropping a file on the window does nothing: winit
0.30 (eframe 0.36) has no Wayland drag and drop (#386). The only way
out was launching with `WAYLAND_DISPLAY= photocraft`.

Add Preferences › Performance › Linux display server (auto | x11,
applies at next launch), direction 2 in #386. With x11, main.rs asks
winit for its X11 backend through eframe's event_loop_builder hook
(EventLoopBuilderExtX11::with_x11), so the window runs through XWayland
and drops work. No environment is modified (no unsafe). It only applies
when an X server is reachable (DISPLAY set); otherwise winit's default
is kept, so a missing XWayland can't stop the app from starting. Other
platforms ignore it.

The Wayland drop notice now points at the preference instead of the
environment variable (all catalogs updated).

Tests: the startup reader (x11, auto, unknown value, wrong type, broken
JSON, missing file), the notice text, prefs_usage, label translations.
Verified on KDE Plasma 6 Wayland: with x11 the window is an X11 window
(XWayland) and dropping a NEF from Dolphin opens it.
2026-10-09 16:06:12 +07:00
Claude 165226b390 fmt: import order in paint_mouse.rs (#1629)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:07 +07:00
Adam Gincel 35c323c9de feat(ui): keep a brush per painting tool, as in Photoshop (#218)
The brush was one global session setting (only its smoothing was per tool), so
switching tools carried the size, hardness, mode, opacity, flow and dynamics
over. sync_tool_smoothing becomes sync_tool_brush: it saves the session brush
for the outgoing tool and loads the new tool's on every switch. UiState's
smoothing_tool/tool_smoothing become brush_tool/tool_brushes (serde default, so
saved UI state still loads), and the session's own fields (colours, erase flag,
stroke seed) are never carried across a swap.

Called from the frame start, the options bar before the brush chip, both canvas
stroke paths, ui.set {tool} (so tool + brushSize in one call sets the new tool's
size) and select_tool_preset. A tool seen for the first time inherits the brush
in hand at Photoshop's 10 % smoothing.

Tests: each_tool_keeps_its_own_brush, the renamed per-tool smoothing test,
per-tool coverage in the ui.set brushSize test, and pencil_tests switching tools
before setting the smoothing as the options bar does.
2026-10-09 16:06:07 +07:00
McClabe 9b7349d0e5 Keep legacy PSD text origins visible when editing (#1560)
* fix: keep legacy PSD text origins visible when editing

* fix: normalize legacy TySh origins on import
2026-10-09 16:06:03 +07:00
Brennan StehlingandClaude Opus 5.5 22ba13b47b macOS: quit with terminate:, not by closing the window (#1575, #1458) (#1624)
Since the native menu bar (#894), Quit runs file.exit, which closes the window through eframe
while AppKit's run loop is still going. AppKit's Touch Bar finder then removes a KVO observer from
the already-deallocated view in the next display cycle (`-[_NSTouchBarFinderObservation
invalidate]`): an uncaught exception that aborts on macOS 27 (#1458) and leaves a windowless app
on macOS 26 (#1575). The window's close button took the same path before #894; ⌘Q used winit's
default menu, which calls terminate:.

Every way of leaving (Quit, ⌘Q, the close button, the OS quit event, app.quit) arrives at
guard_window_close as a close request. Once leaving is settled (nothing unsaved, or the prompt
answered) it now runs Services::quit when the platform has one, keeping the window open for it.
On macOS that is -[NSApplication terminate:], scheduled on the main queue so winit is out of its
event handler and eframe's exiting hook still saves the window layout.

Reproduced in a macOS 26.6.2 VM with XCUITest: on main, Quit, Quit with a document, ⌘Q and the
close button abort with the #1458 stack; with this change all of them exit, write no crash report
and save ui.ron.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 16:05:58 +07:00
UnemarsandClaude Opus 5.5 a273d3b27d Layers panel: ⌥-click on the line between layers, measured against Photoshop (#1642)
#1066 added the gesture; this makes it behave as Photoshop 25.4 does (measured by sweeping
⌥-hover and ⌥-click a pixel at a time across each line, results read back by script):

- the line takes the pointer from 3 px above it to 5 px below it (was ±3);
- a release frees the layer above the line together with the clipped layers stacked above it,
  in one history step, and makes it the active layer (was: that layer alone); a clip keeps the
  active layer, as before;
- clipping and releasing have their own cursors: PhotoCraft's original clip-below (a hooked
  arrow onto the layer below) and clip-release (the same arrow with a small "no" badge),
  replacing Lucide corner-right-down for both.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 16:05:52 +07:00
StreetblockandGPT-6 576811263e Fix combined-mask cache invalidation after pixel edits (#1645)
Co-authored-by: GPT-6 (Codex) <[email protected]>
2026-10-09 16:05:48 +07:00
MarekSpodBiedry a5d92c231c fix(xtask): corpus --all --update-manifest refetches and rewrites every manifest (#948) 2026-10-09 10:42:10 +02:00
callmeBallochandClaude Opus 5.5 d10d782bc4 AF.3: say which sample set the semantics were fitted on
The previous commit put 189 in both places, but the thumbnail fitting
was done on 176 documents; the set grew to 189 afterwards and the
reader opens all of them. State both, so neither number is overclaimed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 07:44:22 +00:00
callmeBallochandClaude Opus 5.5 6deb1e0934 AF.3: attribute the Affinity corpus, one sample count, run it on --changed
From the review on storytold/photocraft#1607:
- ATTRIBUTION.md gets a row for corpus/affinity: the four upstreams at
  their pinned commits, with authors and licences (CC0-1.0, and MIT by
  Nick Beeuwsaert, Jeremy Cantu and DAIKI).
- The README said 176 sample documents in the provenance and 189 in the
  limits. It now says 189 distinct public documents in both places; the
  reader opens all of them (298 files with duplicates, no failures).
- crates/affinity/ joins CRITICAL, so test-corpus --changed runs the
  corpus when the reader changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 07:41:49 +00:00
callmeBallochandClaude Opus 5.5 2b279ba61b Merge storytold/main into AF.3 native Affinity import
No conflicts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:38:46 +00:00
callmeBallochandClaude Opus 5.5 a8d7c6ef3f AF.3: put the Affinity fuzz targets and corpus back in CI
The maintainers approved the new crate, its fuzz targets and the corpus
upstreams in principle (storytold/photocraft#1607), so this undoes the
interim opt-in: ci.yml fuzzes preview and import_preview again, corpus
--all fetches the Affinity set, and its test runs under the normal
corpus feature. The provenance section stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:38:40 +00:00
shmuelbiller1 ec350d64ae fix(engine): finish background job bookkeeping on its document (#1572)
* fix(engine): finish background job bookkeeping on its document

* test(engine): cover Fade state after switching tabs during background job

* style(test): format background job regression assertions

* style(engine): follow rustfmt for background-job regression test
2026-10-09 13:24:33 +07:00
shmuelbiller1 83d5c5436d fix(autosave): report background write failures and retry unchanged revisions (#1577)
* fix(format): expose asynchronous autosave completion and enqueue errors

* feat(ui): accept nonblocking background autosave outcomes

* fix(desktop): pass worker autosave results to shell

* fix(ui): confirm autosaves only after successful writes and retry failures

* test(desktop): retry failed background autosave with unchanged revision

* test(format): report async autosave write failure and successful retry

* style(ui): compact autosave iterator chains under project rustfmt width

* style(format): apply rustfmt for autosave completion iterator

* fix(format): simplify async autosave completion types for Clippy

* test(format): allow slow Windows autosave worker time to finish

* test(desktop): use bounded wall-clock deadlines for background autosave checks

* style(format): match rustfmt for autosave worker signature
2026-10-09 13:24:30 +07:00
ericandhideoneric 829aca9483 Snap the Marquee drag outline to whole pixels (#1587)
- Rectangular and Elliptical Marquee previews use the same pixel rect the release commits, so the ants and size readout match the final selection.
- Extract marquee_preview_px in canvas.rs and use it in draw_drag_preview.
- Add a regression test: a zoomed-in drag between pixel edges previews whole pixels and commits that same rect.

Co-authored-by: hideoneric <[email protected]>
2026-10-09 13:24:27 +07:00
Dan HiornsandClaude Opus 5.5 5aaefe378e Layers panel: pop-up sliders for Opacity and Fill (#1592)
Photoshop's Layers panel Opacity and Fill fields have a ▾ that opens a
slider. Add widgets::popup_value_field, a value field with that ▾:

- a click opens the slider below the field; a click outside closes it
- pressing the ▾ and dragging moves the value at once and closes the
  slider on release
- the number can still be typed, scrubbed and stepped with the arrows

A drag (on the slider, from the ▾, or scrubbing the number) sends
layer.setProps live with a per-drag coalesce key, so it is one history
step. The ▾ reuses the translated Opacity/Fill labels (no new strings)
and is disabled on the Background layer, like the fields.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 13:24:24 +07:00
Books 0bb46df638 fix(raw): a DNG with only DefaultCropSize is cropped from the active area's origin (#950) (#1595)
The DNG default crop was applied only when both DefaultCropOrigin and
DefaultCropSize were present, so a file carrying just the size was
developed at the whole active area. Each tag now takes its own default
from DNG 1.7.1.0: the origin (0, 0), the size the whole image. The
existing bounds check is unchanged, so an origin-only crop that no
longer fits still falls back to the active area with a warning, and a
tag with the wrong number of values is still reported as incomplete.

Fixes #950.
2026-10-09 13:24:21 +07:00
McClabe 7c15011410 fix: open Image command dialogs before applying (#1596) 2026-10-09 13:24:17 +07:00
kubuntugamer 967dcfd324 fix(io): a layered TIFF export warns that retained EXIF will be dropped (#1545) (#1597)
A TIFF export with --tiff-layers reported "EXIF not supported; it will be
dropped" even though the file kept the tags. export_layered puts EXIF into
the Photoshop image resources as 1058, which the TIFF writer emits as tag
34377 and the importer reads back, so reopening the file restored the
original tags. ExportResult.warnings is documented as notes about what an
export will lose, so the message was an incorrect loss report for an export
that loses nothing.

The TIFF codec has no EXIF directory, so the second copy handed to it in
meta.exif was never written; it only existed to make the codec warn. That
copy is gone, and with it the false warning. The flat-TIFF path still hands
the codec the tags, still drops them, and still says so.

The test is the issue's own claim checked in both directions: a layered
export reports no EXIF loss AND the Artist tag survives the round trip, so
the missing warning reflects a real absence of loss rather than a silenced
one; a flat export is the control and keeps warning. It fails on the exact
reported message before this change.
2026-10-09 13:24:14 +07:00
Claude f55bc64d50 fmt: rustfmt the #1547 save path and test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:24:07 +07:00
kubuntugamer 950eb1dd28 fix(automation): a pathless PSD or PSB save leaves the document dirty (#1547)
Headless::save recorded the saved revision only for .pcraft, so saving a
document back to its own PSD or PSB wrote the file correctly and still
left session.list reporting dirty: true. Automation clients that use the
flag could not tell a saved document from unsaved work.

The test is now "was this a layered document save" (PSD, PSB or .pcraft)
rather than "was this .pcraft". A flat export (PNG, JPEG, ...) stays a
copy: it leaves dirty and the document's path alone. The desktop app.save
path already recorded the revision for every format, so this only affects
the headless JSON-lines server and the MCP stdio server, which both route
through Headless::save.

Tests cover the reported sequence for both .psd and .psb -- create, reopen
the saved file, edit, then doc.save with no path -- plus a control that a
flat export is a copy. docs/control-protocol.md now states the outcome of
doc.save for the dirty flag and the document path.

Co-authored-by: Space Bunny Free <[email protected]>
2026-10-09 13:24:07 +07:00
buivanvu94 e80a691ecf fix(engine): report invalid index on document.inspect and document.activate (#1557) 2026-10-09 13:24:03 +07:00
buivanvu94 1ddda05394 fix(engine): use grammatical article for adjustment layer error messages (#1549) 2026-10-09 13:24:03 +07:00
BFlat f144ec788d Merge pull request #1633 from storytold/community/1599-1611-ui-set-rotate
Rotate View tool (#1611 by @mvanhorn) and ui_set describes every field (#1599 by @kubuntugamer), carried
2026-10-09 13:24:00 +07:00
BFlatandClaude 4a406a6cf6 Pipette cursor test: Windows reports the precise crosshair with its bitmap (Windows CI red since #1160) (#1626)
* Pipette cursor test: Windows reports the precise crosshair with its bitmap (#1160)

test (windows-latest) fails on main in input_tests::eyedropper_and_alt_sampling_show_a_pipette
("Precise keeps the crosshair": left Crosshair, right None). #1437 expected None on Windows because
the canvas drew the crosshair and hid the OS cursor; since #1160 Windows hands the OS a bitmap
cursor and tool_cursor's end-of-pass hook reports Crosshair underneath it. The test now expects
Crosshair everywhere, plus the bitmap on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* i18n(el): translate "Pick a screen pixel or press Esc to cancel"

The last screen color picker string #1618 missed: xtask's
source_key_set_is_covered_by_complete_catalogs still fails on main for el.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit 14ed88e399)

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 13:23:57 +07:00
BFlatandClaude df0fd003d8 i18n(el): translate "Pick a screen pixel or press Esc to cancel" (#1623)
The last screen color picker string #1618 missed: xtask's
source_key_set_is_covered_by_complete_catalogs still fails on main for el.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 13:23:54 +07:00
Elvis33LEandClaude Opus 5.5 f4d489eb96 Command lookup: index registered and shell command IDs (#1156)
Opening a menu or the command palette looked up command ids by scanning the whole command
list. The registry now keeps its ordered specs and an id-to-index map in one OnceLock (first
match wins for a duplicate id), and is_live caches the shell command ids in a set. A batch of
917 lookups drops from 0.157 ms to 0.013 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:26:44 -04:00
callmeBallochandClaude Opus 5.5 edb42922c1 AF.3: keep Affinity out of CI's corpus and fuzz jobs; state the clean-room provenance
Review on storytold/photocraft#1607 asked maintainers to decide on new
fuzz targets in ci.yml and four third-party corpus upstreams in
corpus --all. Until they do, neither touches CI: ci.yml is main's again
(the targets stay in crates/affinity/fuzz, run locally), and the
Affinity corpus is fetched only by corpus --affinity and tested with
--features corpus-affinity, so --all and test-corpus are unchanged.
The plan is to move those files into photocraft-corpus.

The crate README now spells out the provenance: no Affinity software,
afread's MIT description re-described without translating code, every
structure confirmed on public files (CRC-32 per entry, self-typed
fields), semantics fitted to Affinity's own thumbnails, and how the
sample documents were licensed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:19:56 +00:00
optimalbyteeandClaude Opus 5.5 f5a86628ed Speed up exact Shape Blur convolution (#1566)
Shape Blur now builds its span kernel once per operation instead of per tile. It folds
premultiplication into the f64 row prefix sums and adds whole output rows at a time, in the
same order as before. Output is bit-identical to the previous implementation, which stays in
the tests as an exact oracle. A 24 MP Circle r25 runs in 0.65 s instead of 1.04 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:19:09 -04:00
Claude 2da89df8dc i18n(el): drop #1611's screen picker rows that main's #1618 already has
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 05:16:41 +00:00
callmeBallochandClaude Opus 5.5 ade877f8e9 AF.3: count distinct sample documents in the value-budget note
The census behind the limit counted duplicate copies of the same public
files: there are 189 distinct documents, the largest still decoding 3.3
million values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:13:22 +00:00
Elvis33LEandClaude Opus 5.5 1452c49258 PSD save: stream Lr16/Lr32 layer blocks into the output (#1158)
Saving a 16- or 32-bit PSD built the Lr16/Lr32 layer payload in a temporary buffer and cloned
every global tagged block before copying them into the file. The payload is now written
straight into the output with its length patched afterwards, and the neighbouring blocks are
borrowed. Output is byte-identical: all 735 corpus PSD/PSBs re-save to the same bytes. A 24 MP
layer saves in 66 ms instead of 75 ms at 16-bit and 132 ms instead of 164 ms at 32-bit, and the
temporary 144-288 MB copy is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:13:22 -04:00
callmeBallochandClaude Opus 5.5 92207eb7c2 AF.3: the read-only Save test follows the asynchronous file dialog
Main now answers File › Save on an unsaved document with a file dialog
the next frame shows, rather than a blocking cancel. The test checks the
same thing that way: an Affinity document asks for a new file and
nothing is written over the source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:07:29 +00:00
Claude 52552d7127 Rotate View with ui_set: describe rotation, drop the duplicate Greek row, regenerate the scorecard
- #1599 tests that ui_set's description names every field ui.set accepts; #1611 adds
  `rotation` to ui.set, so the description now names it.
- #1611's el.tsv row for "Pick screen color" duplicates the one #1618 added on main.
- docs/scorecard.md regenerated with cargo xtask scorecard after merging both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 05:07:07 +00:00
Claude 31cf872333 Merge #1611 (mvanhorn): Rotate View tool
# Conflicts:
#	docs/scorecard.md
2026-10-09 05:06:50 +00:00
Claude 346e8e8775 Merge #1599 (kubuntugamer): MCP ui_set describes every accepted field 2026-10-09 05:06:46 +00:00
WilandClaude Opus 5.5 3091a2843c Actions: edit steps, nested action calls, F-key shortcuts and recorded Fit on Screen (#1093)
Steps can be deleted and dragged into a new order, live while recording. Playing an action
while recording stores one named actions.play call, and nested playback stops on a child
failure, rejects cycles and goes at most 16 levels deep. Right-click an action to give it
F1-F12 (saved in preferences). View > Fit on Screen (Cmd+0), Zoom In/Out and 100% record and
replay. New actions.move command; actions.delete takes a step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:03:35 -04:00
Brandon ThomasandClaude Opus 5.5 6e558d2fc9 i18n(el): translate the saved New Document preset strings (main red since #1516 + #1562) (#1620)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 00:57:06 -04:00
Jason XiangandClaude Opus 5.5 1bc25d363f New Document: save named presets (Saved category) (#1516)
Save Preset... in File > New stores width, height, resolution, mode, depth, background (with
its colour) and display units in preferences. They are listed under a Saved category, where a
preset can be selected or deleted. Adds document.presets.save/list/get/delete; get returns
params for file.new, which gains an optional backgroundColor. Bad saved entries are skipped one
at a time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:54:37 -04:00
Elvis33LEandClaude Opus 5.5 b7e0d3810d History: count retained tiles incrementally below the byte budget (#1157)
Every edit's History::trim rebuilt a set of every tile in every retained state, even well
under the budget. Retained tile references are now counted as states are recorded, evicted,
undone, redone, purged and cleared, and below the budget trim only looks at the current
document's tiles. Record plus trim on a 24 MP, 150-layer document with 50 states drops from
62 ms to under 2 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:48:31 -04:00
Brandon ThomasandClaude Opus 5.5 44d7902f08 i18n(el): translate the screen color picker strings (main red since #1232 + #1562) (#1618)
#1232 added four tl! strings while #1562 was in review, so every_tl_literal_is_translated
failed for el once both landed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 00:45:50 -04:00
BFlatandClaude fea4a000d0 full_res_preview test: wait again when the software rasterizer outlasts egui_kittest's 10 s render wait (#1593)
test (windows-latest) fails on main in adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off
with "PollError: The requested Wait timed out before the submission was completed.": egui_kittest
waits at most 10 s for the GPU, and on the Windows runner the adapter is WARP, where the first
full-resolution composite of the 6 MP test document can take longer. The submitted work still
completes, so the pixel read now waits up to three times; the assertions are unchanged.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 11:44:58 +07:00
jason4shortandClaude Opus 5.5 fe6e90e676 Dodge and Burn tone each pixel once per stroke (#1041)
Each dab re-applied the tone curve to pixels earlier dabs had already toned, so overlaps and
scrubbing compounded into blotchy results. A stroke now tones each pixel once from its original
colour, mixed in by the stroke's coverage, which builds up as a brush stroke's does, capped at
the set Exposure. Further strokes build on it, as in Photoshop. The originals are kept in dense
64 px cells: a 24 MP, size 500 stroke takes 1.7 s (6.0 s before). The live stroke matches the
commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:43:45 -04:00
Matt Van Horn 4f7bd1f28f Regenerate the scorecard after the Rotate View tool
Marks TOOL-214-17 done and drops the now-read trackpad rotate preference
from the unread list. The Paint Bucket test mapping carries the view's
camera angle.
2026-10-09 04:40:22 +00:00
Matt Van Horn 690dafd61b Add the Rotate View tool in the Hand flyout
Turns the canvas camera around its centre without rewriting pixels.
Reset View zeros the angle; Match Rotation copies it across documents.
Trackpad rotate follows Preferences › Enhanced Controls › Rotate View with Trackpad.
2026-10-09 04:40:22 +00:00
callmeBallochandClaude Opus 5.5 e2ad28838e AF.3: bound the values a document stream decodes
A value takes about 40 bytes in memory however few it took in the file,
and a bool array packs eight in a byte, so a small, highly compressed
doc.dat could declare billions of values and make the import abort on
allocation (review on storytold/vectorcraft#667). The parser now counts
fields and array elements against a 2^24 budget before allocating, and
reserves array storage fallibly, returning Error::Limit either way.
The largest of 333 public documents decodes 3.3 million values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:31:42 +00:00
Γιῶργος (George)andClaude Opus 5.5 1910b9f87c Add Greek (Ελληνικά) UI localization (#1562)
A complete Greek catalog (el.tsv) registered with the other complete-menu languages: menus,
dialogs, preferences, filters, tool names (Greek with the English name in parentheses) and
font weights, plus Greek Services / Hide Others / Show All in the macOS app menu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:31:00 -04:00
Arthur ViníciusandClaude Opus 5.5 9f5ef88e11 i18n(pt-br): separate View/Preview, Flip/Invert and Light/Thin; clearer Wayland notice (#1292)
pt-br labels that shared one translation with a different command now differ (Exibir,
Espelhar, Leve). The Wayland drag-and-drop notice reads as a full sentence, and "Default
Photoshop Size" fits its preset card. Puppet/Perspective Warp keep Photoshop's "Distorção
de...". An unused row with a garbled source key is dropped from fr, id, pl, pt-br and ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:30:55 -04:00
abc2cbandClaude Opus 5.5 0256eaa4e0 Type Color Picker opens the full Color Picker; screen eyedropper with a pixel loupe (#1232)
The Type options bar and Character panel swatches open the full Color Picker (Text Color),
applying to the selected characters or the whole layer via type.setStyle; Cancel is inert. A
shared "Pick screen color" action in the full dialog and compact popups samples the desktop:
X11/Windows/macOS snapshots with an 11x11 magnifying loupe (4x-24x), the XDG PickColor portal on
Wayland, the EyeDropper API on the web. Captures stay in memory, are bounded at 64 MP, and are
ICC-converted to sRGB where the display publishes a profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:30:50 -04:00
callmeBallochandClaude Opus 5.5 57ae2fb1a3 Merge storytold/main into AF.3 native Affinity import
Conflicts: File › Open and batch extension lists (SVG, swatches and
Affinity together); the import dispatcher (upstream's type-layer
preparation with the new result fields); the pinned EXR and Affinity
corpora side by side; README, book and development docs.

Affinity type layers now draw through text_import::prepare, so they get
the same raster bound as other imported type, and SVG import fills the
two new ImportResult fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:26:18 +00:00
MerleandClaude Opus 5.5 8d95a564e3 Windows: brush tips and crosshairs are native bitmap cursors (#1160)
Brush circles and precise crosshairs go to the OS through egui's CustomCursorImage, cached by
shape and DPI, so they track the pointer without waiting for a canvas frame. A per-pass plugin
clears stale images when the pointer leaves the canvas or the tool changes. Tips past winit's
2048 px limit keep the painted outline with a native crosshair at the hotspot. Other platforms
and immediate viewports keep the painter path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:25:53 -04:00
buivanvu94andClaude Opus 5.5 873e92dbaa Delete and Backspace delete the layer while a panel widget has focus (#1565)
A focused non-text widget (a Layers row on Windows) reserved Delete/Backspace as navigation
keys, so the shortcut never fired (#1534). They now reach Delete Layer / Edit > Clear unless
the focused widget uses them itself: the Curves graph claims them while focused
(claim_delete_keys), so Delete still removes its selected point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:20:00 -04:00
Willy Foo 64496178d0 fix(ui): release closed-document canvas and navigator textures 2026-10-09 11:12:52 +07:00
Willy Foo 83d1f3115d perf(algo): specialize photo blur and resample channel loops 2026-10-09 11:12:52 +07:00
Nigel Ihza Firdausy d80455ad25 feat(ui): edit independent rectangle corner radii with narrow-panel controls and history tests 2026-10-09 11:12:49 +07:00
Nigel Ihza Firdausy ce35c1455b fix(pen): reshape the final outgoing handle while preserving incoming geometry and linkage 2026-10-09 11:12:49 +07:00
Claude cff8daf185 automation test: as_chunks for the preview pixel check (clippy 1.99 chunks_exact_to_as_chunks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit 7406c26bd4)
2026-10-09 11:12:46 +07:00
harrisonoconnorhover 0e0311d245 Reject unsupported document indices in MCP screenshots 2026-10-09 11:12:46 +07:00
Harrison O'Connor-Hoover 79e783bcdf Record Transform Again as one concrete action step (#1440) 2026-10-09 11:12:38 +07:00
MarekSpodBiedry fb71daf2c6 Control channel: ui.click rejects a count above 256 instead of queueing unbounded input (#982) (#1446) 2026-10-09 11:12:34 +07:00
MarekSpodBiedry eb06c9e105 Opening a file with different horizontal and vertical resolutions says the vertical one is not kept (#1018) (#1445) 2026-10-09 11:12:30 +07:00
MarekSpodBiedry 746f2530e6 Plug-in names reject bidi controls, zero-width spaces and other invisible format characters, and need a visible character (#1011) (#1444) 2026-10-09 11:12:27 +07:00
MarekSpodBiedry 9ce06090b1 Plug-ins: a missing parameter's default is clamped to the declared min..max (#1012) (#1443) 2026-10-09 11:12:24 +07:00
kayrockscreenprintingandClaude Opus 5.5 7ad869eb32 Canvas: a huge view centre draws the selection outline without overflow (#980) (#1441)
`ui.set` takes any finite centre, and the selection outline quantised the
visible document region with unchecked i32 arithmetic. The float-to-i32
casts saturate, then the padding overflowed: `attempt to multiply with
overflow` for a large positive centre and `attempt to subtract with
overflow` for a large negative one, panicking the canvas frame outside any
dispatch guard. Release builds wrapped to an inverted, empty region.

The visible corners are now clamped to ±2³⁰ before the cast. No document
reaches that far, and the padding (at most 2 · 256 · 64 px when zoomed far
out) can't overflow i32 from there. The fix stays in canvas.rs, so ui.set
(which #820 rewrites) is unchanged.

The test draws a canvas frame with a selection after `ui.set` centres of
±1e30 and (3e9, -3e9), at 100 % and at 1/64 zoom (the largest quantisation
step), and checks that a centre on the selection still traces the outline.
It panicked at the multiply before the fix.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:12:21 +07:00
kayrockscreenprintingandClaude Opus 5.5 e486262d3e Divide Slice: one division makes at most 10000 parts (#1020) (#1504)
slice.divide clamped each axis to 1..1000 but checked no total, so one
request made up to a million slices, and resolving the slice list
(slices::resolve, used by slice.list and export) is quadratic in the slice
count: about 5e11 rectangle visits at a million.

A division over 10000 parts (100 x 100) is now refused with a bad-params
error before any part is created; the params doc says so. Resolving 10000
slices is about 5e7 visits, and the test lists them in well under a second.

The test refuses 101 x 100, 100 x 101 and 1000 x 1000 with the slices and
history unchanged, then divides 100 x 100 into 10000 parts and lists them.
Before the fix 101 x 100 was accepted (10100 parts).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:12:18 +07:00
David MannandClaude Opus 5.5 62fd1342dd Font menu keeps its height and focuses its search field (#1369) (#1505)
egui measures a popup once, when it opens, and never lets it grow back,
so filtering the font list left the menu short after the query was
cleared. While searching, the list now keeps the height it measured
unfiltered.

The search field was only focused while the query was empty, so a
reopened menu with a leftover query had no focus; it is now focused on
every open. The ComboBox closed on any click, including on the search
field; it now closes on clicks outside, and picking a font closes it.

Fixes #1369

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:12:15 +07:00
David MannandClaude Opus 5.5 48f78a9e1e Help › Search: keep the field focused after a click opens Help (#1447, #1438) (#1507)
Both reports were filed against v0.5.0, which predates e63ff519 (#1027):
there the Help menu used egui's CloseOnClick, so any click inside it,
including on the search field, closed it. The egui menu bar is what
Windows (and Linux) show; macOS uses the native menu bar, hence "on
Windows". That close is fixed on main but not yet released.

What remained on main: menus open on the press, and the search field
asks for focus then, but the release a frame later completes a click on
the Help title, outside the field, so egui (SurrenderFocusOn::Clicks)
took the focus away again. After clicking Help, typing went nowhere
until the field was clicked. The field now asks for focus again on that
release, as on a fresh opening.

The new whole-app kittest drives real clicks (press and release in
separate frames) with and without the custom title bar and at 1.25x and
1.5x scale. It checks the field is focused after Help opens, that typing
searches at once, and that clicking the field and typing keeps Help
open. It fails without this change (focus) and with v0.5.0's
CloseOnClick for Help (menu closes).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:12:09 +07:00
UnemarsandClaude Opus 5.5 6054a8a019 Crop tool: show the pixels past the canvas while a frame is edited, as Photoshop does (#1508)
A crop with Delete Cropped Pixels off keeps what it cuts away (and a moved layer can reach past
the edges), but the Crop tool drew only the canvas, so a frame couldn't be pulled back out over
those pixels by eye. Photoshop 25.4 (observed) grows the view to every layer's bounds and the
frame from the first press on the frame: hidden pixels under the shield, transparency as the
checkerboard, no canvas border or shadow, until the crop is committed or cancelled.

- crop_ui: `CropState::editing`, set by the press (before egui's drag threshold) and cleared when
  a new default frame is made (commit, Esc) or the tool is left; `shows_beyond_canvas`.
- canvas: `draw_beyond_canvas` composites only the ring between the canvas and every layer's
  pixels (`extra_cmds::reveal_all_bounds`, shared with Image > Reveal All), once per document
  state, at most MAX_TEXTURE texels per side (from a downsampled proxy past that), and draws it
  with the checkerboard out to the frame under the canvas, reaching a point under its edge so the
  edge's anti-aliasing has no seam. The drop shadow and the Line border are off meanwhile.
- compose: `render_reduced_rect`, the reduced render of any area of the document plane.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:12:05 +07:00
David MannandClaude Opus 5.5 284724af37 TIFF: alpha after an unspecified extra sample opens transparent (#1349) (#1513)
TIFF 6.0 allows the alpha at any ExtraSamples position, but the decoder
only looked at the first extra sample, and for RGB/CMYK the tiff crate
drops every sample after its colour type. With ExtraSamples [0, 2] the
alpha was silently lost (and gray [0, 2] opened as plain gray).

The alpha is now the first extra sample marked associated (1) or
unassociated (2), wherever it is. When it is not the first extra sample
of an RGB or CMYK image, the crate is asked for every sample (the
photometric is patched to multiband gray, as for WhiteIsZero) and the
colour and alpha samples are picked per chunk; planar files read the
alpha's own plane. Associated alpha in that position is made straight.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:12:00 +07:00
David MannandClaude Opus 5.5 a9fa35ab96 PSD: warn that Color Lookup tetrahedral interpolation is saved as trilinear (#1347) (#1515)
Photoshop's clrL descriptor (lookupType, NM, Dthr, profile, LUTFormat,
dataOrder, tableOrder, LUT3DFileData, LUT3DFileName) has no interpolation
setting, so a tetrahedral Color Lookup layer reopens trilinear and renders
differently. PSD save now reports that in its warnings instead of changing
the composite silently. Trilinear lookups save without a warning.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:11:55 +07:00
David MannandClaude Opus 5.5 4dcb2e4088 Undo: a paint stroke's undo keeps the painted layer active (#1356) (#1523)
Selecting a layer is not a history step, so each state kept the target it
had when it was created. Undoing a step therefore jumped back to whatever
was selected when the previous step was taken: rasterize a shape with the
Background active, select the rasterized layer, paint, Ctrl+Z, and the
Background became active again.

The state a step leaves behind now targets what was selected just before
the step (Session::edit and compound steps set the current state's target
before recording), as in Photoshop: undo of a stroke or fill keeps the
painted layer, undo of New Layer or Merge goes back to the layers selected
before it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:11:50 +07:00
Harrison O'Connor-Hoover 672c0775de Reject control requests that expire before dispatch (#1525) 2026-10-09 11:11:47 +07:00
Harrison O'Connor-Hoover de9cc5f12c Do not replay bridge requests after an uncertain result (#1527) 2026-10-09 11:11:44 +07:00
David MannandClaude Opus 5.5 9b08da8d98 Free Transform: the reference point moves with the box when scaling (#1329) (#1535)
In Photoshop the reference point is attached to the bounding box: scaling
from a corner or edge, skewing, distorting and perspective carry it along
so it keeps its place within the box (the centre stays the centre, a
corner reference point stays on its corner). PhotoCraft left it at its old
canvas position for all of those drags.

After each of these drags the reference point is now mapped through the
box: its position in the old box's unit frame is mapped onto the new box
(carry_pivot). Alt-scaling, which scales about the reference point, and
rotating about it still leave it put; moving and the numeric options bar
already behaved correctly and are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:11:40 +07:00
kubuntugamer 756ee3de87 fix(automation): MCP ui_set omits three fields it accepts (#1548)
The ui_set tool's `fields` parameter description listed 18 of the 21
fields the desktop `ui.set` control method accepts, omitting colorPanel,
gradientBlendMode and gradientClassic, and then ended with "Other fields
are an error". An agent that copies the list out of tools/list is told
three working fields are invalid.

The description now names all three, in the order UI_SET_FIELDS lists
them, with what each takes: colorPanel ({background: bool}) and
gradientClassic (bool) and gradientBlendMode (a blend mode name).

The new test reads UI_SET_FIELDS from the desktop shell's source and
asserts the schema names every entry, so the list cannot drift again --
which is how this omission arrived. It fails on colorPanel before this
change, and also fails if a later field is added to UI_SET_FIELDS without
updating the description. The parse is anchored on the declaration and
checked against its declared array length, because the doc comment
mentions UI_SET_FIELDS first and scraping that reads unrelated names.

Co-authored-by: Space Bunny Free <[email protected]>
2026-10-08 23:00:31 -05:00
BFlatandClaude 3e8d54896d Fix #1531: duplicate artboards beside the original, drag boards with the Move tool (#1584)
- Layer > Duplicate on an artboard stacked the copy exactly on the original, so
  nothing visible happened. The copy now goes right of the board (100 px gap,
  sliding past boards in the way) with its contents, and the canvas grows to
  show it, in one undo step (single and multi-layer duplicate). `inPlace: true`
  keeps the old behaviour; the Move tool's Alt-drag and Alt+Cmd+T use it.
- Move tool Auto-Select ignored artboards: a press on an empty spot of a board
  picked the Background under it, so the drag never moved the board. A click
  there now picks the board (layers on it first, clipped to the board; Group
  mode stops inside the board), and the drag moves the board with its contents.
- Moving a board past the right/bottom edge grows the canvas, as a new board
  does, instead of cutting it off.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 10:54:15 +07:00
BFlatandClaude b693d690e1 docs: MCP tool names and job tools, codec README JPEG metadata and deep EXR (#1552, #1553, #1559) (#1573)
- architecture.md listed dotted MCP tool names (doc.open, command.run...) that the server
  rejects; it now uses the advertised underscore names, adds the missing tools, and points
  out that dotted names belong to the JSON control protocol.
- development.md's MCP tool inventory gains jobs_list and jobs_cancel.
- crates/codecs/README.md: oversized JPEG EXIF/XMP is dropped with a warning (XMP first sheds
  its layered-document properties), not an error; supported deep EXR opens flattened with a
  DeepFlattened warning, and only deep ZIP and mip/rip-map tiles are unsupported.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 10:23:19 +07:00
Claude 26de59f54c automation test: new fill and adjustment layers' path is a vector path (#1419)
#1539 gives layer.newFillLayer.* and layer.newAdjustmentLayer.* a "path" parameter (the
vector mask), which registry_filesystem_path_params_are_classified took for a file path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:16:12 +07:00
David MannandClaude Opus 5.5 99b6aff26b Fill and adjustment layers take the active path as their vector mask (#1419)
In Photoshop, when a path is selected in the Paths panel (the work path
just drawn with the Pen or made from a selection, or a saved path), Layer
> New Fill Layer > Solid Color/Gradient/Pattern and every new adjustment
layer get a vector mask made from that path: a Solid Color fill with a
vector mask is a shape layer, editable as a vector. The path wins over an
active selection; with no path selected the selection becomes a layer
mask (#1250), else there is no mask. PhotoCraft always used the selection
and ignored the path, so Solid Color filled the whole canvas.

Engine: the new fill and adjustment layer commands take an optional
"path" param ("work", a saved path's name or a path object). The shared
helper mask_new_layer turns it into the layer's vector mask and falls back
to the selection mask without it; an unknown name is an error, not a
panic.

UI: run_command passes the path selected in the Paths panel as "path"
(callers can opt out with "path": null). Drawing a work path with the Pen
and Make Work Path select it, as in Photoshop, and clicking the panel's
empty area deselects it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 10:16:12 +07:00
Xus BadiaandClaude Opus 5.5 bfc59c784f macOS DMG: branded window background and icon layout (#1201)
The DMG opens on a 660x400 Finder window with the PhotoCraft artwork, 128 pt icons and the
Applications link beside the app. packaging/macos/dmg/ holds the SVG source, a reproducible
generator, an 87 KB HiDPI palette TIFF (was 1.0 MB) and the .DS_Store layout. The volume is
named "PhotoCraft" (the background alias resolves by volume name); the DMG file name keeps
the version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 23:11:00 -04:00
Filip PłachecińskiandClaude Opus 5.5 f0ae12dd22 Self-host the web app with Docker (nginx, community-maintained) (#1334)
A multi-stage Dockerfile builds photocraft-web with Trunk and serves dist/web from an
unprivileged nginx on :8080, with /healthz, precompressed gzip, the wasm MIME type and
immutable hashed assets. Rust and Trunk follow the release workflow, and the
PHOTOCRAFT_BUILD_SHA/DATE build args stamp About. Documented in packaging/web/README.md;
not built in CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 23:10:55 -04:00
BFlatandClaude 9c554e2ead Fix #1544: Image Size refuses a resample past the raster budget; resampling writes bands as it goes (#1569)
image.imageSize accepted 64x48 -> 300000x300000 (90 billion pixels, each side within the
300000 px limit) and started allocating destination-sized f32 bands. The command now
refuses a resampled canvas whose raster alone would pass the decoders' default allocation
budget (8 GiB on 64-bit), with an actionable message, before touching the document;
resolution-only changes (Resample off) are unaffected. The resampler also writes its bands
into the output a group at a time instead of collecting every band first, so the f32 rows
held at once are bounded by the thread count, not the destination size.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 10:03:08 +07:00
BFlatandClaude f82a55f056 Fix #1543: oversized Satin and Bevel & Emboss sizes render instead of panicking (#1563)
A Satin size or a Bevel & Emboss size or softness of 1e308 reached the
box blur unclamped: the kernel radius saturated to ~2^62 taps and the
render panicked with "capacity overflow"; 1e6 ran for minutes. Drop
Shadow survived because its reach is clamped in margin(). The blur width
is now capped at MAX_REACH (and NaN is 1) in one place, box_width, which
both the CPU tent and the GPU's tent_kernel go through, so values from
commands and from files are bounded alike.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 09:47:13 +07:00
Harrison O'Connor-Hoover 47ad748638 Use rotated preview dimensions on the GPU canvas (#1459) 2026-10-09 09:02:10 +07:00
StreetblockandGPT-6 7fd610741b Fix PNG text metadata decode budget bypass (#1460)
Bound decoded PNG text and XMP by one shared max_alloc budget before
growing output, retaining CRC validation and malformed ancillary handling.
Cover compressed, plain, Unicode and post-IDAT metadata with small
synthetic regression fixtures, and document the budget's scope.

Opt Streetblock into public GitHub profile-name credits at their request.

Co-authored-by: GPT-6 (Codex) <[email protected]>
2026-10-09 09:02:07 +07:00
kayrockscreenprintingandClaude Opus 5.5 fd74419aeb Crop: an explicit rectangle past 300000 px per side is refused (#960) (#1470)
image.crop took any explicit width and height, so a crop to 100000000 x
100000000 succeeded (the tiles are lazy) and left a canvas that a following
full-canvas flatten, such as image.trim or image.duplicate with mergedOnly,
could only abort on allocating.

An explicit crop now refuses a side over 300000 px with a bad-params error,
before the document changes. That is the limit file.new, Image Size and
Canvas Size already use. Extending the canvas up to it still works.

The test checks 1e8 x 1e8, 300001 x 10 and 10 x 300001 are refused with
the document and history unchanged, and that 300000 x 2 still extends the
canvas. It failed before the fix (the 1e8 crop returned Ok).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:02:04 +07:00
optimalbyteeandoptimalbytee c7dbb37b3c Speed up wide Motion Blur with adaptive FFT convolution (#1471)
Keep the merged upstream row kernel for shorter streaks and unsupported inputs.
Use bounded overlap-save convolution of its merged bilinear coefficients when
channel pairs, parallelism and memory estimates favor FFT. Preserve alpha cutoff
behavior, cancellation, selections and depth/model-neutral filtering.

Validate 303 algorithm tests in debug and release, plus native checks and full
image comparisons. Repeated current-upstream comparisons show 2.31x at 24 MP
and 4.15x at 1.5 MP for angle 30 / distance 1000 on the measured host.

Co-authored-by: optimalbytee <[email protected]>
2026-10-09 09:02:00 +07:00
Nigel Ihza Firdausy 0d058f4c29 fix(psd): write Photoshop-compatible Photo Filter adjustment blocks (#1473)
* fix(psd): emit Photo Filter phfl block with required four-byte alignment (#1455)

* test(psd): golden phfl length, padding, legacy compatibility and Photo Filter variants

* test(psd): compare quantized Photo Filter channels with encoding tolerance

* fix(psd): normalize legacy 18-byte Photo Filter blocks on re-save

* test(psd): full exporter/importer roundtrip and repair of legacy invalid phfl blocks

* style(psd): format Photo Filter golden-block regression test

* style(psd): apply rustfmt layout to exporter compatibility tests
2026-10-09 09:01:57 +07:00
UnemarsandClaude Opus 5.5 d6aeb970ee Camera RAW: a measured Nikon D4 profile (image area, white level, colour matrix, tone curve) (#1475)
NEF carries no colour description, so D4 frames developed with the camera channels taken as
sRGB primaries and no tone curve (dark, pale), the sensor's optical-black columns showed as a
purple stripe on the right, and clipped highlights turned pink (green saturates first).

crates/raw/src/cameras.rs holds camera profiles measured from the cameras' own output (no
third-party tables), applied to TIFF/EP raws by make, model and sensor size. Sensor gains
tone_curve (like DNG ProfileToneCurve, per channel in linear ProPhoto), folded into the gamma
LUT; other cameras develop exactly as before.

Nikon D4, from 148 uncompressed NEFs of one body (ISO 100–10000):
- image area 4928x3280 at (8, 6): where the camera's own full-size JPEG sits, found by aligning
  the two (2 columns of optical black on the left, 50 + 8 on the right);
- black 0 (the data is black-subtracted in camera: the darkest image pixels reach 0);
- white 15520: green saturates at 15546–15672, red and blue at 15687–16383, so one level just
  below green makes clipped highlights white without moving the white balance;
- ForwardMatrix (white-balanced camera → XYZ D50) and tone curve fitted to the embedded JPEGs
  (Picture Control Standard, Active D-Lighting off; 52 frames, smooth areas). On 15 held-out
  frames PhotoCraft's development is 7.0/255 per channel from the camera's JPEG on average,
  against 34 before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 09:01:54 +07:00
kayrockscreenprintingandClaude Opus 5.5 b2003a4329 .pcraft: a tile index outside the i32 pixel range is a corrupt file (#938) (#1483)
The loader accepted a manifest tile at any i32 index. TileCoord::rect
multiplies the index by the tile size, so an index past ±2²³ loaded fine
and then panicked ("attempt to multiply with overflow", geom/src/lib.rs)
wherever the layer's bounds were asked for: the status bar, Properties,
edit.transform, the compositor. Release builds wrapped to a rectangle
unrelated to the tile.

Loading now refuses a tile whose rectangle doesn't fit in i32 on both edges
(indices -8388608 ..= 8388606) with FormatError::Corrupt, as it refuses a
duplicate tile. In-app editing can't produce such an index; only a damaged
or crafted manifest can.

The test moves a raster layer's tile to just past each end of the range and
to i32::MAX / i32::MIN (refused), and to the extreme valid indices (loaded,
bounds computed). Before the fix the out-of-range bundles loaded, and asking
the layer for its content bounds panicked at the multiply.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:01:51 +07:00
David MannandClaude Opus 5.5 17be581390 Liquify: Alt reverses Twirl and swaps Pucker/Bloat, as in Photoshop (#1442) (#1484)
Holding Alt (Option) at pointer-down draws the stroke with the selected
brush's opposite: Twirl Clockwise twirls counterclockwise (and back),
Pucker bloats and Bloat puckers. The selected tool is left unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 09:01:48 +07:00
Jeremy 3955bf4f19 Canvas: checker moves with the image; pixel grid from 500%, over pixels only (#1491)
The GPU canvas anchored the checker to the top-left of the canvas viewport,
while the pixel grid sits on document pixel edges, so panning or zooming
slid the checker under the image and out of step with the grid. The checker
is now measured from the document origin; its 8 screen pixel cell is unchanged.

The pixel grid appeared from 800% (1200% on the CPU fallback), was faint and
was drawn across empty pixels. It now shows above 500%, lightens a dark pixel
by a quarter, and is skipped where the pixel is transparent (GPU canvas).

New offscreen render tests in tests/checker_canvas.rs cover cell size at nine
zooms, the phase, the grid threshold and strength, and the grid over empty
checker.
2026-10-09 09:01:45 +07:00
kayrockscreenprintingandClaude Opus 5.5 3f7d8afc78 Puppet Warp: pins that stretch the content too far are refused (#1019) (#1492)
edit.puppetWarp accepted pin destinations that are finite but arbitrarily
far away, and the triangle renderer sizes its tile, bin and lookup
collections from the deformed mesh's bounds: a pin dragged to (2^20, 2^20)
meant 17.6 million tiles (at least 846 MB of bookkeeping) before a pixel
was drawn.

- algo: MAX_WARP_PIXELS (2^30, about 16000 tiles; the Select > Modify
  budget). puppet_warp returns None when the deformed mesh's bounding box
  exceeds it or isn't finite, checked after the solve and before render.
- engine: edit.puppetWarp returns a bad-params error and the edit is
  discarded (layer unchanged, no history step). run_on_layer's and
  deform_selected's warp callbacks now return Result (a LayerFn alias);
  Liquify and Perspective Warp wrap theirs in Ok.
- A stored Puppet Warp smart filter too far to render leaves the smart
  object's pixels as they were.

Tests: algo (far pins refused, a near pin renders) and engine (the issue's
command refused at 8/16/32-bit, with and without a selection, layer and
history unchanged; a rigid move and a 4000 px pin still work; smart object
unchanged).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:01:42 +07:00
Harrison O'Connor-Hoover 5355221f81 Preserve document depth when splitting vector shapes (#1495) 2026-10-09 09:01:39 +07:00
David MannandClaude Opus 5.5 086d78a023 Apply the transform when another layer is selected (#1400) (#1496)
While Free Transform was open (Ctrl+T, or after dragging a Move-tool
transform control), selecting another layer in the Layers panel left the
box and the lifted preview on the old layer. end_if_left only ended the
session when its layer or document went away or the tool changed.

It now also commits the session when the active layer is no longer the
one being transformed, like Photoshop applying the transform on a layer
switch; the next transform (or the Move tool's controls) then starts on
the newly selected layer. Transform Selection is not tied to a layer and
stays open.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 09:01:36 +07:00
kayrockscreenprintingandClaude Opus 5.5 6c4d4e9dc9 Mesh warp of a gray surface writes the old and warped regions separately (#995) (#1500)
warp_mesh_gray (select.transformSelection with a warp mesh) cleared the
source and warped regions as one dense buffer over their union, so a mesh
that moves a selection far away allocated a buffer growing with the square
of the distance: about 37 GiB of floats for a 48 px selection moved
100000 px. b70fbd4 fixed the same thing in the homography path
(transform_cmds::warp_gray); this does the same here: clear the old region,
then write the warped one. Surface is tile-sparse, so a distant region
costs only its own tiles.

Moving a 48 px selection 8192 px, peak RSS of a test binary went from
346 MB to 3 MB (0.41 s to under 0.01 s).

The test warps 8- and 32-bit gray selections by ±100000 px and 300 px and
checks the result is the translated block with the old region cleared.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:01:33 +07:00
David MannandClaude Opus 5.5 2aa1eeadcc Selection tools: arrow keys nudge the selection outline (#1428) (#1502)
With a marquee, lasso, wand or other selection tool active, the arrow
keys now nudge the selection outline 1 px (Shift: 10 px) through
select.transformSelection, one undo step per press; the pixels stay put.
A floating piece (Cmd-dragged pixels) moves instead. Before, the arrows
did nothing unless the Move tool was active.

Also fixes the Move tool's arrow-key modifiers: egui's consume_key
ignores extra Shift/Alt, so the plain arrow matched first and Shift+arrow
nudged 1 px instead of 10 and Alt+arrow did not duplicate. The most
specific modifiers are now tried first.

Dragging inside the selection already moved the outline, with the move
cursor on hover (#686); a regression test now covers the cursor.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 09:01:30 +07:00
BFlatandClaude Opus 5.5 31cb502caf Fix #1313: Tab hides all panels, Shift+Tab the panel dock, as in Photoshop (#1388)
* Fix #1313: Tab hides all panels, Shift+Tab the panel dock, as in Photoshop

The right-hand panel dock could not be hidden. Photoshop's keys now work:
Tab hides the Tools panel, the options bar and the dock (Tab again brings
back what it hid), and Shift+Tab hides and shows the dock alone. Both are
shell commands (window.togglePanels, window.toggle.dock), remappable in
Keyboard Shortcuts and runnable over the control channel; a text field or
focused widget keeps Tab for navigation. Translated in every shipped
language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Tab hides all panels: Polish translations

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 09:01:02 +07:00
BFlatandClaude Opus 5.5 b58b8df36b Non-Unicode arguments: translate the desktop notice (follow-up to #1168) (#1359)
* Non-Unicode arguments: translate the notice; keep run's doc comment on run

The "Could not open" notice for a path that is not valid Unicode is
translated in every shipped language, and the CLI's "Run the CLI" doc
comment moves back onto run (run_os has its own).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Non-Unicode arguments: Polish translations

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 09:00:44 +07:00
c04624d119 Live strokes for the History Brush, Healing Brush and the dab tools (#1040)
* Live strokes for the History Brush, Healing Brush and the dab tools

The Clone Stamp's live stroke now covers every brush-based retouching tool:

- History Brush and Healing Brush: LiveClone becomes LiveRetouch, which
  paints from the clone source or the History Brush's history state
  (history_source, shared with the commit). The Healing Brush shows its
  cloned texture while drawing and blends it on release; it also gets the
  source preview under the brush.
- Dodge, Burn, Sponge, Blur, Sharpen and Smudge: their settings are parsed
  once into DabTool, whose per-dab effect both the command (dab_cmd) and the
  new LiveDab use. LiveDab runs each new dab's effect on a working copy of
  the target, in order, and mixes the changed area back at the brush
  opacity with mix_back (split out of apply_dab_stroke), as the commit does.
  Sample All Layers strokes keep the trail (their composite spans the whole
  stroke).

Part of #213 (live previews for brush-based tools).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Format live retouch test

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Trail test: use Spot Healing, which still previews a trail

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* LiveDab::push: read new dabs with get(), no slice-index panic path

Rebased onto #1026 as rebased on main; LiveRetouch::begin already reads the
first stroke point with first() (resolved in the rebase) and the Healing
Brush's own source overlay is dropped with clone_overlay.rs in favour of
main's Clone Source preview (#1042).

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 21:58:21 -04:00
BFlatandClaude Opus 5.5 ad857f1c93 Wayland: drop notice offers paste and the XWayland command for this install (#338, #386) (#1073)
* Wayland: drop guidance offers paste and this install's XWayland command (#338, #386)

winit 0.30 (eframe 0.36) delivers no file drops on Wayland. The notice told every
user to run `WAYLAND_DISPLAY= photocraft`, which does nothing for the AppImage
(no `photocraft` on PATH) or the Flatpak (fallback-x11 grants no X socket while
Wayland is available). It now names the command for this install (Flatpak flags,
the quoted $APPIMAGE path, or `photocraft`), omits it without an X server, and
offers pasting a copied image file with Edit › Paste's shortcut, which works on
Wayland since #384. The start screen hint on Wayland says the same.

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

* Wayland drop notice: Polish translations

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 08:57:26 +07:00
7892365f93 Clone Stamp: live stroke, strokes start on the press, source preview under the brush (#1026)
* Clone Stamp: show the stroke live while it is drawn

The Clone Stamp painted a translucent grey trail while dragging and only
showed the cloned pixels on release. It now renders live, like the Brush:
`retouch_cmds::LiveClone` feeds the same StrokeRenderer the commit's
coverage uses and, for the tiles each step changes, redraws them from the
pre-stroke pixels with the clone source sampled through the coverage so far
(the same `clone_sample` / `apply_coverage` as `paint.cloneStamp`), so the
preview is what the commit paints. `StrokeRenderer` gains
`take_dirty_rect` / `coverage_in`; `clone_source::resolve` resolves the
mapping without storing the aligned anchor (the commit still stores it).
The canvas's live stroke holds either engine stroke; the Clone Stamp's
params come from one place shared with the commit (`clone_params`).

Part of #213 (live previews for brush-based tools).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Painting strokes start on the press

The Brush, Pencil, Eraser and Clone Stamp started their stroke only once the
pointer moved past egui's click distance, so nothing showed on the press and
the first few pixels of movement arrived at once (a visible jump). A press
on the canvas with a live-stroking tool now starts the stroke immediately:
the first dab shows at once, the drag egui recognises later continues the
same stroke, and the release ends it, whether egui saw a drag, a click (no
second dab) or a long press that never moved. Other tools keep the click
distance.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Clone Stamp live stroke: show the first dab on the press

A single point makes no dab until the stroke finishes, so the live Clone
Stamp drew nothing on the press and the stamp only appeared on release.
LiveClone now also draws what finishing the stroke would add (the renderer's
tail_preview: a lone first dab, the smoothing catch-up) and redraws it on
every step, as the Brush's live stroke does: the stamp shows on the press.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Press-start strokes use the press event's modifiers

A Shift that arrives with the click (not yet in the frame's modifier state)
was dropped, so a Shift-click no longer connected to the previous stroke.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Box the live brush stroke in EngineStroke (clippy large_enum_variant)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Format live Clone Stamp code

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Live Clone Stamp: follow clone_sample's checked signature

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Live Clone Stamp: use main's source preview (#1042), no panic path on the first point

The rebase drops this PR's own source overlay (clone_overlay.rs) in favour of
the Clone Source panel preview that landed in #1042; remove its leftover module
line. LiveClone::begin reads the first stroke point with first() rather than
indexing.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 21:53:52 -04:00
9cdcfbb1b7 Fix startup panic when UI Font Size is not Small. (#1467)
The font-size plugin was rescaling egui's default fonts on the first frame and overwriting the queued Inter stack, so Name("medium") was unbound on the next pass.

Co-authored-by: sunyd168 <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-09 08:37:01 +07:00
BFlatandClaude cb17db9a81 Numeric fields: arrow-key steps never clamp with a reversed or NaN range (#1450)
f32::clamp panics when the range is reversed or NaN, which a value
field's DragValue range tolerates; the step keeps the value unclamped
then.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 08:36:51 +07:00
MarekSpodBiedry c653e36f1f Show Extras Options: checkboxes read and set the Show flags they name (#957) (#1320) 2026-10-09 08:36:48 +07:00
MarekSpodBiedry c77e4122ce Control server: a bad JSON reply carries "id": null like every other unknown-id error (#1006) (#1304) 2026-10-09 08:36:44 +07:00
MarekSpodBiedry 84cca02d68 type.create: an unknown or out-of-range kerning is a bad-params error, as in type.edit (#994) (#1303) 2026-10-09 08:36:41 +07:00
Brandon ThomasandClaude Opus 5.5 0b5803500c Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1530)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 21:24:37 -04:00
Brandon ThomasandTanish Manku f5a5a5c9ee Surface Blur: 4x to 20x faster on 16-bit and 32-bit layers (ranked window sums) (#1529)
The 8-bit sliding histogram (#631) can't serve 16-bit or float layers: a 16-bit threshold spans
thousands of levels and floats have none. The weight `1 - |v - v0| / t` is linear on each side of
the centre value, so the weighted window sum only needs the count, Σv and Σv² of the samples within
the threshold below and above it. A Fenwick tree over the window's distinct values holds those and
slides along the row, so a pixel costs O(radius · log n) instead of O(radius²), for any depth,
with the direct sum's own weight test deciding which samples count. Used from radius 10, where it
overtakes the direct sum; non-finite samples keep the direct sum.

Co-authored-by: Tanish Manku <[email protected]>
2026-10-08 21:23:19 -04:00
02984af969 Color Range: improve previews, sampling and eyedropper responsiveness (#1377)
* Refine Color Range preview sampling and add selection preview modes

* Avoid formatting ICC profiles on Color Range hover

* Color Range preview honours sampleAllLayers like OK; drop the env-var evidence/benchmark tests; Polish rows

The Sampled Colors preview always judged the composite while select.colorRange
judges the active layer for sampleAllLayers:false, so preview and OK could
disagree. It now reads the active layer's proxy pixels in that case (test:
preview_honours_sample_all_layers_like_ok). The two #[ignore]d tests that
wrote PNGs to COLOR_RANGE_EVIDENCE_DIR and timed a 24 MP hover are removed.
pl.tsv gets rows for the new strings.

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

---------

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 21:22:03 -04:00
Brandon ThomasandClaude Opus 5.5 5341bff255 Swatches import/export and the Export As test use the async file dialog (#1528)
#1071 replaced Services::pick_open/pick_save with the async file_dialog
service while #886 (Swatches) and #1394 (Export As scaling test) still used
the old fields, so ui-egui stopped compiling once all three were on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 21:18:04 -04:00
BFlatandClaude Opus 5.5 ccadeef41a Fix #1357: every Layer Style effect row shows its checkbox (#1422)
* Fix #1357: every Layer Style effect row shows its checkbox

An effect not on the layer yet was drawn as a greyed label with no
checkbox, so the box only appeared after the effect had been turned on
and off once. Those rows now draw an empty checkbox too, as in Photoshop;
a click on the row still adds the effect turned on. The checkbox drawing
is shared by both kinds of row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Layer Style test: count the effect list's checkboxes, not the pane's

Main's Blending Options pane now draws checkboxes of its own, so the test counts the leftmost column only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 08:08:06 +07:00
BFlatandClaude Opus 5.5 b97a72ac79 File dialogs no longer freeze or crash the app (#673, #574) (#1071)
* File dialogs no longer block the event loop (#673, #574)

Open, Save As, Export As, Place and every other file dialog ran
rfd's synchronous dialog inside winit's event handler. On macOS that
runs AppKit's modal loop there, and an event arriving meanwhile
aborted the app (SIGABRT, panic_cannot_unwind); on Linux the frozen
event loop made compositors offer to kill the app.

The platform shell now shows dialogs through one asynchronous
Services::file_dialog: the request is queued with what to do with the
answer, shown at the end of the frame with the window as its parent
(a sheet on macOS, an owned modal dialog elsewhere) and awaited on a
helper thread; the answer comes back through a channel that wakes the
app, which continues the action on a later frame. One dialog at a
time. The unsaved-changes prompt waits behind Save's dialog and closes
the document only once it saved. The web build uses the same service.

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

* brush picker: Import ignores the Open dialog's result, like the other callers

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 08:08:03 +07:00
BFlatandClaude c1cb455c7a Fix #1381: selecting type on Wayland no longer deletes it (#1421)
Wayland input methods send an empty preedit (and at times an empty
commit) whenever the caret rectangle moves, as it does while Cmd+A,
Shift-arrows or a drag select text. With nothing being composed,
ime_update took that as "replace the selection with nothing" and deleted
the selected text. An empty preedit or commit now changes nothing unless a
composition is in progress; a real composition still replaces the
selection.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 08:08:01 +07:00
BFlatandLordFarquaadtheCreator c84ec1b43a Document tabs: elide and overflow into a » menu, carrying #1293 by @LordFarquaadtheCreator (#1367)
Co-authored-by: LordFarquaadtheCreator <[email protected]>
2026-10-09 08:07:57 +07:00
BFlatandClaude Opus 5.5 c4d1c1f8d1 Fix #1229: the Polygonal Lasso tool removes its last vertex on Backspace (follow-up to #1262) (#1338)
* Fix #1229: the Polygonal Lasso tool removes its last vertex on Backspace

Alt-click polygon mode. The Polygonal Lasso tool itself keeps its points
elsewhere (ui.polygon), so there Backspace still reached Edit › Clear and
wiped the layer, the data loss reported in #1229. It now removes the last
vertex there too (Delete as well), a right-click does the same, and
removing the only vertex cancels the polygon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Pipette cursor test: Windows draws the precise crosshair itself

input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 08:07:54 +07:00
619095bfd0 Pattern Stamp tool in the S-group flyout (#922)
* Add Pattern Stamp as a Clone Stamp flyout sibling

Introduce paint.patternStamp to paint the current pattern through a
brush stroke. Aligned strokes keep a document-space tile origin;
unaligned strokes restart at the first point; Impressionist jitters
each dab with a seeded RNG. Empty patterns and strokes larger than
4096² pixels return an error instead of allocating.

* Record Pattern Stamp on the tools scorecard

Allow the aligned stamp helper's argument count under clippy.

* Pattern Stamp: raise the stroke cap to 8192² so a 24 MP diagonal fits

The 4096² bounding-box cap refused a corner-to-corner stroke on a 6016x4000 canvas (~24M px).
Unit test covers that diagonal and a still-rejected 9000² box; the hostile-input test uses a
footprint above the new cap. Scorecard note labels Impressionist as an approximation.

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

* i18n(pl): translate the Pattern Stamp strings

The Polish catalog landed on main after this PR was written; the
translation-coverage tests need every language to carry new UI strings.

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

* Tool::ALL holds 51 tools now that Red Eye and Pattern Stamp both landed

The rebase onto main (with #1084's Red Eye tool) leaves Tool::ALL one entry
longer; bump the array length and the count test.

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

* Flyout test: qualify egui::Vec2 (panels.rs no longer imports Vec2)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 21:07:06 -04:00
578bc905a6 i18n: split "Light" — font weights get their own context, Camera Raw keeps the panel term (#1061)
* i18n: split "Light" - font weights get their own context, Camera Raw keeps the panel term

The plain "Light" entry served two unrelated meanings, and every
catalog had resolved the collision in favour of the font weight (de
"Light", ja "ライト", fr "Maigre", ko "가늘게"...), so Camera Raw's
Light section header showed a weight word in most languages.

- The type tool's style dropdown now shows localized labels while the
  raw string stays the engine's fontStyle key (value/label pairs, like
  the anti-alias dropdown), with each word translated in a new
  fontWeight context (Thin...Black, Italic).
- Camera Raw's Light section reads cameraRaw|Light (Licht, Luz,
  Lumière, Свет, 光...); every catalog gains the missing rows, and a
  new i18n test pins both contexts plus their distinction per language.

* i18n(pl): font weights and Camera Raw keep their split contexts in Polish too

* i18n tests: close the Seafoam test before the font-weight test (rebase fixup)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 21:00:58 -04:00
f3fda94c87 Large document: a notice explains a slow CPU fallback redraw (#1015) (#1427)
* Large document: a notice explains a slow CPU fallback redraw (#1015)

A full refresh that falls back to the CPU compositor blocks the canvas for as
long as it runs, with only a log line to say why. The first one per document
that takes over a second now posts a notice: how long it took, which edits
redraw the whole document, and for the GPU memory budget fallback, that
raising Memory usage raises the budget (up to a quarter of RAM).

* Slow-refresh notice test: check the real "Memory usage" casing; add Polish rows

The last assertion looked for "Memory Usage" while the string says
"Memory usage", so it passed vacuously. The budget case now asserts the
hint is present too. pl.tsv gets rows for the new strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:53:39 -04:00
Brandon ThomasandClaude Opus 5.5 7a9a2684a1 Toolbar test: qualify egui::Vec2 (main fails to compile ui-egui tests) (#1519)
#1376's test used the module-level Vec2 import that #886 dropped; each
compiled alone, but not together.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:49:21 -04:00
Brandon ThomasandClaude Opus 5.5 ed6f989bc6 Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1518)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:47:09 -04:00
5bef14dbdc Swatches: user swatch model with .aco/.ase import and export (FILE-215-14, part of #215) (#886)
- photocraft-psd: clean-room .aco (v1/v2, UTF-16 names, RGB/HSB/CMYK/Lab/Gray at 16 bits,
  other spaces kept verbatim) and .ase (groups, global/spot/process, RGB/CMYK/LAB/Gray floats,
  unknown models kept) readers and writers; byte-stable round trips, hostile input -> Err.
- engine: presets::swatches (colours in their own model, display/foreground conversion through
  photocraft-cms working spaces, persisted with the prefs, default 40 colours in 4 groups) and
  swatch_cmds (swatches.list/add/delete/rename/move/newGroup/renameGroup/deleteGroup/use/reset/
  import/export) with graceful-failure tests.
- ui: swatches_ui.rs replaces the const grid (groups, tooltips, click = foreground and selected
  type, Alt/Ctrl-click = background, rename/delete/move, panel menu Import/Replace/Export/Reset),
  Color Picker "Add to Swatches", .aco/.ase via File > Open and drag-and-drop; strings translated
  in every complete catalog.
- Scorecard FILE-215-14 -> done.

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:43:01 -04:00
harrisonoconnorhover bf8d52ca74 Fix Paint Bucket sampling across visible layers 2026-10-09 07:39:55 +07:00
MarekSpodBiedry 12b014f677 Edit › Toolbar: hidden tools leave the toolbar (#1116) 2026-10-09 07:39:51 +07:00
MarekSpodBiedry 7842e57085 Select › Modify refuses a radius below the dialog's minimum instead of clamping it to 0 (#993)
modify() rejected only a non-finite radius or one above the maximum, then
clamped with r.max(0.0). A negative or zero radius reported success: Border
cleared the selection, Expand and Contract did nothing, and Feather 0 was
accepted although its documented range starts at 0.1.

Each command now checks the range in its params string (Border 1..200,
Smooth/Expand/Contract 1..500, Feather 0.1..1000). A radius outside it,
including NaN and infinity, returns BadParams naming that range, and the
selection is left as it was. The clamp is removed.
2026-10-09 07:39:48 +07:00
kayrockscreenprintingandClaude Opus 5.5 9af9df8275 PNM: the file, not the header, bounds what a decode reserves (#1110) (#1365)
ASCII PGM/PPM reserved a u32 (four bytes) per declared sample before
reading any, against a Limits budget of one or two bytes per sample, so a
19-byte header could ask for 12 GiB. With Limits::none() a 2³¹ × 2³¹
header panics with "capacity overflow"; PBM reserved 2⁶² bytes before
finding the raster missing and aborted. ASCII reservations are now capped
by what the remaining bytes can hold (a PBM digit is at least a byte, a
PGM/PPM sample at least a digit and a separator), and binary PBM checks
the raster length before reserving.

Limits::none() documents that it is still bounded by usize overflow
checks, but the decoder multiplied w × h × channels, n × 2 (16-bit
samples) and the PFM raster length unchecked. A new sample_count checks
the product, and the length checks use checked_mul.

Tests: a header alone reserves nothing (P1-P4), u32::MAX sizes are errors
(P3, P5, P6, PF, P7), and ASCII files with no slack (PBM digits without
separators, no trailing whitespace) still decode. The first two failed
before the fix: an abort on the 2⁶² reservation, "capacity overflow" for
the P2/P3 headers, and "attempt to multiply with overflow".

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 07:39:45 +07:00
MarekSpodBiedry 6cb36f3ddb New Document: a size typed in millimetres or another physical unit keeps its digits (#1147) (#1378) 2026-10-09 07:39:42 +07:00
harrisonoconnorhover d1ff8920d5 Test Export As scaling through real PNG exports 2026-10-09 07:39:39 +07:00
ragtuxandClaude Fable 5.1 a7587f6270 Export As: a scale that changes only the height is applied (#1353)
export_document scaled the width alone and skipped Image Size when the
rounded width matched the original, so a 1 × 100 document exported at
50% stayed 1 × 100 while the dialog promised 1 × 50. Scale both sides,
send both to image.imageSize, and let the size label use the same
rounding (never below 1 px) so it names the exported size.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-09 07:39:39 +07:00
2f42b1cc80 Camera Raw opens in a window of its own (#1361)
* Camera Raw opens in a window of its own

The Camera Raw dialog (Filter › Camera Raw Filter and the open-time
dialog) was a full-window overlay drawn over the PhotoCraft window,
covering its title bar: it looked like a fullscreen app without window
controls. Adobe Camera Raw is a separate window.

Show it in an egui immediate viewport: an OS window titled "Camera Raw
(name)" / "Camera Raw Filter (layer)", 1360 x 900 by default, resizable
(minimum 900 x 600). The dialog stays modal: the main window is dimmed
and takes no input while it is open. Closing the window is Cancel (for
the open-time dialog that closes the raw being opened). Where the
platform has no extra windows (ctx.embed_viewports(), the web build) it
is drawn over the main window as before, with its painted title strip.

The drawing code is unchanged apart from the title strip, which is not
painted in a window of its own.

* Camera Raw window: scrim theme token, close-is-Cancel test, document the screenshot gap

- theme::Tokens gains `scrim` (the dim over the main window behind a modal
  that takes all input); Camera Raw uses it instead of a hard-coded colour.
- closing_the_camera_raw_window_cancels: the overlay fallback keeps the
  dialog open, a close request on its window cancels without history.
- control-protocol.md: eframe 0.36 cannot screenshot an immediate viewport,
  so ui.screenshot shows the dimmed main window while Camera Raw has its own
  window; agents read it via ui.inspect.cameraRaw and see it offscreen with
  the snapshot example or kittest (which embed viewports).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:39:15 -04:00
BFlatandClaude 4477cf6bdf Fix #1380: the web build asks before leaving the page with unsaved work (#1424)
Closing or reloading the browser tab dropped unsaved documents without a
word, while the desktop app asks before closing its window. The web shell
now registers a beforeunload listener that has the browser show its own
"Leave site?" prompt while a document has unsaved changes (the new
PhotocraftApp::has_unsaved_work, refreshed every frame).


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:12 +07:00
BFlatandClaude 15e4a00991 Selection tools: ⌥ with nothing selected draws a new selection (#1364)
With #1106's engine change, subtract or intersect with no selection
selects nothing. The UI turned ⌥ (and ⇧⌥) into subtract (intersect)
even with nothing selected, so ⌥-dragging a marquee or lasso on a clean
canvas would select nothing; in Photoshop ⌥ then only draws the marquee
from its centre. A modifier-made subtract or intersect is now a new
selection when nothing is selected; an options-bar Subtract or Intersect
is kept, and the cursor badge follows.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:09 +07:00
BFlatandClaude acb57c169f Fix #1387: the Type tool's font, style and size controls show in every theme (#1414)
Outside the Pro themes the options bar showed only the hint "Click to add
text" for the Type tools, so Studio, Studio Light and Classic had no way to
pick a font while typing. The Type options bar now shows in every theme,
as the Move tool's Auto-Select does since #1275.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:06 +07:00
BFlatandClaude 980cf75492 Fix #1354: the macOS menu bar is modal under dialogs, like shortcuts (#1413)
Native menu clicks went straight to menus::invoke, so a menu command ran
beneath an open dialog: rotating the canvas under Image Size left the
dialog's stale size to be applied on OK. While a dialog, the unsaved
changes prompt or Camera Raw is open, the native rows are now disabled and
a click that raced the update is dropped; Zoom In/Out, Fit on Screen and
100% stay live, as their shortcuts do.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:03 +07:00
BFlatandClaude e2a760f68d Fix #1371: read large macOS display ids, keep ICC bytes out of the error (#1412)
NSScreenNumber was coerced with `as integer`, so a CGDirectDisplayID above
AppleScript's integer range (2^29) came back as a real, "2.077748985E+9".
parse_reply rejected it, dropped the only display, and the error quoted
the whole raw line, base64 ICC profile included, in the Display profile
notice and Color Settings.

The helper now asks NSNumber for stringValue; the parser also accepts an
exact scientific-notation id; and the error names only the start of the
first field, capped at 80 characters.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:00 +07:00
BFlatandClaude Opus 5.5 7238b4100d Move tool moves the selected pixels when there is a selection (#578) (#1068)
* Move tool moves the selected pixels when there is a selection (#578)

With a selection, a Move-tool drag (from anywhere) or arrow-key nudge now
floats the active layer's selected pixels (select.float) instead of moving
the whole layer: the hole is transparent, or the background colour on the
Background layer; Alt copies the pixels instead of duplicating the layer;
Shift constrains the drag to 45 degrees; Enter, Deselect or any other
command drops the piece as one "Move" history step. Layers whose pixels
can't float (type, shape, smart object, group, position-locked, hidden, or
several selected layers) and Auto-Select without a selection behave as
before. select.float gains "copy"; a click that moves nothing no longer
leaves a floating piece behind.

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

* Pipette cursor test: Windows draws the precise crosshair itself

input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 07:37:57 +07:00
Claude 38c97bdc5d Layers panel: test the Blend Mode dropdown's arrow keys with the Move tool active
The open dropdown takes ↑/↓ ahead of the Move tool's nudge, checked in
the full app. The arrow-key step reads the option with get() and uses the
response's own id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 07:37:54 +07:00
Rozaq 4f99e42dcb fix: support arrow keys in dropdowns 2026-10-09 07:37:54 +07:00
BFlatandClaude 8208761a7c Fix #1288: keep edits to a video frame when the playhead moves (#1395)
Paint, fills and filters edit a video layer's displayed raster, but the
frame stack kept the old pixels, so scrubbing or Render Video re-synced the
stale frame over the edit. Write the displayed content back into the
playhead's frame (video_cmds::store) before any navigation, frame edit,
new video layer or render.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 07:37:51 +07:00
BFlatandClaude 88a47dc7c7 Fix #1248: Create and Release Clipping Mask act on every selected layer (#1392)
With several layers selected, Layer › Create Clipping Mask (⌘⌥G) now
clips every selected layer except the lowest, which becomes the base,
and Release Clipping Mask releases them all, as in Photoshop; each is one
history step. A layer param still acts on that layer alone. The command
returns the layers it changed.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 07:37:48 +07:00
UnemarsandClaude Opus 5.5 ce1da78d93 Color Balance: Photoshop's slider gestures, Alt+Cancel Reset, Properties Reset and history (#1256)
Measured on Photoshop 25.4 (Image > Adjustments > Color Balance and the adjustment layer's
Properties): a double-click on a slider (knob or track; Windows double-click counting) zeroes it
in the shown tone, whatever modifier is held; clicks and drags ignore Shift/Ctrl/Alt; the
dialog's sliders step 1 per wheel notch (Shift 10), the panel's ignore the wheel. Holding Alt
turns the dialog's Cancel into Reset (every tone 0, Midtones shown, Preserve Luminosity kept,
dialog stays open). In the Properties panel consecutive edits are one history step, and Reset
first returns to the settings the panel found, then to the defaults. Up/Down in the fields are
every value field's since #1326 (1, Shift 10, as Photoshop); the tests check them on Color
Balance, below zero too.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 20:35:44 -04:00
f5c3d37c5a Add layer and group color labels (#1283) (#1289)
* Add layer and group color labels (#1283)

* i18n(pl): layer colour label rows for the Polish catalog now on main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:32:09 -04:00
Ebisuzawa KurumiandClaude Opus 5.5 43b4742e3f GPU startup: a failed CPU start no longer pins later starts to CPU
A start that returned an error kept its gpu-starting.json marker so the
next start would try a safer backend. On the CPU path there is no safer
backend: the marker made every later start plan CPU again and fail the
same way (seen under XWayland, where the GL adapter can't present: "no
graphics adapter found"), until the file was deleted by hand.

Keep the marker after an error only when a safer backend exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:26:55 +07:00
dde8fcddce Curves: add black, gray, and white point eyedroppers (#873)
* feat(curves): add black gray and white point eyedroppers

* fix(curves): preserve luminance with gray-point eyedropper

* Curves eyedroppers: drivable by the command's eyedropper param (rule 1)

image.adjustments.curves and layer.newAdjustmentLayer.curves accept
{"eyedropper":{"point":"black|gray|white","at":[x,y]}} (samples the merged composite) or
{"point",…,"color":[r,g,b]}, resolved by the same curves_eyedropper the dialog uses, so agents
and the CLI can set black/gray/white points without ui.pointer. Bad points, positions outside
the image, transparent samples and malformed values are errors. The picker swatches are the
tone each picker maps to (image data, like the Curves gradient bars), drawn as
Color32::from_gray(level) with a token ring.

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

* i18n(pl): translate the Curves eyedropper strings

The Polish catalog landed on main after this PR was written; the
translation-coverage tests need every language to carry new UI strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:26:33 -04:00
d2ad238e82 PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1406)
* PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1405)

ag-psd, GIMP and other writers leave a PSD type layer without image data and
let Photoshop re-render it on open. The importer made those empty pixels the
layer's cache, so the canvas showed nothing until the layer was edited, and
the family guessed from the PostScript name ("CC Wild Words" for
CCWildWords-Regular) never got corrected to the installed family
("CCWildWords"), so Missing Fonts replaced an installed font.

- crates/io/src/psd_import.rs: a type layer's record pixels are the cache only
  when there are some.
- crates/io/src/text_import.rs (new, run by import): resolves each run's
  PostScript name with FontDb::resolve_postscript, as the layout does, and
  writes the installed family, weight and italic back into the run; renders
  every type layer that has no cache from its model.
- crates/io/tests/psd_type_bounds.rs: a type layer exported without pixels
  imports rendered, on the engine's own pixels; "JetBrainsMono-Regular" (guess
  "Jet Brains Mono") imports as the family "JetBrains Mono".

Fixes #1405

* PSD import: cap the automatic type render for untrusted sizes

text_import::prepare now renders a type layer without pixels only when its
ink fits MAX_RENDER_PIXELS (64 Mpx); the renderer's own guard is 256 Mpx,
which for an absurd type size or scale in a hostile PSD meant several GB of
f32 accumulation buffers on open. Over the cap the layer keeps empty pixels
until edited, as before. Test: hostile_type_sizes_without_pixels_import_without_a_huge_render
(an ink between the two caps, plus 1e30 pt, a 1e12 scale and f64::MAX).

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

* rustfmt (module order, the hostile-size test)

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

* PSD import: keep empty type pixels when the engine text is blank

cargo xtask test-corpus: rendering every pixel-less type layer regressed the
Photoshop oracle text/path-wave-open.psd (133 -> 132, below the floor).
Photoshop wrote that layer with no pixels because its EngineData text is
blank (the characters sit only in the descriptor's Txt ): it draws nothing,
and the import must not draw it either. New photocraft_text::psd::
engine_text_is_blank; the oracle passes again (133).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:22:25 -04:00
SckraTandClaude Opus 5.5 2619cd2200 fix(ui): wrap New Document preset titles inside their cards (#1401)
Preset card titles were painted with Painter::text, which lays out a single
unwrapped line centred on the card, so translations longer than the 164 px
card (ru "Размер Photoshop по умолчанию", es "Tamaño predeterminado de
Photoshop", ...) spilled past both edges. Lay titles out wrapped to at most
two lines, elide anything longer with a hover tooltip carrying the full name,
and move the size line down to make room.

Test: every preset title in every language fits the card in two lines
without eliding.

Also adds my contributors/people.toml entry.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:20:24 -04:00
b8235e132a Layers panel: Alt-drag a row to copy the layer there, as in Photoshop (#1216)
* Layers panel: Alt-drag a row to copy the layer there, as in Photoshop

Holding Alt when a dragged Layers panel row is dropped leaves the layer in
place and drops a duplicate above, below or into the target, in one
"Duplicate Layer" undo step; the copy becomes active. A plain drag still
moves the layer. The cursor shows a copy icon while Alt is held.

layer.moveTo takes "copy": true for this and returns the copy's id. The copy
is made by the same helper as layer.duplicate (naming, unlocked Background
copy). A group can be copied beside itself but not into itself.

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

* Alt-drag copy test: expect "A copy 2" for the second copy of A

Main's Duplicate Layer now names copies with Document::copy_name ("A copy", then "A copy 2"),
and layer_copy uses it after the rebase, so the second copy of A in the test is "A copy 2".

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:16:39 -04:00
01752be81a Lasso: Alt clicks add straight segments, releasing Alt closes the outline (#888)
* Lasso: Alt clicks add straight segments, releasing Alt closes the outline

- With no selection, Alt held at the first press starts straight segments
  (there is nothing to subtract from) instead of latching subtract; with a
  selection, Alt at the first press still subtracts.
- While the button is down the outline follows the pointer, Alt or not: an
  Alt click adds a straight segment, an Alt drag a freehand stretch.
- Releasing Alt while the button is up closes the outline and makes the
  selection (no double-click needed).
- No subtract badge for Alt when nothing is selected.
- The Polygonal Lasso no longer draws handles on its vertices.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Lasso: clicking the last point again closes the outline

A second click on the last straight-segment point (however long after the
first, not a double-click) closes the outline and makes the selection, like
a click on the first point.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Polygonal Lasso: ⌥-drag draws freehand into the polygon

With the Polygonal Lasso, holding ⌥ while the button is down draws a
freehand stretch into the polygon; releasing ⌥ goes back to straight
segments and the polygon stays open. ⌥ at the first click with nothing
selected starts a new selection (nothing to subtract from), and shows no
subtract badge.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 20:12:16 -04:00
a7f6c2b71c I18N: Localize startup notices and welcome text (#1094)
* I18N: Localize startup notices and welcome text

Translate startup guidance when it is drawn so it follows language changes. Add welcome and notice-overflow strings to all language catalogs, with a Spanish/English regression test.

Validation: 697 UI tests, fmt, all-target clippy, layers, wasm,
i18n coverage and a fresh native startup screenshot.

* I18N: the notice test reads the catalogs without changing the shared language

The interface language is process-wide (i18n::CURRENT), and the regression test
for translated startup notices set it to Spanish, which other UI tests running
in parallel draw with. It now looks the strings up with an explicit language
(i18n::tr) instead.

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

* I18N: Polish rows for the startup notice strings

pl.tsv landed on main; the coverage test needs every catalog to translate them.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:08:07 -04:00
b0680fd421 Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (FILE-215-16, part of #215) (#903)
* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)

Blending Options > Advanced Blending now works end to end:

- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
  interior effects as group, blend clipped layers as group, transparency
  shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
  model and written back (in place, or added when not at the defaults);
  the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
  (isolated groups and clipping groups stop a deep knockout), clipped
  layers blended individually, effects over the whole layer when
  Transparency Shapes is off, effects built from an unmasked copy when a
  mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
  five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
  Advanced Blending, Blend If with split sliders), translated.

psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.

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

* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.

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

* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard

#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:08:03 -04:00
4cb7cf3931 Layered TIFF: the review changes of #672 (keep unknown blocks with their byte order, io behind the engine, fixture attribution) (#835)
* Review: keep unknown blocks with their byte order, io behind the engine, fixtures documented

Addresses the review on #672:

* Unknown layer blocks read from a little-endian TIFF are no longer dropped. Their bytes are
  kept verbatim with the signature `MIB8` (`46B8` for `8B64`) marking the little-endian
  layout (`TaggedBlock::is_foreign_order`), so a save back to TIFF restores them exactly. A
  malformed known block is kept the same way. Only a save to the other byte order has to drop
  such a block, with a warning: a PSD export strips them (`strip_foreign_order_blocks`), and a
  big-endian block of unknown layout still cannot go into a little-endian TIFF.
* The UI no longer depends on `photocraft-io`: the layer predicate is exposed by the engine
  (`file_cmds::tiff_would_write_layers`); io is a dev-dependency again.
* The layered TIFF path ends in the same `encode_image` as every flat export, so export-wide
  metadata policies (such as #733's XMP choice) apply to its composite too.
* Scorecard FILE-215-7 is `partial`: extra alpha channels and a Quick Mask are not written.
* The committed fixtures get an ATTRIBUTION.md row and a README describing their contents and
  how they were generated (psdtags + tifffile); the Python generator script is removed.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* tiff_corpus: ask for layers explicitly (headless default is flat since #825)

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* i18n: drop the duplicate Korean "Ambient" entry that fails bundled_catalogs_are_consistent

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* i18n: drop the duplicate Korean "Warmth" entry as well

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* agent_tasks: tolerate Windows holding the test directory open during teardown

The three tests that remove their directory right after cancelling the client failed on Windows (CI and locally) with os error 32: the spawned server task is still closing its files. The cleanup now retries briefly and otherwise leaves the directory for the next run's tmp() to clear, as it already did at the start.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* preset store: a missing directory is an empty store on Windows too

DirBackend::read passed the OS message through, and Windows words a missing directory as "cannot find the path specified (os error 3)", which missing_file() did not recognise, so attaching a store on a path that does not exist yet (first launch, and two engine unit tests) reported actions.json as skipped. The backend now reports every NotFound as "<name>: not found", the wording the predicate and the memory backend already use. Regression test: opening a store on a nested missing directory warns about nothing and writes nothing.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* Review: keep scripts/layered_tiff_fixtures.py, drop the fixtures README and attribution rows

The four .tif fixtures those described were never committed (binary test data stays out of the repo; the tiff_layers tests read them from the gitignored corpus/layered-tiff/, written by the script that tiff_layers.rs:181 still points to).

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* automation: the Direct Selection path commands edit vector paths, not files

#865 added path.moveAnchors, path.moveHandle, path.bendSegment and path.convertPoint, whose registry docs mention the vector path they return, so registry_filesystem_path_params_are_classified fails on main (Linux and Windows): they join the list of commands whose "path" is a document path.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* i18n(cs): the lower-case "{n} layer|{n} layers" plural (main red since #829)

xtask's source_key_set_is_covered_by_complete_catalogs fails on main: the key reached every catalog but the Czech one. Same forms as the capitalised twin already in cs.tsv.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* Drop the duplicate path.* entries from the workspace test (main already lists them)

main already has path.moveAnchors/moveHandle/bendSegment/convertPoint in this match; the
duplicates trip unreachable_patterns.

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 19:57:19 -04:00
869ecf3cff Red Eye tool in the J-group flyout (#1084)
* Add the Red Eye tool to the J-group

Click a red pupil to neutralize the flash reflection. Pupil Size
sets the search window around the click; Darken Amount controls how
far corrected pixels go toward a darker, desaturated pupil.
Catchlights stay white. A miss reports an error instead of a silent
no-op. The kernel only reads a window plus a 5 px mask-filter halo.

* paint.redEye: write back only changed pixels, so CMYK/Lab stay untouched

write_rgba re-encoded every pixel of the click window through the naive
RGBA round trip, which regenerates K in CMYK and shifts Lab values. Now
untouched pixels keep their original channel data; only pixels whose RGB
changed are re-encoded. Adds a CMYK test checking untouched pixels inside
the window stay bit-exact.

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

* algo::redeye: cargo fmt

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

* Polish catalog rows for the Red Eye tool; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage tests need every catalog to translate new UI strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:57:14 -04:00
Elvis33LE 5ecc860ac1 ui.set validates every field before applying the first one (#820)
* ui.set validates every field before applying the first one

ui.set applied fields as it validated them, so a call like
{"tool": "zoom", "theme": "nope"} changed the tool and then replied
ok:false - the caller is told the call failed and assumes nothing
changed, but the app state moved. Nested objects were worse: a non-object
"panels"/"dockTabs" skipped the merge silently and still replied ok:true,
and an unknown nested key (a typo like "layers2") was inserted and then
dropped by the serde round-trip, also with ok:true - the guarantee of
(selectionMode: "add", brushSize: "big", center: [1]) were silent no-ops
too, and brushSize passed raw floats through (`1e300 as f32` = inf).

All fields are now validated and materialized into locals first; the
apply pass cannot fail, so a rejected call applies none of its fields.
Nested objects reject non-object values and unknown keys (the known set
is the serialized current state, so it tracks the struct automatically),
scalars are type-checked, and brushSize clamps into the Brush Settings
panel's range instead of producing an unusable or infinite tip.

* ui.set: validate-first on rebased main; brushSize keeps the journaled dispatch

Rebased onto main and reworked per review:

- brushSize keeps main's journaled `tools.setBrush` dispatch (#744) with
  its semantics intact ("large"/null/non-numeric = the historical silent
  no-op with ok:true, f64::MAX = the command's error). The size is
  validated in the first pass against MAX_BRUSH_SIZE, so the journaled
  command cannot fail after earlier fields were applied.
- colorPanel (#664) joins the validate-first pass through a new
  whole_object helper (unknown nested keys rejected, value replaces).
- dock gets the same unknown-nested-key check (replacement semantics,
  like main).
- center requires exactly [x, y] again: 3+ elements are an error, not a
  silent skip.
- merged_object (merge semantics, panels/dockTabs) and whole_object
  derive the known-key set from the serialized current state, so new
  struct fields are automatically known.

* CI: retrigger ubuntu (only failing job; its logs expired unretrievable, corpus/windows/macos/wasm32 green and the full suite is green locally)

* rebase: colorPanel, brushPicker and brushPickerView join the validate-first set
2026-10-08 19:41:12 -04:00
53985e5f34 Color Lookup: keep the .cube input domain and reject the ones we cannot apply (#1130)
* Color Lookup: keep the .cube input domain and reject the ones we cannot apply

DOMAIN_MIN/DOMAIN_MAX (and LUT_3D_INPUT_RANGE) were parsed and thrown
away, so a log-domain or extended-range look was applied to plain 0..1
inputs: silently wrong colours, exactly what the in-code comment
admitted. The domain now travels with the parsed LutFile (validated:
every component finite and min < max), and colorLookup refuses a
non-default domain with the reason instead of shifting the look -
the same unsupported-over-wrong-output call the codecs make. Honoring
a custom domain end to end means carrying it through ColorLookup
(doc, compose, gpu, PSD round trip) and stays open as the follow-up.

* tests: the colorLookup domain rejection needs a pixel layer active

The outer session of the test still had the adjustment layer active, so
the command was disabled before reaching the domain rejection - the
assertion only passed when the active layer happened to differ. Make it
deterministic with a fresh pixel layer.

* Color Lookup: bake a 1D .cube's input domain into the resample

The 1D branch of parse_cube still ignored DOMAIN_MIN/MAX and
LUT_1D_INPUT_RANGE. Since the 1D table is resampled onto a 0..1 cube anyway,
the domain is now applied there (saturating outside), and a degenerate
domain is rejected like the 3D one.

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

* cargo fmt after rebasing onto main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:41:07 -04:00
Elvis33LE 9182f8f76d undo/redo report the step's damage instead of recompositing the whole canvas (#810)
* undo/redo report the step's damage instead of recompositing the whole canvas

Session::edit, undo and redo all cleared last_damage, so every undo of a
local edit recomposited the entire document instead of the edited area
(scorecard P6: undo a move on 20 MP = 256 ms p50 against a 25 ms budget;
P7 redo 144 ms). Only layer.setProps and the move paths computed damage.

Undo/redo now diff the two documents on either side of the history step:
every layer whose pixel identity changed contributes the union of its
change bounds (where it was, where it landed) to last_damage. The identity
covers everything the compositor reads - blending, placement, masks,
effects, and pixels via their copy-on-write tile pointers (both documents
stay alive for the comparison, so tile addresses cannot be reused under
it). Steps the fingerprint cannot bound still composite everything, as
before: canvas size/format/profile changes, layer-structure changes
(order, additions, removals, kind flips), and video layers - so mode
conversions, flatten/merge and structural undos keep the safe full path
by construction rather than by hoping the edit's target layers were
recorded correctly.

* step_damage: structural comparison, document-field and clipping bail-outs

Rework per review (correctness of the bounded claim):

- Document-level fields the compositor reads are compared up front in
  doc_pixels_equal (size, resolution, mode, depth, profile, channels,
  quick mask, global light, patterns, colour table, duotone); any
  difference composites everything. Undoing Layer Style > Global Light
  or an RGB > Indexed conversion (which keeps the pixel format) no
  longer reports Some(EMPTY) and leaves shadows stale.
- Layer clipping and channel exclusion reach outside the layer's own
  bounds; a difference composites everything, like layer.setProps
  already reports for edits.
- layer_fp's 64-bit hash is gone: layers are compared structurally
  (surfaces by copy-on-write tile pointers, blobs by pointer or bytes,
  parameters by ==), so no hash collision can under-report damage.
- Layer and Document are destructured exhaustively in the two compare
  functions: a new field fails to compile until it is classified as
  compositing (compared) or pixel-irrelevant (explicitly ignored), the
  same sentinel the .pcraft manifest uses.
- Groups no longer hash their children (the walk recurses into them),
  so an edit inside a group damages the edited child's extent, not the
  whole group's.

Tests: undo of Global Light, of image.mode.indexedColor, of a clipped
toggle and of an excluded channel each report None.
2026-10-08 19:41:02 -04:00
Brandon ThomasandClaude Opus 5.5 9d3447b153 Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1489)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:34:20 -04:00
Claude b71991e15a Batch layer.moveTo test: make the target group before B moves into G
layer.new.group inserts above the active layer, which was B inside G, so
Target was created inside G and moving G into it was rightly refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy d3632f361b style: remove extra blank line after deleted helper 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 38257f0209 style: apply rustfmt layout for multi-layer drag handler 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 408975423c test(ui): verify multi-selected group drop payload and single-layer fallback 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy bc2b0a3ca6 test(engine): batch group move, stable stacking, undo/redo, nested selections and atomic rejection 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy aec1aa1980 refactor(engine): share cycle checks through batch move implementation 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy b3b5801a73 feat(ui): drag all selected layers into existing groups with a single drop (#1245) 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 4b14baf4f8 feat(engine): expose batch layer.moveTo through the command registry 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 6fbdbdb396 feat(engine): move multiple selected layers in one atomic layer.moveTo operation (#1245) 2026-10-09 06:20:20 +07:00
才倾梦华V 8492f14620 Keep the active document when closing another tab (#1148) 2026-10-09 06:14:12 +07:00
BFlatandClaude 10f3d9efad Pipette cursor test: Windows draws the precise crosshair itself (#1437)
input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <[email protected]>
2026-10-09 06:13:47 +07:00
006e1a3432 i18n: translate the Fill dialog, layer style contours and Image Size "(was …)" (#1291)
* fix(i18n): translate the Fill dialog labels, contour names and Image Size "(was …)"

The Fill dialog painted its row labels and the Contents options without
going through the catalog, the layer-style contour presets had no catalog
entries, and Image Size formatted "(was …)" in English. i18n-coverage only
sees tl! literals, so these stayed English in every language while it
reported 100%.

Wrap the Fill labels and the Image Size template in tl!, add the missing
keys to every catalog, and add a live test that checks each complete
catalog covers the Fill options and CONTOURS and that the dialog draws in
the selected language.

* chore(contributors): add my real name to the About credits

* i18n: Polish rows for the Fill dialog, contour presets and Image Size

pl.tsv landed on main after this PR; the translation-coverage tests need
every catalog to carry the new strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:10:19 -04:00
Remidesbois 46fb94f813 Type Tool: Ctrl/Cmd temporary transform handles while editing (#1091) (#1202)
* type: transform editable text with temporary Ctrl/Cmd handles

* type: cover temporary transforms, editable files and preview costs

* docs: describe temporary Type transforms and automation

* type: release temporary previews immediately when gestures end

* test: benchmark the active 24 MP Type document
2026-10-08 19:10:13 -04:00
ef9d075f23 Eyedropper: the comparison ring while sampling (#213) (#1227)
* Eyedropper: the comparison ring while sampling (#213)

The colour under the pointer on the upper arc, the current foreground below,
over a thin dark outline, drawn at the cursor for the Eyedropper and for
Alt-sampling with a painting tool. The Precise-cursor preference keeps the
plain crosshair. Scorecard row flipped to done.

* Eyedropper ring: only while the button is held, theme outline

Photoshop shows the comparison ring only while sampling (button down), not
on hover; hovering now draws the plain crosshair and samples nothing, so
there is no per-frame document.pixel call when idle. The outline uses the
theme's shadow token instead of a hard-coded colour.

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

* Eyedropper cursor: ring first, then main's pipette or Precise crosshair

After rebasing onto main (#1028's pipette cursor), the Eyedropper had two
match arms (the second unreachable) and Precise Other Cursors hid the
comparison ring. One arm now shows the ring while sampling, otherwise the
pipette, or the crosshair when Other Cursors is Precise.

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

* rustfmt canvas.rs

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:10:08 -04:00
UnemarsandClaude Opus 5.5 3439d327f7 Preferences › Performance › Low Resolution Previews (on by default) (#1247)
Live previews of large documents (filter dialogs, adjustment dialogs that fall back to the
filter preview, zoomed-out adjustment dialogs, an adjustment layer's sliders while they drag)
render on a nearest-neighbour reduced copy: a 16 MP photo previews at every 3rd pixel, drawn
3x blocky at 100 %. Turning the setting off renders them at full resolution.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 19:10:02 -04:00
Nigel Ihza Firdausy e2956dcf93 feat(ui): auto-scroll Layers panel during layer drag and drop (#1298)
* feat(ui): auto-scroll Layers panel when dragging near viewport edges (#1246)

* fix(ui): import Pos2 for Layers panel drag-edge scrolling

* test(ui): cover drag autoscroll velocity, boundaries, stop conditions and short panels
2026-10-08 19:09:57 -04:00
Brandon ThomasandClaude Opus 5.5 ffe59b4247 Preferences: show Zoom Clicked Point to Center now that the Zoom tool reads it (#1480)
#1241 wired tools.zoomClickedPointToCenter, so prefs_usage's
every_preference_is_read_or_hidden fails while it stays hidden.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:09:47 -04:00
d57fdf0116 Zoom: the Zoom tool's click honours Zoom Clicked Point to Center (#204) (#1241)
* Zoom: the Zoom tool's click honours Zoom Clicked Point to Center (#204)

Preferences > Tools > Zoom Clicked Point to Center now moves the clicked point
to the centre of the view instead of keeping it under the pointer; scroll-wheel
zoom keeps the point under the pointer either way. The settings audit drops from
57 to 56 unread fields; docs/scorecard.md regenerated.

* Regenerate docs/scorecard.md after rebasing onto main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 18:51:35 -04:00
1656961cc5 Fix/1350 preserve active on save (#1416)
* fix(automation): preserve active document after indexed native save (#1350)

* test(automation): cover indexed save preserving active document (#1350)

* rustfmt inactive_save.rs

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 18:51:28 -04:00
ec477ca9c4 Rulers: right-click to switch the ruler unit (#1235)
* Rulers: right-click to switch the ruler unit

Right-clicking either ruler (or the corner) opens a context menu listing
every length unit with the active one checked. Picking one runs the same
prefs.set the Units & Rulers preference pane uses, so the change is
immediate and persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* rustfmt rulers.rs

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 18:28:57 -04:00
DWAA1660andClaude Opus 5.5 6f7192d294 Select › Modify refuses a negative radius instead of clamping it (#993) (#1274)
modify() rejected only non-finite radii and radii above the dialog's
maximum, then clamped with r.max(0.0). A negative radius therefore reported
success while leaving the selection alone (Expand, Contract) or clearing it
(Border), against the function's own rule that out-of-range values are
"refused, never clamped silently". Negative radii now get the same
BadParams error, "radius must be a number in 0..max", and the selection is
untouched.

Test: modify_commands also tries -5, -0.5 and f64::MIN for all five
Modify commands.


Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 18:28:50 -04:00
UnemarsandClaude Opus 5.5 4649946900 Vibrance: Photoshop's Saturation and Vibrance, measured (linear light, 0.288 R + 0.712 G grey) (#1418)
Saturation −100 greyed to the HSL lightness, so a yellow (255, 255, 0) became grey 128 where
Photoshop shows near-white: a yellow logo with Brightness/Contrast +150 and Vibrance › Saturation
−100 clipped to it stayed yellow under Lighter Color. Measured black-box on Photoshop 25.4
(16-bit runs of 7,400-colour charts and HSV sweeps; sRGB, Adobe RGB and ProPhoto documents), both
sliders work in linear light through the document tone curve, Vibrance first, then Saturation:

- Saturation moves each colour from the grey 0.288 R + 0.712 G (blue has no weight, in every
  profile) by 1 + s, clipping per channel: within 0.07/255.
- Negative Vibrance is an HSV change in linear light keeping the hue, in closed form: 0.2/255 on
  average, the deepest shadows (V < 0.05) drift.
- Positive Vibrance: the brightness term is exact; the saturation boost (stronger for dull and
  dark colours, damped on reds and skin) is a fit, 0.4 (+10) to 5 (+100) /255 on average.

CPU (compose::adjust::vibrance_px) and GPU (compose.wgsl) share the constants; the parity test
gains a negative Vibrance and Saturation −100. photocraft-corpus adjustments/rgb8|rgb16/
vibrance.psd (Vibrance +60, Saturation −15): 25.6/255 mean, 89 max → 0.38 mean, 5 max.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 18:28:42 -04:00
kayrockscreenprintingandClaude Opus 5.5 64db46f5ab Puppet Warp: an expansion that empties the mesh is refused, not a panic (#951) (#1410)
With at least one pin, a density or expansion that remeshed to no vertices
panicked in PuppetSolver:🆕 `locate` falls back to vertex 0 when nothing
is closer, and the union-find over an empty mesh indexed it ("index out of
bounds: the len is 0 but the index is 0"). The UI reached it through the
control channel and the options bar's Expansion field (its -50 minimum
empties a 20 px tall bar), and edit.puppetWarp reached it through
puppet_warp, where the dispatch guard caught the panic.

- algo: PuppetSolver::new binds no pins when the mesh has no vertices, and
  puppet_warp returns the source when there are no triangles. With only the
  first change the command erased the layer, since rendering keeps only
  pixels inside the mesh.
- ui-egui: a remesh that leaves no triangles is refused, as `begin` refuses
  a layer without opaque pixels. The previous density, expansion and mesh
  come back. The control channel returns the error; the options bar's
  fields snap back.

Tests in algo, engine and ui-egui follow the issue's fixture and failed with
that panic before the fix.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 18:28:36 -04:00
kayrockscreenprintingandClaude Opus 5.5 4f2197d37e Perspective Warp: replacing the planes mid corner drag ends the drag (#952) (#1398)
A corner drag holds (plane, corner) indices into the plane list. Replacing
the planes through the control channel (`"planes": [...]`) kept the drag, so
the next pointer move or release indexed the new, shorter list and panicked
in set_corner ("index out of bounds: the len is 0 but the index is 0").

Replacing the planes now clears the drag, and set_corner skips indices that
no longer exist instead of indexing blindly.

The regression test draws a plane, presses its corner, replaces the planes
with an empty list, then moves (and, separately, releases) the pointer. It
panicked before the fix.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 18:28:29 -04:00
ragtuxandClaude Fable 5.1 966511d87f codecs README: lossy WebP is written by the built-in VP8 encoder (#1352) (#1396)
The capability matrix and the "Lossy WebP" note still said there was no
pure-Rust lossy encoder and that webp_lossless: false was refused. The
VP8 key-frame encoder landed in #880, and the book already documents it;
say the same here: lossless by default, lossy with webp_lossless off and
webp_quality, alpha in ALPH, metadata in VP8X chunks, 16383 px per side.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 18:28:24 -04:00
Jamie Pates 66b560295f Feature: Add incrementation with up/down arrow keys on numeric fields (#1326)
* Implement number incrementation with up/down arrow keys

* Ensure incrementation functionality works with equations

* Match rounding parity for incrementation
2026-10-08 18:28:18 -04:00
5a97ee30f3 Brush tests: seams, hard edge, zero scatter and ABR tip colour (#213) (#1221)
* Brush tests: seams, hard edge, zero scatter and ABR tip colour (#213)

Adds the four TOOL-213-13 regressions: minimum coverage inside a stroke
(spacing 0.1/0.25/0.5), a hardness-1 rim measured against a soft falloff,
dabs staying on the path at zero scatter, and an imported ABR tip painting
the brush colour instead of black. Scorecard row flipped to done.

* Contributors: add Reynold Hu to people.toml

Real name credited, display name synced from the public GitHub profile at the
next contributors.py refresh (every field is opt-in).

* Regenerate docs/scorecard.md after rebase onto main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 18:28:12 -04:00
BFlatandClaude Opus 5.5 da86700693 Right-click Brush Preset picker picks presets like Photoshop (#615, #1031) (#1080)
* Right-click Brush Preset picker: pick presets like Photoshop (#615, #1031)

One picker for the canvas right-click and the options-bar brush chip, drawn
once a frame and kept in UiState (brush_picker, brush_picker_list). Its
preset list is the Brushes panel's (brushes_tab::preset_list): a click picks
and keeps it open, a double-click picks and closes it, drag reorders and
right-click renames or deletes. A gear menu adds New Brush Preset… (named in
the rename bar), Rename/Delete Brush, list or grid view and Import Brushes….
Hardness shows for round tips only; Enter applies a typed size before it
closes the picker. ui.set gains brushPicker and brushPickerView.

Also fixes the Brushes panel's Rename Brush…/Rename Group… context items,
which never opened the rename bar.

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

* Brush Preset picker: Polish translations

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 18:28:06 -04:00
ef8610adeb Fix radial blur sampling density (#515) (#658)
* Fix radial blur sampling density (#515)

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Bound radial blur quality and parallelize sampling

Add Draft, Good, and Best radial sampling tiers, defaulting to Good, and process sampled blur rows in parallel. Preserve compatibility with serialized filters that omit quality.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

* i18n(ko): Radial Blur quality choices Good and Best

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-08 18:28:01 -04:00
Claude Opus 5.5 f1433f4ab2 Compose: restore the Lab-mix flag instead of clearing it after a tile (#1112)
Rendering set the thread-local LAB_MIX to the document's value around each
tile, then set it to false. A nested rayon job (a gradient fill, say) can
let the same thread render another tile in the middle of an outer one;
clearing the flag there made the rest of the outer Lab tile mix in sRGB.
The issue's reproduction (a 4096 px Lab document with a gradient fill and
two 50 % fills, 60 renders, RAYON_NUM_THREADS=4) showed whole 256 px tiles
differing between identical renders.

psblend::with_lab_mix sets the flag, runs the closure, and restores the
previous value with a drop guard, as with_cmyk_space does for the CMYK
space. The three set/clear pairs in render_tiled_with use it.

The new test sets the flag, renders Lab and RGB documents on the single-
and multi-tile paths, and checks the flag is what it was. It failed before
the fix. The issue's reproduction, run twice each way on macOS with four
threads: before, 6 and 2 of 60 renders had one whole tile wrong (65536 px);
after, 0 of 60 both times.
2026-10-08 18:27:48 -04:00
mnmlywandClaude Opus 5.5 3ac39e1ede Content-Aware fill returns the size error for a huge selection extent instead of panicking (#963)
The automatic sampling margin was three quarters of the selection's larger side, computed
as `ext * 3` in u32 (edit.fill) or i32 (edit.contentAwareFill) before the window cap, so a
loaded document with a far-flung selection overflowed and panicked. edit.contentAwareFill
also had no window cap at all and could try to allocate a canvas-sized window.

The margin is now one shared function computed in u64 and saturated to i32, and both
commands check the window against the same 400 M-pixel cap before reading or allocating.
Content-Aware Move uses the shared margin too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 05:09:47 +07:00
BFlatandClaude Opus 5.5 652b9726be Pen tablets: Xwayland for a pen on Wayland, macOS monitor after NSApp (#639, #759) (#1070)
Wayland compositors give a pen only to clients that bind zwp_tablet_v2,
which winit 0.30 doesn't, so on Wayland the pen did nothing in the
window. With a pen attached (BTN_TOOL_PEN in /proc/bus/input/devices),
$DISPLAY set and the X11 libraries installed, the window now opens
through Xwayland, where the X11 reader gets pressure, tilt and the
eraser. PHOTOCRAFT_NATIVE_WAYLAND=1 keeps native Wayland.

macOS: the AppKit event monitor was installed before winit created the
shared NSApplication; it now goes in from eframe's app creation. The
pen's button-up (pressure 0) no longer zeroes the pressure of the
stroke's last moves, which reach the UI in the same frame.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 05:08:44 +07:00
BFlatandClaude Opus 5.5 d836e3458d Fix #1272: a reassigned shortcut runs its new command (#1330)
* Fix #1272: a reassigned shortcut runs its new command

Giving F5 to Select › Modify › Feather… warned that Brush Settings would
lose it, but F5 kept toggling Brush Settings. The dialog lists Photoshop's
Window › Brush Settings item (window.panel.brushSettings) and OK cleared
that id, while the key was bound to the shell command it runs
(window.toggle.brushSettings), which kept its default; and the shell
commands came first in the binding table, so they won the key.

The binding table now gives a key the user assigned to that command ahead
of any default that still names it, and an edit to a Window › panel row
applies to the shell command behind it. The same held for F6 (Color) and
F7 (Layers), and for shortcuts set through edit.keyboardShortcuts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Shortcuts: contains_key, not get().is_none() (clippy)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 04:36:56 +07:00
Brandon ThomasandClaude Opus 5.5 fa09fcb218 Regenerate docs/scorecard.md (139 preferences after #1016) (#1435)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:28:28 -04:00
Brandon ThomasandClaude Opus 5.5 6957d7c546 i18n(pl): translate the strings added alongside the Polish catalog (#1426)
The Polish catalog (#1192) landed next to #1016 (System title bar), #1309
(Camera Raw first) and #1293 (document tab overflow), whose new strings it
lacked, so the every-language translation tests fail.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:08:03 -04:00
17d72cf771 Fix Classic Gradient transparency and open the Gradient Editor (#905)
* Fix Classic Gradient transparency and open its editor

* Translate the minimal Gradient Editor

* Document gradient preset and editor controls

* Group gradient translations to avoid append conflicts

* Split the minimal Gradient Editor out; keep the Classic Gradient transparency fix

The floating editor (preset list + opacity-stop number rows) is not Photoshop's Gradient
Editor, so it goes to a follow-up. What stays: classic drags no longer override the preset
with opaque FG/BG colours, the options-bar swatch shows the selected preset in both modes,
labelled Gradients-panel items, and the classic transparency regression tests. Drops the
ui.set/inspect gradientEditor field and the editor's translations.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:05:51 -04:00
1e1d3dcad4 Fix corrupted reduced canvas previews with separate mip render targets (#892)
* Test separate render targets for canvas mip generation

* Read back canvas mip levels after full and partial uploads

* Gate the mip scratch-texture workaround to DX12

Creating a scratch texture and copying back per mip level on every edit cost about 3.8 ms
p50 for a 128x128 update (the PR's own Metal numbers). The corruption it works around is an
Intel DX12 driver fault, so only DX12 adapters take the scratch path now
(Resources::separate_mip_targets); every other backend renders straight into the level as
before, with main's timings. The readback test runs both paths.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:05:45 -04:00
b885173da1 Embed PhotoCraft CLI Windows icon and version metadata (#889)
* Embed PhotoCraft CLI Windows icon and version metadata

The CLI crate now sets build to build.rs and takes winresource 0.1 as a
build dependency. On a Windows target the script embeds photocraft.ico
and sets ProductName to PhotoCraft, FileDescription to PhotoCraft
command-line interface, CompanyName to Learning Machines LLC, the shared
copyright line, OriginalFilename to photocraft-cli.exe, and InternalName
to photocraft-cli. Version strings keep the Cargo package version,
including a pre-release tag, and the numeric file and product versions
are major.minor.patch.0. A missing resource compiler prints a cargo
warning and the link continues, while PHOTOCRAFT_REQUIRE_WINRES makes
the script return an error that names rc.exe or windres. package.ps1
runs check-cli-resources.ps1 after the PE subsystem checks and before
signing, including SkipBuild artifacts, and that script checks those
VERSIONINFO fields against the package version and compares the icon
Windows extracts from the CLI with photocraft.ico drawn at 32x32,
without launching the binary. docs/releasing.md records the same CLI
resource contract beside the existing GUI one.

Refs #613

* Drop the untested check-cli-resources.ps1 release gate

It has never run on Windows, and a misfiring 200-line gate in package.ps1 would block
releases. The CLI build.rs icon/VERSIONINFO stays; PHOTOCRAFT_REQUIRE_WINRES=1 in
package.ps1 already makes a missing resource compiler a build error.

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

---------

Co-authored-by: Matt Van Horn <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:05:38 -04:00
Fahad Faruqi b44133c8b3 Document tabs: elide and overflow into a » menu when they are too many (#1276) (#1293)
The document tab strip laid every tab out at its natural width from the strip's
left edge and never clamped it, so once the tabs were wider than the window the
last ones ran off the strip: only a sliver stayed visible, and picking it
showed the document without bringing its tab into view. The dock *panel* strips
solved the same problem in #151 (elide, then a » menu that keeps the selected
tab on the strip); the document strips never got wired to it.

Both document strips now go through `tab_strip::fit`:

- tabs that don't fit shrink — long titles elide with '…' and show the full
  title on hover — and the ones that still don't fit move into a » menu at the
  end of the strip;
- the active document's tab always stays on the strip, and activating a
  document from the menu brings its tab straight onto it;
- files still opening in the background (#210) share the same index space and
  the same menu;
- tabs that fit keep their exact natural widths and the strip shows no menu,
  as before.

The » button is now a shared `tab_strip::overflow_button`, used by the panel
strips too. "More documents" is translated in every catalog.

Tests: `canvas/tabs_tests.rs` drives the whole app with 14 open documents
(Pro and Studio themes) and asserts the tabs stay inside the strip, the menu
holds the rest, and the active document's tab is the one on the strip.
2026-10-08 17:05:31 -04:00
0423f74b50 Paint Bucket and Gradient keep alpha on a transparency-locked layer (#1104) (#1193)
composite_area already honours lock transparency, but paint_gradient and
bucket_fill(_src) always passed false, and paint.bucket / paint.gradient
dropped the flag target_surface returns. Thread it through, as Edit > Fill
and the brushes already do.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:05:25 -04:00
2425431c78 layer.delete keeps a document's last layer (#1107) (#1189)
The single-layer path removed the layer with no guard, leaving a document
with no layers and no active layer. It now refuses with the same error as
the multi-select path; the edit is discarded, so nothing changes and no
history step is recorded.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:05:20 -04:00
278e294131 channel.merge: reject a repeated source index instead of closing an unrelated document (#1179)
Fixes #934. `channel.merge` closes its sources by current index, sorted and in
reverse, so `documents: [0, 0, 1]` passed validation and then ran `close(1)`,
`close(0)`, `close(0)`; the last call removed whatever document had shifted
into index 0, i.e. one that was never named, with its unsaved edits and
history. Repeated indices are now rejected as bad params before anything is
built or closed, and the first-source lookup no longer indexes the document
list directly.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 17:05:12 -04:00
9e6bfdc76c fix(format): refuse to save a document holding a NaN or infinite float (#1101) (#1172)
serde_json writes a non-finite float as `null`, and the manifest's plain
`f32`/`f64` fields refuse `null` on load. So a document that picked one up
(a PSD `expA` block, a descriptor double) saved and autosaved with a success
report, and every later open failed with `manifest JSON: invalid type:
null, expected f32`. JSON has no spelling for these values, so the save is
refused instead of writing a file that can never be read back.

`finite::check` walks the manifest with a serde `Serializer` that produces
nothing and fails on the first non-finite float, tracking struct fields,
sequence indices, variants and map keys so the error names the value
(`document.layers[2].content.adjustment.exposure`). `PcraftWriter::prepare`
runs it before serialising, so one-shot, incremental, directory and
autosave saves all share it. New `FormatError::NonFinite { path }` with an
actionable message.

Tests: unit (`finite_values_pass_and_each_position_is_named`: each
container kind reports its path) and integration
(`a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load`:
NaN/-inf in an Exposure layer and +inf in `resolution_dpi` are refused with
the field named, through both `save_to_bytes` and `PcraftWriter`, and the
finite document round-trips as before). The integration case was the
issue's repro: on main it saved and failed to load.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 17:05:04 -04:00
9d9e512d40 ORF: a file cut short inside IFD0 is malformed, not the compressed variant (#1008) (#1090)
The "is this the undecoded compressed/packed ORF" guard summed the strip
byte counts and compared the sum with width × height × 2. Tiff::tag_uints
returns an empty list when the entry is missing or unreadable, so a file
whose IFD0 entry table is truncated (a partial download, a damaged card)
summed to zero and was reported as "Olympus compressed (or packed) ORF is
not decoded" — a format the user's camera does not produce — while the same
file cut a few bytes earlier or later was correctly reported as malformed.

Missing or unreadable strip byte counts are now RawError::Malformed before
the compression check. Regression test: a synthetic uncompressed ORF cut
right before its StripByteCounts entry is Malformed, and no prefix of the
file reads as the compressed variant.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 17:04:58 -04:00
75b6931e71 Open camera raws in Camera Raw first (Open / Cancel) (#1309)
* Open camera raws in Camera Raw first (Open / Cancel)

Photoshop opens a raw file in Adobe Camera Raw before it becomes a
document; PhotoCraft developed it with the defaults straight away and
said so in a warning. An interactive open (File › Open, Open Recent,
drag and drop, the command line) of a raw developed from its sensor data
now shows the Camera Raw dialog first, titled "Camera Raw (name)", with
Open and Cancel.

- Open re-develops the raw when Temperature, Tint or Exposure changed:
  as-shot white-balance multipliers times the filter's gains, and the
  develop exposure (photocraft_io::raw::import_raw_tuned). The other
  settings are one filter.cameraRaw step; the document starts
  unmodified. Without an as-shot white balance or without the file's
  bytes, Temperature / Tint stay RGB adjustments as in the filter.
- Cancel closes the document again.
- The "developed with default settings" note is no longer shown when
  the dialog is.
- Preference rawDefaults.openInCameraRaw (default on), the first
  visible Camera Raw Defaults setting. Automation opens (app.open, MCP,
  CLI) never show the dialog.
- "NIKON CORPORATION" + "NIKON D3200" now reads "NIKON D3200" in the
  import note.

Tests: open → set → Open re-develops (brighter, still 16-bit, one
document, unmodified); Cancel closes; unchanged settings add no step;
preference off skips the dialog; a closed document is an error.
Translations for the two new strings in every catalog.

* Camera Raw open: re-develop in the background, keep tab and view

Open re-read and re-developed the whole raw on the UI thread. In the desktop
app it now runs as a job (cameraRaw.redevelop, document locked) like the
open itself; the reply is {job, pending} and the shell finishes when it
lands. The final Camera Raw step goes through app.run (a job too) and the
document is marked unmodified once it lands. Tests and the web still run
inline.

The re-developed document replaces the old one at the same tab position
and path, and its view (zoom, scroll) carries over. New test covers the
background path with documents on both sides.

wb_gains now uses camera_raw::white_balance_gains, shared with the Camera
Raw filter and documented as an observed approximation. 'Developing {name}'
translated in every catalog.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:04:51 -04:00
optimalbyteeandoptimalbytee 0ab30f0c9e Optimize Mosaic by averaging each cell once per tile (#1294)
Co-authored-by: optimalbytee <[email protected]>
2026-10-08 17:04:45 -04:00
4198da39b0 PSD export parallel layer channels; shared proxy cache; banded CMYK export (#1230)
* CMYK -> sRGB flat export: row bands through the colour transform

cmyk_image_to_srgb materialized three full-size f32 buffers (normalized
input, transform working set, sRGB output) - ~1.4 GB of allocation
churn on a 36 MP export. convert_f32 walks plain strided slices, so the
image now passes through in bands of rows (~32 MB budget): peak
transient memory drops by the document size times two, the output vector
is the only full-size allocation left. Byte-identical output: the
colour test pins the converted pixels and the tagged sRGB profile.

* Banded CMYK export: guard empty images, bounds-checked slices

(BAND_BYTES / (row * ss * 4)) divided by zero for a zero-width image and
clamp(1, 0) panicked for a zero-height one. Guard both, take each band with
checked arithmetic and data().get(), and reuse the output band buffer.
Tests: empty CMYK images convert without panicking; a banded conversion
covers every row.

Note: this PR's single change is the banded CMYK -> sRGB flat export; the
title's parallel PSD layer export and shared proxy cache are not part of it.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:04:39 -04:00
a32ba211c9 Clear, Cut and Image › Adjustments refuse a pixel-locked layer (#1103) (#1194)
* Clear, Cut and Image > Adjustments refuse a pixel-locked layer (#1103)

clear_area, destructive_adjust and auto_adjust took the layer's surface
directly. They now go through commands::paint_surface, which refuses a
pixel- or fully-locked layer with the same message painting uses.

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

* edit.cut: refuse a pixel-locked layer before filling the clipboard

copy() ran before clear_selected noticed the lock, so a refused Cut still
replaced the clipboard. Check the lock first (check_pixels_unlocked, shared
with paint_surface) and assert the clipboard is unchanged in the test.

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

---------

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 17:04:33 -04:00
88568297ab Show Clone Stamp source preview inside the brush cursor (#1042)
* Show Clone Stamp source preview inside the brush cursor

* Drop the Clone Stamp preview screenshots and doc page

Binary screenshots and a standalone doc page aren't wanted in-tree (review):
remove docs/clone-stamp-preview.md, the two PNGs, their licence file and the
ATTRIBUTION.md row.

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

* Clone preview: pass the command name to clone_sample and propagate its far-source error

clone_sample gained a cmd parameter and returns Result since #1165.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:04:27 -04:00
f41eb4f70d SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215) (#1305)
* SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215)

crates/io/src/svg.rs: usvg parses (already in the tree through egui_extras); paths become
ShapeLayer knots with the transform chain baked in, quadratics raised to cubics and every
subpath after the first joining the component so the fill rule spans the path as SVG does;
groups keep opacity and blend mode (transform-only and id-only wrapper groups vanish);
text is shaped into outline shapes with the craft fonts plus the system's; gradients become
the layer's gradient fill (stops and angle, with a warning). Clip paths, masks, filters,
pattern paints and images are rasterised by resvg into pixel layers over their bounding box
rather than dropped. Limits: 16384 px canvas, 2000 elements, 24 M px per raster, bounded
recursion; malformed input is IoError::Svg.

smart_cmds::render rasterises an SVG source at the placement's axis scale (1/16 steps,
cached per scale) and divides the placement by it, affine and projective alike; place_bytes
renders that cache up front. svg/svgz join the openable lists; book page; scorecard row done.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* SVG import: never read local files from <image href>

usvg's default image resolver reads any absolute or relative path in an
<image href>, so opening an untrusted SVG could embed local files
(/etc/..., ~/.ssh/...) into the document. Parse with a resolver that
only accepts data: URLs (resources_dir None, string hrefs ignored).
Embedded sub-SVGs already get no <image> resolution from usvg. The font
database (and its system-font scan) is now only loaded when the drawing
may contain text. Regression test: a PNG on disk referenced by absolute
path, file:// URL and relative path is not loaded; a data: URL still is.

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 17:04:21 -04:00
Claude 24d10c3dd6 Hand tool: translate Fill Screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 03:43:42 +07:00
Rozaq 8e09dcf655 fix: add hand tool scale buttons 2026-10-09 03:43:42 +07:00
TiredIsa 6197270230 Preferences: reset a single shortcut or menu colour override (#988) (#1064) 2026-10-08 16:39:16 -04:00
e63ff5196b Help › Search: clicking the search field keeps the menu open (#1027)
Menus close on any click, inside them too (egui's CloseOnClick), so a click
in the Help menu's search field closed the menu. The Help menu now closes
only on clicks outside it; its items (and search results) close it
themselves when chosen.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 16:39:10 -04:00
Matt Van Horn 649417af1d Relight: size the tile halo from σ, keep large images, speed up the blur (#895)
The neighbourhood radius was a 97 px constant (3σ+1 at σ=32) while softness
can drive σ to 96, so tiles seamed once the short side reached about 1600 px.
Halo is now 3σ+1 plus the finite-difference step, taken from the document's
σ (or the 96 cap when size is unknown).

The kernel used to copy the input unchanged above 8 MP of luma. Large rects
are split into overlapping bands instead, so an 8 MP+ layer is actually relit.

Replace the O(nσ) separable kernel with the three-box Gaussian already used
for other smoothing fields in this crate.
2026-10-08 16:39:05 -04:00
e8850ac53a Filters respect layer locks; render filters and Fill Path refuse a pixel-locked layer (#1102) (#1195)
* Filters refuse a pixel-locked layer (#1102)

run_filter never consulted layer locks. It now refuses a pixel- or
fully-locked layer with the same message painting uses. Channel, Quick
Mask and mask targets are unchanged.

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

* Filters keep alpha under lock transparency; render filters and Fill Path refuse a pixel-locked layer (#1102)

- run_filter puts the layer's alpha back in the tiles a filter changed when
  transparency is locked (the same tile walk as restrict_to_color).
- Flame / Tree / Picture Frame take the surface through paint_surface.
- path.fill refuses a pixel- or fully-locked layer.

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

---------

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:38:59 -04:00
Nigel Ihza Firdausy 7b4e816caf Keep empty selections empty for subtract and intersect (#1190)
* fix(engine): leave empty selection empty for subtract and intersect

* test(engine): cover empty selection subtract and intersect modes

* test(engine): read selection coverage through Surface API

* test(engine): cover selection subtraction history and format regressions
2026-10-08 16:38:53 -04:00
2b90697d48 Delete with nothing selected deletes the selected layers, of any kind (#1077) (#1087)
Delete / Backspace is Edit › Clear's key, and Clear only works on pixel
layers, so on an adjustment, fill, type or shape layer the key reported
"not available" and nothing happened. As in Photoshop, with no
selection the key now deletes every selected layer (one undo step). A
selection, a targeted layer mask, a single channel or Quick Mask keeps
Clear.

Co-authored-by: metzmehard <[email protected]>
Co-authored-by: Liohtml <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:38:47 -04:00
Minghang Chamling 925bdfe335 fix(text): keep tiny EngineData reals parseable (#979) (#1085) 2026-10-08 16:38:41 -04:00
llduyll10andClaude Opus 5.5 f985b81300 Layer style: an Outside stroke at Fill below 100 % leaves the interior clear (#906)
Fill 0 % + an Outside stroke is Photoshop's "outline only" look, but on pixel
layers the stroke lay beneath the whole interior at full coverage, relying on
the layer composited on top to hide it. With the fill lowered the interior
showed the stroke colour (a solid disc instead of a ring).

The share beneath an interior pixel is now (1 - a) / (1 - c), with a the
shape's coverage and c the layer's alpha after fill: 1 under an opaque
layer (100 % fill renders exactly as before, so PSD oracles don't move),
1 - a at 0 % fill. CPU compositor and the wgpu shader agree (the layer's
alpha now rides along the coverage seed for pixel layers too).

Tests: compose regression at 8/16/32-bit (0 %, 50 %, 100 % fill), and a GPU
parity case at fill 0 and 0.5 (fails without the shader change).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:38:35 -04:00
f6c34f63cb Add Polish translation (#1192)
* Add Polish translation

* Polish: clippy-clean plural rule, plural test, ATTRIBUTION and docs rows

plural_polish used manual range checks (clippy::manual_range_contains) in a
match (); now a plain if/else with ranges. Adds polish_plural_rule, the
pl.tsv ATTRIBUTION row like the other catalogs, and the localization doc row.

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

* pl.tsv: drop three duplicate keys (Wave, High, Low)

bundled_catalogs_are_consistent and i18n-coverage reject duplicate
(context, source) pairs; the first translation of each is kept.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:38:28 -04:00
077888ea03 PSD plane shuffles in bands; the document histogram computes off the UI thread (#1185)
* PSD plane shuffles in bands; the document histogram computes off the UI thread

interleave/deinterleave walked every pixel of a 20 MP image on one
thread during PSD open and save. Both now split the rows into one band
per core (the file's own par_map/bands helpers) and merge the band
results in order: 20 MP RGB u16 interleave 152 -> 101 ms, deinterleave
461 -> 160 ms (2.9x); f32 rgba 329/730 -> 220/396 ms. Behaviour is
pinned by a parity test against the sequential reference at odd pixel
counts (band boundaries), all depths, with a missing plane in the mix.

The Histogram panel computed its payload inline in the frame every
250 ms while stale - a 41.5 ms UI-thread stall measured on a 24 MP
document, on top of brush work. compute_histograms now runs on a
worker thread; the panel keeps showing the previous histogram with the
existing cached-data warning until the result lands, and a stale
result cannot overwrite a newer revision. Pinned by a panel test that
drives one egui frame and asserts the job is scheduled, delivers off
thread and respects revisions.

* Histogram panel: compute inline when no thread can be spawned; drop the benchmark test

std::thread spawn fails on wasm32 (and can fail natively), which left
hist_job waiting on a disconnected receiver forever. Without a worker the
histogram is now computed inline and delivered through the same channel, a
disconnected job is cleared, and the perf span is measured in the worker.
Removes the dead t0 and the wall-clock benchmark test; adds a dead-worker test.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:38:22 -04:00
ba222715b3 filter.cameraRaw: refuse an area too large to develop instead of overflowing (#1183)
* filter.cameraRaw: refuse an area too large to develop instead of overflowing

Fixes #962. `rgba_region` sized its buffer with `area.width() * area.height()`
in u32, so a legal 65536×65536 canvas (2^32 pixels) overflowed the product and
the command's `run` panicked; dispatch turned that into an internal error. In
release the product wraps to 0 and the first tile read would index past the
empty buffer instead.

The command now checks the area it is about to develop (canvas ∪ the layer's
content) against `MAX_RAW_PIXELS` (400 MP, ≥ 48 bytes per pixel of working
memory) and returns bad-params with the size and the limit. `camera_raw_surface`
carries the same guard so the preview and Smart Filter paths can never overflow
either; the buffer length is computed in u64.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* lens_cmds tests: move the wasm32 cfg back onto lens_correction_batch_over_files

The new Camera Raw test was inserted between the cfg and the batch-over-files
test, which gated the wrong test.

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

---------

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 16:38:15 -04:00
64da6bb455 Fit Artboard on Screen: no i32 overflow for a board with large coordinates (#1181)
* Fit Artboard on Screen: no i32 overflow for a board with large coordinates

Fixes #981. `fit_artboard` computed the view centre as `(b.x0 + b.x1) as f32`,
which overflows i32 for a board near ±2^30 on either axis, and for a board
whose far edge `Rect::from_xywh` saturated at i32::MAX. In test and debug
builds the shell view command panicked outside any dispatch guard; in release
the sum wrapped and the view was centred on the wrong point. The midpoint is
now computed in f64 and rounded to the f32 the view stores.

The same repro hit a second overflow on the engine side: `layer.new.artboard`'s
default placement added `GAP` to the rightmost board's far edge, which may
already be i32::MAX. That is now a saturating add (the saturated base then
fails rect validation with an actionable error instead of panicking).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Drop the artboard_cmds.rs overflow hunk (#1032 fixes it in i64); keep the f64 midpoint

The engine's default-placement overflow lands with #1032, which rejects boards
outside the i32 range instead of saturating. Keep only the Fit Artboard f64
midpoint fix; its test now uses a board that stays in range under #1032.

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

---------

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 16:38:09 -04:00
才倾梦华V 34225c0f4a fix(ui): cancel transforms in their originating document (#1170) 2026-10-08 16:38:02 -04:00
5e13abd67c A non-Unicode command-line argument is a usage error, not a startup panic (#1108) (#1168)
* fix(apps): a non-Unicode argument is a usage error, not a startup panic (#1108)

Both binaries read `std::env::args()`, which panics on an argument that is
not valid Unicode: `photocraft-cli info $'caf\xe9.png'` died in
`library/std/src/env.rs` before printing anything, and a file manager
handing the desktop app a Latin-1 file name would do the same before any
window opened.

CLI: `photocraft_cli::run_os` takes the raw `OsString`s, and an argument
that does not convert is a usage error naming its position and a lossy
rendering (exit 2, with the usage text), like any other bad argument.
`main` calls it; `run(&[String])` is unchanged for in-process callers.

Desktop: the loop matches on `OsStr::to_str`. Flag values that are paths
(`--control-token-file`, `--automation-*-root`) become `PathBuf`s directly
and so keep working with any name; a non-Unicode `--control-token` is a
control-argument error like a bad `--control` port; non-Unicode file
arguments are collected and reported in a "Could not open" notice once the
app is up, so the launch still succeeds.

Test: `a_non_unicode_argument_is_a_usage_error_not_a_panic` (unix) runs
the binary with a `caf\xe9.png` argument for `info` and `convert` and
asserts exit 2, the message, and no panic output.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* photocraft-cli: put run()'s doc line back above run, not run_os

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

---------

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 16:37:56 -04:00
BFlatandClaude Opus 5.5 83936bdcee Fix #1271, fix #1316: Preferences › Interface › System Title Bar (#1344)
* Fix #1271, fix #1316: Preferences › Interface › System Title Bar

Since #986, Windows and Linux draw PhotoCraft's own one-row title bar
with its own window buttons. On tiling window managers those buttons do
nothing, and some Linux desktops look better with their own decorations.
The new System Title Bar preference (off by default) gives the window the
system's title bar and buttons back; it is read when the app starts.
macOS always uses the system's. Translated in every shipped language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Scorecard: count the System Title Bar preference

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <[email protected]>
2026-10-09 03:36:21 +07:00
Streetblock 84b76a8c9c Preflight pattern allocations and reuse decoded channels (#1242) 2026-10-08 16:19:46 -04:00
UnemarsandClaude Opus 5.5 f08cffd4d1 Select › Color Range: Photoshop's Sampled Colors model, Subtract from Sample and the eyedropper on the image (#1239)
* Color Range: Photoshop's Sampled Colors model and the eyedropper on the canvas

Sampled Colors now compares colours in Lab (through the document's profile) the way
Photoshop does, reconstructed from Photoshop 2024's own masks (observation only):
per-axis distance outside the samples' Lab box in 8-bit units, a/b weighted x3, the
largest of the three, and a quadratic B-spline falloff of width fuzziness*256/255.
On 8-bit sRGB images it matches Photoshop to under one level of 255 on average
(the previous per-channel RGB distance was off by about 12). Several samples span
one Lab box, like Photoshop's Minimum/Maximum.

While Color Range is open, the pointer over the free canvas is its eyedropper:
a click picks that pixel, Shift adds to the sample, Alt subtracts (also through
ui.pointer on the control channel).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Color Range: Photoshop's Subtract from Sample, click order, 8-bit Lab grid

Measured through Photoshop's real Color Range dialog (eyedropper clicks):
- Subtract from Sample reshapes the samples' Lab box per axis: inside it the
  nearer edge moves onto the subtracted value; on an edge or within the axis
  width w it moves in to the first of m + k*w/5 beyond it (k <= 5); further out
  nothing changes; an edge never crosses the other. Matches every measured box
  to +-0.05. The click order matters, so select.colorRange takes an optional
  `order` list and the dialog records it.
- The a/b centring shift of the previous change was an artefact of scripted
  samples (Photoshop's descriptor Lab scale); with the eyedropper the sampled
  colour is fully selected at any fuzziness. Removed.
- 8-bit documents compare pixels and samples on the 8-bit Lab grid, where an
  a/b step above the samples' range counts half a unit more and one below half
  a unit less (lab_range_coverage_8bit).
On Photoshop's eyedropper masks of a photo the mean error is ~0.46 levels of
255 at fuzziness 40/80 and ~0.02 at 5/10 (previous change ~0.9).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 16:19:39 -04:00
Nigel Ihza Firdausy 1a9a39eadf fix(text): discover CJK system fonts through Flatpak host mounts (#1204) 2026-10-08 16:19:32 -04:00
0d11e76fef Toolbar: the header chevron switches between one and two columns (#1197) (#1200)
* Toolbar: the header chevron switches between one and two columns (#1197)

The Pro theme's chevron was painted with a hover-only sense, so clicking it
did nothing. It now toggles Panels::toolbar_double (serde default, so older
state still loads); the toolbar is two columns when that is on or when one
column doesn't fit, and the chevron points left while two are shown.

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

* Toolbar: the Pro two-column toolbar fills every row (#1197)

Rows were cut per tool group, so a group with an odd number of tools left
a half-empty row (Move, Eyedropper). Pro draws no group dividers, so its
two columns now flow across groups; the other themes keep their per-group
rows between dividers.

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

---------

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:19:26 -04:00
Shayan Horbank ba96d741fb Improve Color Picker layout and spacing (#1188) (#1196) 2026-10-08 16:19:19 -04:00
0d6e58e2f4 fix(io): a PSD note icon near i32::MAX saturates on save instead of overflowing (#939) (#1176)
`write_note` built the icon rectangle as `[y, x, y + 20, x + 16]` with plain
i32 arithmetic on a position that comes straight from the file's `Anno`
block (or from `notes.set`). A stored icon top at or above `i32::MAX - 19`
overflowed on every PSD save, because `export_blocks` regenerates the
comparison block before deciding whether the stored one can be kept
verbatim: a panic in debug (surfaced by `file.saveACopy` as an internal
error, no file written), a wrapped bottom edge in release.

The far edge now uses `saturating_add`, matching the cast that already
saturates the origin, so the written rectangle is always representable and
the note survives a round trip at its stored position. No signature change:
`write_note` stays infallible and unchanged notes still compare byte-exact.

Test: `a_note_icon_near_i32_max_saves_with_representable_geometry` builds
the issue's `Anno` fixture at `i32::MAX - 5` (and a far-left and
`i32::MIN` variant), imports, exports, checks the written icon rectangle
and re-imports; also covers a model position beyond i32 and NaN, and the
ordinary `[42, 28]` control. Panicked at `annotations_map.rs:183` on main.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 16:19:13 -04:00
DWAA1660 c1f7e5f3b1 Fix shared layer style angles and add radial angle dials (#882) 2026-10-08 16:19:05 -04:00
Gert Pellin - GPE 3d09578322 raw: decode Nikon compressed NEF (lossless + lossy) (#1310)
* raw: decode Nikon compressed NEF (lossless + lossy)

Nikon's default NEF (TIFF compression 34713) opened only as the embedded
8-bit JPEG preview. Decode the Huffman-coded strip: lossless and lossy
type 1 / 2, 12 and 14 bit, with the seeds and curve of maker note 0x0096
in the maker note's byte order. "Lossy after split" files stay
unsupported and keep the preview fallback.

Clean-room: format facts from prose write-ups (Clévy, Claff, ExifTool tag
names, T.81). The fixed Huffman tables are not in the files; they come
from LightCraft's black-box analysis of CC0 raw.pixls.us samples
(storytold/lightcraft#86, verified there on ~45 files from ~35 bodies).
No decoder code is shared: nefc.rs is written against this crate's TIFF
reader.

Also read Nikon BlackLevel (0x003d) in 14-bit units for 12-bit data, as
measured for LightCraft (D750 / D850 / Z 50).

Refs #50.

* raw: Nikon NEF without a BlackLevel tag is black-subtracted in camera

A D3200 NEF's darkest samples are 0-4 (noise around zero): assume 0
without the "black level not recorded" warning when the Nikon maker
note is present but has no 0x003d tag.

* raw: trim Nikon's right-edge padding columns

A D3200 NEF pads every row with 46 columns of 256 (exact in 97.5% of the
rows, lossy-coding noise elsewhere) after its 6034 image columns; they
developed as a pink stripe on the right. Trailing columns (at most 128)
that hold the last column's value in at least 90% of their rows, when
that value is dark, are left out of the active area and crop. A white-
clipped edge is never trimmed.
2026-10-08 16:18:58 -04:00
DWAA1660andClaude Opus 5.5 347b6c9009 UI Scale Auto keeps the system's fractional scale on 4K (#1072) (#1237)
Auto forced at least 200% on any 4K monitor, overriding a fractional
system scale such as 125% or 150%, so Linux users on 4K got either
100% or 200% and nothing between. Auto now follows whatever scale the
system reports and uses the 200% fallback only when the system reports
no scaling (100%), which is the case #225 was fixing.


Claude-Session: https://claude.ai/code/session_01QcbChgBDVrdhjfsFA5D9nt

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 03:17:18 +07:00
a41800c466 fix: resolve edge-case crashes, unbounded allocations, and UI sync defects (#1010)
* fix: resolve edge-case crashes, unbounded allocations, and UI sync defects (#915, #916, #917, #918, #920, #921, #923, #924, #925, #926)

* Review fixups: drop hunks covered elsewhere, simplify shortcut conflicts

- Drop the brush preview spacing_enabled hunk (#941), the vanishing.rs
  overflow guards (#869) and the artboard_cmds.rs Custom background change
  (conflicts with #1032); ui.window.open already landed in #1133.
- Revert the Measurement Log export change: an empty selection keeps
  exporting the whole log instead of an empty CSV.
- Drop the reach().min(1024) cap: effects still sample past a clamped reach,
  which would give tile seams.
- Shortcut dialog: one pass, newly assigned shortcuts first, taking theirs
  from any holder (overrides included); unchanged overrides only clear
  defaults. More test cases (no-op OK, giving a shortcut back).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:09:38 -04:00
Gert Pellin - GPE cbed56b7f1 Desktop: write log records to standard error and a rotating log file (#1143)
* Desktop: write log records to standard error and a rotating log file

The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: GPU startup fallbacks, a lost wgpu device,
the crash guard's panic report. A launch from a desktop menu has no
terminal either, so there was nothing to attach to a bug report.

apps/photocraft/src/logging.rs (no new dependency):
- standard error plus <settings dir>/logs/photocraft.log; each launch
  rotates the previous log to photocraft.1.log and .2.log, so the log of
  a run that crashed survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for photocraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink

The logger is installed first in main (so the panic hook is recorded) and
the file is opened after argument parsing (--version and usage errors
leave no file). Documented in docs/development.md (Logs).

* Contributors: credit switch87 as Gert Pellin

* Desktop: report an unusable log folder through the logger

eprintln! panics when standard error is a broken pipe (a GUI launch on
Windows); log::warn! reaches standard error the same way once the file
sink has given up, and never panics. Same fix as storytold/vectorcraft#465.
2026-10-08 16:09:32 -04:00
Elvis33LE 3ef631619e Autosave: a thread that fails to start is reported, not a silent no-op (#1124)
Autosaver::new discarded the spawn result with .ok(): under thread or
resource exhaustion the recovery channel pointed at a dead receiver,
every send failed silently and last_result stayed None - crash
recovery was off while looking on. The failure is now recorded where
last_result/flush report it (via the same note helper the worker uses),
so health checks and flush surface it.
2026-10-08 16:09:27 -04:00
yangandClaude Opus 5.5 ba4dc112bb Artboard placement, edits and export reject coordinates past the 32-bit range instead of overflowing (#931) (#1032)
Default placement added the 100 px gap to the rightmost board's right edge in i32,
editing a board subtracted its old position from the new one in i32, and exporting
negated the board's origin, so a board at an i32 extreme overflowed (a panic in checked
builds, a wrapped position or content offset in release). Rects were also built with
Rect::from_xywh, which clamps the far edge and silently shrank boards past i32::MAX.

Board rects are now built in i64 and checked, so a rect with any edge outside i32 is a
bad-params error naming it; default placement with no room on the right asks for an
explicit x; a move whose offset needs more than 32 bits is rejected unless
moveContents is false; and export reports a board at i32::MIN instead of negating it.
All checks run before the document changes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:09:21 -04:00
BFlatandClaude Opus 5.5 f0208fd798 Move tool: layers overhanging the canvas keep their effect maps; moves copy 2x faster (#761, #128) (#893)
- A layer about the canvas's size keeps its whole effect region even where it overhangs the
  edge, so the region keeps its size while the layer is dragged and the GPU moves its effect
  maps with it instead of rebuilding the shape, distance fields and every effect each frame.
- Surface::translated moves a surface by copying encoded tile bytes tile to tile (no float
  round trip, no full-size intermediate; whole-tile moves share the tiles): 2.3x faster than
  to_interleaved + write_interleaved. Used by layer.translate, Move tool drags and
  algo::resample::translate_surface.
- perf_scenarios: Move tool drag frame rows (styled layer, full-canvas layer with and without
  a drop shadow).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:09:16 -04:00
Elvis33LE b44cad8747 MCP doc_save: bridge mode refuses headless-only options instead of saving with defaults (#1138)
The bridge branch forwarded only the path to the running app's
app.save, silently dropping quality, format, tiffLayers and index (the
headless branch honours all of them): a client asking for a
quality-50 JPEG got an ok reply and whatever the app's current
settings produce. Those params now fail fast with the reason - naming
headless mode - before anything is written.
2026-10-08 16:09:10 -04:00
yangandClaude Opus 5.5 cd03d49c34 Layer comp offsets out of i32 range are dropped with a warning, not overflowed (#1017) (#1039)
Comp positions were computed with unchecked i32 arithmetic on values from
files: import added a layer's origin to the cmls `Ofst` (and truncated wider
integers with `as i32`), export subtracted the origin from the stored
position, and applying a comp subtracted the current position. Extreme values
panicked with overflow checks on and wrapped otherwise.

Import now keeps offsets at i64 and drops a position whose sum does not fit,
with an import warning; export omits an `Ofst` that does not fit; applying a
comp skips a move that does not fit. Visibility of the same entries is kept.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:09:06 -04:00
26b6aac9ac Selection tools: ⌘ moves like the Move tool; ⌘⌥ copies (pixels inside the selection, the layer outside) (#896)
* Selection drags: ⌘⌥-drag floats a copy; ⌘-drag with the Polygonal Lasso and Magic Wand

- `select.float {"copy": true}` floats a duplicate of the selected pixels
  and leaves the originals in the layer; ⌘⌥-drag inside the selection uses
  it (⌘-drag still cuts).
- ⌘-drag inside the selection now also floats it with the Polygonal Lasso
  and the Magic Wand (not while a polygon is being drawn); a plain press
  there still places a point / samples, so only ⌘ moves the selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* ⌘ / ⌘⌥ with a selection always cut / copy and drag it, never combine selections

With a selection tool and an active selection, a ⌘ or ⌘⌥ press anywhere (not
only inside the ants) floats the selected pixels (cut, or copy with ⌥) and
drags them; ⌥ no longer turns it into a subtract. The cursor shows no
selection-mode badge while ⌘ is held over a selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* ⌘ with a selection tool moves the layer outside the selection; ⌘⌥ duplicates it

With a selection tool, ⌘ acts as the Move tool for the drag: inside the
selection it cuts the selected pixels (⌘⌥ copies them), and outside the
selection, or without one, it moves the whole layer; ⌘⌥ duplicates the
layer first and moves the copy. ⌘ never combines selections, so the cursor
shows no add / subtract badge while it is held.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* ⌘ outside the selection moves the layer, not the selected pixels

Only a ⌘ press inside the selection floats its pixels; outside it the layer
moves (`command_moves_layer`). Drops the duplicate badge check.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* ⌘⌥-drag on a floating piece drops it and drags a copy

A floating piece (from a ⌘ cut-drag) dragged again with ⌘⌥ is dropped where
it is and a copy of it floats with the pointer, as ⌘⌥ does on any selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 16:09:00 -04:00
Tanish Manku 964a41dc02 Corpus: pin the deep OpenEXR test images and check them in CI (#881)
The deep EXR decoder (#642) was verified against the OpenEXR project's deep test images, but those
lived in a hand-copied `corpus/exr/` and the test skipped when they were absent, so CI never read
them. `cargo xtask corpus --exr` (and `--all`) now fetches the five `*.deep.exr` files of
openexr v3.5.2 `src/test/bin/test_images`, sha256-verified against `xtask/exr-corpus.sha256`.

`real_deep_files_decode` is behind the `corpus` feature and fails when the corpus is missing. It
checks each file's sample total, largest per-pixel count, cumulative counts and channel lengths,
and that the decoded u32 ID channels hold exactly the IDs listed by the upstream `.txt` sidecars
(an independent oracle), plus the flattened decode. FILE-216-3 moves to partial (EXR read in CI;
TIFF and RAW still are not).
2026-10-08 16:08:56 -04:00
Nigel Ihza Firdausy 05b405aab0 fix(cli): report missing font fallbacks in run, info and convert (#1299)
* fix(cli): report missing font substitutions in run, info, and convert (#1251)

* test(cli): verify missing fonts in run/info/convert and no duplicate or false warnings

* docs(cli): document missing-font fallback warnings in convert, info and run

* style(cli): apply exact rustfmt format for multi-command warning test
2026-10-08 16:08:50 -04:00
a593b3651c CPU effect maps: key fill layers by their cached pixels, drop opacity/visible (#1059)
* CPU effect maps: key fill layers by their cached pixels, drop opacity/visible

The layer-effect map cache keyed a Fill layer by its Fill spec alone.
But what shapes the maps is the layer's cached pixels whenever they
still match the fill (what a PSD import stores and Image > Transform /
crop / canvas ops rewrite in place, without touching the spec): after
such a rewrite the CPU compositor kept serving the pre-rewrite maps, so
flattens and the CPU canvas showed the drop shadow of the pre-rewrite
shape while the GPU canvas was correct - until budget eviction or a
purge. The key now hashes the cache surface's tile fingerprint while it
matches the fill, like the GPU's fx::shape_key/group_key already did.

layer.opacity and layer.visible leave the key: the maps depend only on
fill_opacity (opacity is applied when the maps are composited,
visibility only skips the lookup), so an opacity slider drag or a
visibility toggle rebuilt every shadow and glow map over the whole
layer for nothing. Again matching the GPU key.

* Effect-map key: keep child visibility/opacity/blend for groups

A styled group's maps come from its children's composite, so hiding a
child or changing its opacity (or blend, clipping) must change the key;
only the top layer's own visible/opacity stay out. Adds a warm-vs-purged
render test for a group with a drop shadow.

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

* Effect-map test: name the edit tuple type (clippy type_complexity)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:08:45 -04:00
1dd9a34181 Option flips Dodge/Burn and Blur/Sharpen for a stroke (#1038)
* Option flips Dodge/Burn and Blur/Sharpen for a stroke

Holding Option (Alt) when a stroke starts paints with the opposite tool:
Dodge burns, Burn dodges, Blur sharpens and Sharpen blurs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Tests for the Option flip of Dodge/Burn and Blur/Sharpen

A unit test for alt_flipped and a stroke test through tool_event with
Option held that checks the journal runs the opposite command.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 16:08:40 -04:00
2c9ae1fce6 Eyedropper cursor: a pipette instead of the crosshair when sampling (#1028)
* Eyedropper cursor: a pipette instead of the crosshair when sampling

The Eyedropper tool and ⌥-sampling with a painting tool now show the same
pipette pointer as the Color Picker's eyedropper (its tip on the sampled
pixel). Preferences › Cursors › Other Cursors = Precise keeps the crosshair.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Keep alt_eyedropper's doc comment on alt_eyedropper

pipette_cursor was inserted between the comment and its function.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 16:08:33 -04:00
96413cff45 Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple (#1021)
* Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple

Grayscale deep files (Y + A + Z) flattened wrong: the tail of the
composite loop wrote a fixed [R, G, B, A] quadruple, so a GrayA image's
alpha slot received an un-premultiplied luminance copy (alpha 1.0 for a
0.5-alpha file) while the real alpha fell past the end of the row. The
values are now written by layout: RGB(A), gray(+A), gray - with the
alpha in the slot it belongs to.

* Deep EXR flatten: no per-pixel Vec allocation

Build a fixed [f32; 4] plus a length instead; this runs once per pixel.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:08:27 -04:00
3088577f46 feat(ui): edit the Color panel's hex and RGB readouts (#1016)
* feat(ui): edit the Color panel's hex and RGB readouts

The Color panel's hex readout was a static label. It is now an editable
field: type a colour with or without the leading '#', and it applies as
you type. The R, G and B readouts beside it become value fields too, so a
channel can be set directly.

Both use the dock's themed field surfaces, so they match the rest of the
panel. Partial or invalid input never changes the colour and snaps back
when focus leaves. Editing updates the foreground (or background) and
keeps the panel's hue memory for greys.

Tests cover hex with and without '#', partial input, the RGB fields and
the pro panel's readout.

* rustfmt panels.rs

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:08:22 -04:00
BFlatandClaude Opus 5.5 5a2f42c476 CI: build faster and stop running out of disk and cache (#1075)
- Debug/test builds use line-tables-only debug info: the workspace test
  build is 1.9x faster (509 s -> 271 s, measured on Windows) and its
  target dir 31 GB -> 13 GB, which also fixes the Linux job's craft-fonts
  build running out of disk.
- The corpus job's --release build drops thin LTO and the single codegen
  unit: 2.7x faster to build (744 s -> 273 s); it was 23.5 of 37 minutes.
- Only main saves rust-cache entries (PRs restore main's), with
  cache-on-failure, so per-PR copies no longer evict main's caches from
  the 10 GB budget.
- The Linux job deletes unused image toolchains in the background.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 02:46:00 +07:00
BFlatandClaude Opus 5.5 8ebdd36769 Tools leave hidden layers alone: painting or moving a hidden layer is refused (#571) (#872)
* Tools leave hidden layers alone: painting or moving one is refused (#571)

Photoshop refuses to paint on, fill or move a hidden target layer ("the
target layer is hidden"). The painting, retouching, Paint Bucket, Gradient
and Move commands now fail with that message when the target layer, or a
group around it, is hidden (alpha channels and Quick Mask still paint), and
the canvas refuses the press so no live stroke draws first. Auto-Select
already skipped hidden layers; a test now pins it.

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

* Hidden layers: the live Gradient still works over a hidden target

Review on #872: the live Gradient adds a Gradient Fill layer above the
target rather than painting it, which Photoshop allows over a hidden
layer. Only the classic Gradient is refused now, and a test covers the
live one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 02:45:55 +07:00
Pathos0925andClaude Fable 5.1 9df3a481ee New adjustment and fill layers take the active selection as their mask (#1250) (#1282)
Photoshop masks a new adjustment or fill layer with the active selection, so the
adjustment acts on the selected area only. `layer.newAdjustmentLayer.*`,
`layer.newFillLayer.solidColor`, `.gradient` and `.pattern` inserted the layer
without reading `doc.selection`, so select → New Adjustment Layer gave an
unmasked adjustment over the whole image. The Gradient tool's fill-layer
command already did the right thing; its inline code becomes the shared
`commands::selection_mask`, used by every creator. The selection stays active,
as after Layer › Layer Mask › Reveal Selection.

Tests: each command at 8, 16 and 32-bit gets a linked, enabled mask that reveals
the selection and hides the rest, the composite outside the selection is
untouched, undo removes the layer; and no mask without a selection.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 15:42:23 -04:00
Jamie Pates 3952fcef79 Resolve issue that makes Photocraft save various file formats incorectly as a .psd (#1178) 2026-10-08 15:42:17 -04:00
MokuLakshithReddy 7de2b5f20e Nearest Neighbor: sample single source pixels on reductions without averaging (#1114) (#1141) 2026-10-08 15:42:11 -04:00
BFlatandClaude Opus 5.5 ab4920a4df Ctrl/Cmd-scroll pans sideways; canvas scroll bars always shown (#635, #620) (#1069)
As in Photoshop, the wheel scrolls vertically, Shift or Cmd/Ctrl + wheel
scrolls sideways and Alt + wheel zooms; Zoom with Scroll Wheel swaps the
wheel and Alt + wheel. egui no longer turns Cmd/Ctrl + wheel into zoom,
so only a real pinch (Event::Zoom) zooms. Windows delivers a touchpad
pinch as Ctrl + wheel in fractions of a notch: the raw input hook turns
those back into pinch events, so pinch-zoom keeps working there while
Ctrl + a notched mouse wheel pans.

The canvas scroll bars are always shown in Standard Screen Mode (none in
the full screen modes, as in Photoshop). With Overscroll on, an image
that fits the window can be scrolled edge to edge with them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:42:06 -04:00
BFlatandClaude Opus 5.5 938c079d68 Layers panel: Alt-click the line between two layers clips the upper one (#967) (#1066)
Holding Alt (Option) over the line between two layers in the Layers panel
shows the clip cursor; a click runs Layer > Create Clipping Mask on the upper
layer, or Release Clipping Mask when it is clipped already, as in Photoshop.
The line only counts between siblings that are next to each other in the
stack (not inside an open group or across filtered-out rows), and stays
clear of the eye column and the effects triangle. The cursor is the Lucide
corner-right-down icon.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:42:00 -04:00
Ian Swope e06c03344a Holding a number key sets one digit's opacity, not a two-digit value (#953) (#1030)
opacity_keys::handle ignored egui's `repeat` flag, so the first auto-repeat
of a held digit arriving within the 800 ms two-digit window read as a
second typed digit: holding 4 set 44%, and holding 0 flipped between 100%
and 0%. Repeats are now consumed without being counted.
2026-10-08 15:41:55 -04:00
Elvis33LE d438f84225 Shape gradients: honour midpoints, opacity stops, offset, dither and align (#1321)
vector::Paint::from_fill discarded every gradient field beyond stops,
angle, scale and style, so Photoshop files whose shape fills use opacity
stops, colour midpoints, a centre offset (Ofst), dither or no
"Align with layer" rendered with the wrong ramp, alpha, position or
banding. Paint::Gradient now carries the full ramp (sorted stops,
midpoints, opacity stops, dither) and the compositor's geometry
(centre offset, whole-pixel end-point snapping, pixel-corner sampling,
the align frame choice: shape bounds or canvas), with shape strokes
painting through the same path.

The vector crate cannot depend on compose, so the ramp and geometry
mirror effects::gradient_units/gradient_t, fill_layout::gradient_layout
and gradient_fill::{Ramp, midpoint_remap, dither}. A parity test in
compose (which sees both) renders each non-default field, style and
frame as a shape layer and as a fill layer and requires equal pixels;
from_fill binds every Fill::Gradient field — no ".." — so a new field
fails to compile until the shape renderer honours it.
2026-10-08 15:41:50 -04:00
才倾梦华V 3dd4fd1574 Keep the Background position lock after Flatten Image (#1238) 2026-10-08 15:41:44 -04:00
af3c99d817 fix(engine): Clone Stamp and Healing Brush refuse a far source instead of slicing out of bounds (#1111) (#1165)
`Mapping::offset()` saturates its `as i32` cast and `Rect::translate` saturates
each edge, so a far `source` or `offset` (3e9, i32::MAX) produced a sample
window narrower than the stroke bounds. `clone_sample` then relabelled that
region as the full-size rect and `Region::px` indexed past the buffer:
`range start index 120 out of range for slice of length 92` at
`paint/src/retouch.rs:54`, which the dispatch guard reported as an internal
error. #716 fixed the rotated/scaled branch (`source_rect`); this is the pure
translation branch.

`clone_sample` is now fallible: when the translated window does not keep the
stroke rect's size, the command returns a bad-params error naming the offset
and the document and history are untouched. A source far outside the canvas
but inside the i32 range still clones transparency, as before.

Test: `clone_and_heal_with_a_far_source_are_plain_errors` (both tools, the
issue's inputs, a negative far source, the untouched-layer and no-history-step
assertions, and the in-range far source that must keep working).

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 15:41:38 -04:00
4860cef703 Absolute colorimetric refuses a zero or non-finite media white (#972) (#1125)
connection() scaled the PCS by src.media_white() / dst.media_white() with
no check on the divisor, so a v4 profile whose wtpt tag holds zeros (parsed
and stored verbatim) gave a NaN scale, and every colour through a direct
absolute transform or a paper-simulating proof came out NaN. The transform
is now refused with CmsError::Invalid when either white has a zero,
negative or non-finite component; the other intents never divide by it and
are unchanged.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 15:41:32 -04:00
BFlatandClaude Opus 5.5 fa186c59b9 Paste into a targeted layer mask or channel instead of a new layer (#1035) (#1078)
With the layer mask targeted (its thumbnail clicked, or Alt-clicked to view
it), Edit > Paste, Paste in Place, Paste Into and Paste Outside now paste
the clipboard's luminosity into the mask, placed as a paste places it, over
the mask's values (the pasted pixels' transparency, and the selection for
Paste Into / Outside, let the mask show through). One history step, no new
layer, at 8, 16 and 32 bits. A targeted alpha channel or Quick Mask takes
the paste the same way, as in Photoshop.

The paste commands now follow the target like the fills and filters do
(channel_cmds::routed), and accept a "target" param.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:41:26 -04:00
7e40b40fb0 Edit Contents switches to the already-open contents document (#1037)
* Edit Contents switches to the already-open contents document

Opening a smart object's contents a second time opened another copy.
It now activates the document already linked to that smart object.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* rustfmt smart_cmds.rs

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 15:41:19 -04:00
3f8536868e image.analysis.recordMeasurements: refuse an area too large to measure instead of overflowing (#1184)
Fixes #933. With no selection the whole document is measured, and
`selection_mask` sized its mask with `r.width() * r.height()` in u32. A legal
65536×65536 document (2^32 pixels) overflowed the product, so `run` panicked
and dispatch reported an internal error; in release the product wraps to 0,
`gray_image` still renders the full region (~64 GiB of f32 RGBA) and
`measure_features` then indexes the empty mask.

`selection_rows` now computes the pixel count in u64 before anything is
allocated and refuses more than `MAX_MEASURE_PIXELS` (400 MP) with an error
that says to select a smaller region; the mask is sized from that checked
count. The measurement label counter is also bumped only after the rows are
computed, so a failed measurement no longer skips a number.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 15:33:18 -04:00
TiredIsa 7567a20ac3 fix(engine): preserve Unicode in variable data-set CSV (#1062) 2026-10-08 15:33:11 -04:00
0ff15f9ed9 Space untimed segments by distance when Spacing is off (#1036)
With Spacing unchecked, dabs are spaced by pointer speed, and a segment
without timestamps got a single dab at its end point. The canvas sends no
timestamps, so a Shift-click straight line with such a brush (the Eraser
with 10% smoothing gives start, 90% and end points) left three separate
dabs instead of a line. Untimed segments now fall back to distance spacing.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 15:33:05 -04:00
yangandClaude Opus 5.5 7fec6e9469 Brush Scatter above 1000 % is rejected naming the value, and the renderer caps it so dabs stay finite (#977) (#1034)
tools.setBrush and stroke brush patches accepted any Scatter amount. The paint path
multiplied it by the radius in f32, so 1e38 overflowed the dab centre to infinity, and
dab_rect's unchecked i32 arithmetic then panicked (wrapped to an empty rect in release).

Brush validation now rejects primary and Dual Brush Scatter outside 0..=MAX_SCATTER (10,
the slider's 1000 %) with a BadParams naming the value, before the session brush changes
(also between setBrush's two merges, so infinity is named rather than reported as null).
bounded_for_render caps both amounts for direct paint API users (NaN scatters nothing),
and the dab rectangle, now one helper shared with the retouch tools, saturates.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:33:00 -04:00
lionel 69c01c1bff Startup: preserve the GPU backend after windowing errors (#1228)
* fix(startup): preserve GPU backend after non-graphics errors

* fix(startup): retain prior crash evidence on windowing errors
2026-10-08 15:32:54 -04:00
Chris WaldenandClaude Opus 5.5 acddca58b8 Options bar: the airbrush, smoothing options and symmetry buttons work (#1177) (#1214)
In the Photoshop themes (the default ProMedium included) the Brush and
Eraser options bar drew three icon buttons that did nothing when clicked.
The engine already supports everything behind them:

- Airbrush toggles the brush's Build-up (lit while on).
- The gear beside Smoothing opens Photoshop's smoothing options: Pulled
  String Mode, Stroke Catch-up, Catch-up on Stroke End and Adjust for
  Zoom. They edit the brush like the Smoothing % does, so they stay per
  tool and reach the live stroke and the commit.
- The symmetry button opens a menu with Symmetry Off and every path the
  Paths panel lists (saved paths, the Work Path, the selected layer's
  shape path or vector mask), running paint.symmetryFromPath /
  paint.symmetryDisable. It is lit while symmetry is on; with no path it
  says how to make one.

The three buttons get accessible names (brush_picker::named), and
"Symmetry Off" is added to the twelve complete catalogs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:32:49 -04:00
Streetblock 15cfdcd9e8 docs: refresh implemented tool status in the roadmap (#1205) 2026-10-08 15:32:43 -04:00
Armaan Sandhu dab2a8472b Median, Dust & Scratches: sliding histogram instead of a selection per pixel (#901)
Median gathered every sample of the disc (about pi r^2 values) for each pixel and channel and selected the middle one, so the work grew with the square of the radius. For radius 2 and up, rank each channel's window samples by total_cmp (8-bit samples by level), keep a histogram of the disc's ranks that slides in a serpentine over the tile (one end of each disc row or column out, the other in, per step), and track the median rank as it moves (Huang), with block counts so it crosses large empty rank ranges a block at a time. The value picked is the sample the selection picks, so output is bit-identical at every depth. Radius 1 keeps the direct selection (now median_direct), which is also the test oracle.
2026-10-08 15:32:36 -04:00
yangandClaude Opus 5.5 0954b8c065 Link Layers no longer overflows on a stored u64::MAX link group and never joins an unrelated group (#961) (#1022)
layer.linkLayers picked the new link group as max(link_group) + 1, computed even
when unlinking. A .pcraft or PSD can store u64::MAX, so the addition panicked in
checked builds and wrapped to 0 in release, where it could merge the selection
into an existing group 0.

Add Document::unused_link_group: max + 1 when it fits, otherwise the smallest
free id from 1, never wrapping. Link Layers uses it only when linking and returns
an error if no id is left. Also read the first selected group and the member
counts without indexing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:32:30 -04:00
yangandClaude Opus 5.5 533c46f7d8 gradient.fill.stop rejects an out-of-range index with BadParams instead of overflowing (#958) (#1000)
The midpoint action checked `i + 1 >= v.len()` on the raw `index` param,
so an index of usize::MAX overflowed (a panic with overflow checks on, a
silent no-op success with them off). The shared index parser now takes
the number of addressable entries and rejects an index past them before
any arithmetic, for every action (move, delete, color, opacity, midpoint)
and both kinds, so all of them validate the same way.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:32:23 -04:00
callmeBallochandClaude Opus 5.5 508fafd92d AF.3: compare image sizes as the unsigned values they are
Clippy (-D warnings) flagged an always-false comparison and a no-op
cast in the Affinity image resampler after Rect::width became u32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 19:13:50 +00:00
Brandon ThomasandClaude Opus 5.5 dd555217ac Slices test: as_chunks for the fixed 3-wide rows (clippy chunks_exact_to_as_chunks) (#1374)
Follow-up to #991: Rust 1.99's clippy flags chunks_exact with a constant
size, which turns the workspace clippy gate red.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:06:06 -04:00
yangandClaude Opus 5.5 e1a874d9e9 Timeline: out-of-range duration and fps are rejected; stepping never panics (#1009) (#1023)
timeline.create and timeline.setProps kept any `duration` (up to u64::MAX) and
any `fps`, and timeline.nextFrame/previousFrame then moved the playhead with
`(current as i64 + delta).clamp(0, duration as i64 - 1)`, which panics when the
upper bound goes negative (duration 0 or above i64::MAX) or overflows.

Both commands now reject a `duration` outside 1..=1000000 and an `fps` that
isn't finite and in (0, 1000] with BadParams before anything changes. Stepping
moves to a saturating Timeline::step, safe on any stored timeline (e.g. one
loaded from a file with duration 0), and a non-finite stored fps falls back to
30 like a non-positive one.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:05:39 -04:00
yangandClaude Opus 5.5 815b4ee0f9 Place Scale Marker: a document under 8 px tall returns an error instead of panicking (#932) (#1014)
The bar height was clamped to [2, h/4], and f64::clamp panics when h/4 < 2,
so any image shorter than 8 px panicked inside the command's run closure.
Reject such images with a bad-params error before anything changes, like the
existing bar-width check. Also keep the bar on the canvas when the label under
it, or the left margin, would push it off a small image.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:05:32 -04:00
yangandClaude Opus 5.5 0c2c268c49 image.crop rejects an origin or far edge that can't be moved within i32 instead of panicking (#959) (#1013)
crop_doc negated the crop origin unchecked, so x or y = i32::MIN overflowed;
an origin one past i32::MIN pushed layer pixels past i32::MAX and panicked on
a length mismatch while translating surfaces; and x + width past i32::MAX
saturated, silently cropping less than asked. crop now checks the far edge,
crop_doc checks the negation, and translate_doc checks that every surface
still fits before moving anything; all return BadParams naming the values.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 15:05:25 -04:00
Nigel Ihza Firdausy fa1f0548fb fix(engine): saturate layer slice outsets at coordinate limits (#998) 2026-10-08 15:05:17 -04:00
Nigel Ihza Firdausy bdff608a0c fix(engine): divide wide slice rectangles without integer overflow (#991) 2026-10-08 15:05:09 -04:00
Ian Swope 874867461d file.new at 300000x300000 no longer fills ~360 GB of tiles before returning (#705) (#900)
Surface::fill_rect materialized every tile it touched, so a new document's
background cost one 256x256 tile per tile slot: ~360 GB for the 300000x300000
size the command accepts. Fully covered tiles now share one Arc'd tile, so a
solid fill costs one tile plus the tile index; edge tiles are written as
before, and copy-on-write splits a shared tile off when it is edited.
2026-10-08 15:04:57 -04:00
059546680c Layer copies are numbered: "Layer 1 copy 2", not "Layer 1 copy copy" (#897)
Duplicating a layer (Layer › Duplicate, ⌥-drag with the Move tool, Layer
via Copy without a selection, New Smart Object via Copy, the Edit menu's
duplicate, Select and Mask's new layer) names the copy with
`Document::copy_name`: "Layer 1 copy" first, then "Layer 1 copy 2",
"Layer 1 copy 3"…, unique in the document. A name already ending in
"copy" or "copy N" is numbered from its root instead of growing another
"copy"; names that merely contain the word keep it.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 15:04:52 -04:00
cf758c5b04 New Document: a Clipboard preset with the clipboard image's size (#891)
When the clipboard holds an image (pixels copied in the app, or an image
copied in another app), File › New offers a Clipboard preset first under
the Recent blank-document presets, at that width and height and 72 ppi,
and selects it, so Create makes a document the size of the clipboard.
Without an image the dialog opens as before. Opening the dialog is an
explicit request, so the OS clipboard is read then, as for a paste.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 15:04:45 -04:00
Ian Swope dc256b8b39 A PSD layer with bounds but no channel data no longer aborts on a 360 GB allocation (#755) (#885)
record_surface sized the interleave buffer from the layer rectangle alone,
so a few-hundred-byte file declaring a 300000x300000 layer with no
channels made import allocate ~360 GB and abort. The channel decoders
already bound their output by the bytes present, so a buffer is now built
only when at least one channel decodes; otherwise the layer imports empty
with a warning.
2026-10-08 15:04:40 -04:00
Tanish Manku 5049a1d062 Never crash on hostile numbers: LUT sizes, Quick Selection points, link group ids (#869)
- Color Lookup: a stored document's `size` is untrusted. `size³ × 3` overflowed for sizes of
  2²² and up: debug builds panicked, release builds wrapped past the table-length guard and
  indexed out of bounds. One checked `photocraft_doc::adjust::lut3d_len` now backs the CPU
  compositor, the GPU planner and the PSD writer.
- Quick Selection: a stroke point far off the canvas (`select.quick {"points":[[3e9,0]]}`)
  saturated to i32::MAX and then overflowed on `+ 1`.
- Link Layers: `max + 1` over stored link group ids overflowed at u64::MAX (debug panic, release
  wrap that could merge unrelated layers). A fresh id is now the smallest unused one when the
  largest is taken; a selection's first group is read with `first()`.
- Vanishing Point clone footprint: the same `as i32 + 1` saturates (hardening; no reproducer).
2026-10-08 15:04:34 -04:00
RicSchonfelder 968b1f2c4f i18n: fix pt-br label collisions (Feather/Diffuse, Liquify/Dissolve) (#833)
Per maintainer review (#833): keep Feather as Difusão…, Diffuse becomes
Difundir…, and Liquify becomes Liquefazer… (Licuar is Spanish).
Separates the Fast menu and Stylize menu entries that were colliding in pt-BR.
2026-10-08 15:04:28 -04:00
bfentyandBrandon Fenty f9b72468b4 docs: stop linking readers to the private craftrules repo (#493) (#779)
The contributor docs offer github.com/storytold/craftrules as the place
to read the font rules and release playbook, but the repository
returns 404 to everyone outside the organization, so the cited text is
unreachable for outside contributors. Keep the sibling-checkout paths
and say plainly that the repo is not public and a maintainer can supply
the rules relevant to a change.

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-08 15:04:21 -04:00
BFlatandClaude Opus 5.5 3f740ccfb3 Layers panel: hovering a blend mode previews it live on the canvas (#970) (#1081)
Hovering a mode in the Layers panel's blend-mode list previews it on the
selected layer; moving off the list reverts, and only a click applies it
(one history step), as in Photoshop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 02:04:04 +07:00
Claude 4d4ab93f83 Select › Modify: bound the canvas-edge margin and translate Apply At Canvas Bounds
The margin Modify pads a canvas-edge selection with was twice the radius
for every operation, so Feather 1000 added 2002 px a side. It now pads by
each operation's reach (Feather 1.5 r, the others r) and refuses a padded
mask over 2^30 pixels instead of aborting on the allocation. The new
dialog option is translated in every shipped language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 01:59:24 +07:00
Marcelo Cárdenas d7859f17c7 Select › Modify: Apply effect at canvas bounds, so Contract/Feather/Border work after Select All (#1277)
Contract, Smooth, Expand and Feather gain Photoshop's "Apply effect at canvas bounds" option
(applyAtCanvasBounds, default off). On, the canvas edge counts as a selection edge, so Select All
then Contract shrinks from the edges and Feather softens them; off, the selection continues past
the canvas, as in Photoshop. Border always follows the canvas edge, so a Select All borders to a
band inside it instead of an empty selection.

The mask is padded (empty or edge-replicated) only when the selection touches the canvas edge,
so selections clear of it cost nothing extra.
2026-10-09 01:59:24 +07:00
Claude c5d50b4390 Free Transform: a typed W/H stops at the largest document side
1000000 % on any layer could ask the transform for an image millions of
pixels wide, more than can be allocated. The W and H fields now reach as
far as the box stays within 300000 px (the document limit), at most
1000000 %, and a linked change keeps the other side within its limit too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 01:58:43 +07:00
Marcelo Cárdenas bb723b12eb Free Transform: W/H fields reach 1000000% and X/Y the 300000 px document limit (#1267)
The options bar clamped W/H to ±10000% and X/Y to ±30000 px, while the engine takes any scale
and documents go up to 300000 px a side. Overlays on large stitched mosaics need more. The limits
are now named constants: ±1000000% (a 300 px layer can span the largest document) and ±300000 px.
2026-10-09 01:58:43 +07:00
callmeBalloch ef870103f8 Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2 2026-10-08 18:58:33 +00:00
callmeBallochandClaude Opus 5.5 08a812e568 AF.3: open Affinity 1 and 2 extensions everywhere and match the new fallback warning
File › Open, the web build, batch folders and the CLI now pick up
.afdesign, .afphoto and .afpub, which the native reader handles; the MCP
test checks the preview fallback's new warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 18:58:33 +00:00
Claude 3bab0d4f52 PSD text fill: keep the fix and its tests, drop the proof-only screenshots and example
The Photopea screenshots, their ATTRIBUTION row and the example that made
them documented the bug for review; the unit tests in crates/text cover
the behaviour, so they are not needed in the tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 01:57:17 +07:00
Rage 7bb971f77c Fix PSD text fill after edits in Photopea 2026-10-09 01:57:17 +07:00
Nigel Ihza Firdausy 7d78e1dd2c Export Preferences: WebP quality and lossless settings for Quick Export (#1260) (#1279)
* feat(export): persist WebP lossless mode and quality in export preferences (#1260)

* fix(export): apply configured WebP quality to Quick Export output

* fix(ui): show format-specific JPEG and WebP export preference controls

* fix(ui): use WebP quality and lossless preferences in Export As dialog

* test(export): verify WebP quick export mode, quality, validation, and legacy prefs

* style: format WebP export regression test as rustfmt expects

* style: apply rustfmt layout to conditional WebP preference labels

* docs(scorecard): account for two WebP export preference fields (#1260)
2026-10-09 01:57:01 +07:00
Nigel Ihza Firdausy a5f9029c20 File menu shows the configured Quick Export format (#1259) (#1269)
* fix(ui): show configured quick export format in File menu (#1259)

* fix(ui): preserve localized quick export labels for selected format
2026-10-09 01:56:57 +07:00
Nigel Ihza Firdausy 184ec31f1c Hidden ruler guides no longer intercept canvas input (#1258) (#1268) 2026-10-09 01:56:54 +07:00
marcardenas d8dfb60b9a Gradient tool: a handle dragged past the 10% scale floor stops; the other handle stays put (#1243) (#1306)
Dragging one live-gradient handle towards the other clamps the fill's scale at 10%. For Linear the
centre was still taken from the midpoint of the requested points, so the
clamped gradient was re-centred and the handle that wasn't being dragged slid along the line.
gradient.fill.set now shifts the clamped layout so the handle not given keeps its place. The same
apply_set drives the drag preview, so the canvas matches.
2026-10-09 01:56:51 +07:00
marcardenas eb4b0f5e36 Filter › Last Filter applies to the active layer, not the layer the filter first ran on (#1270) (#1307)
The Filter Gallery, Camera Raw and Adaptive Wide Angle dialogs record the layer they ran on in
the command's params. Last Filter replayed the journal entry verbatim, so it filtered that layer
again (possibly hidden, or deleted) and appeared to do nothing on the layer you were looking at.
It now drops the recorded `layer`, so it applies to the current layer as in Photoshop.
2026-10-09 01:56:46 +07:00
Arian Pashae 93694cf2f6 chore: remove accidentally committed fb-*.log files and ignore *.log (#1155) 2026-10-09 01:56:40 +07:00
BFlatandClaude Opus 5.5 437b25bea9 A Distort corner stops before the box folds over (#1323) (#1332)
Distort and Perspective move a corner along the drag only as far as the
box stays convex, stopping just short of flat, as Photoshop and Photopea
do, instead of folding it into a bow tie.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:52:27 +07:00
BFlatandClaude Opus 5.5 b3d10f9a48 The Studio themes show the Move tool's Auto-Select toggle (#1275) (#1333)
The Pro themes already had Photoshop's Auto-Select checkbox; the Studio
themes showed only a hint for the Move tool, so Auto-Select could not be
turned off there. Both now have the toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:52:19 +07:00
BFlatandClaude Opus 5.5 3e8d788441 Delete and Fill Selection on the selection tools' right-click menu (#1286) (#1331)
Right-click with a selection tool and choose Delete and Fill Selection
to fill the selection from its surroundings with Content-Aware Fill's
default settings, without the dialog. Engine command
edit.deleteAndFillSelection, one undo step, translated in every shipped
language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:51:47 +07:00
callmeBallochandClaude Opus 5.5 ae0313d325 AF.3: native Affinity import with artboards, vectors, text and images
photocraft-affinity now reads native Affinity documents (container
versions 8-12): the FAT and revision chain, stored/zlib/zstd entries
with predictors and CRC checks, the tagged object stream in doc.dat,
and the document model (pages, artboards, layers, groups, curves,
geometric and compound shapes, fills, gradients, strokes, text, placed
images, pixel layers, masks and embedded-document caches). Every size
is bounded and malformed input returns an error.

photocraft-io maps it to an RGB8 document with artboards, groups, shape
layers, type layers, embedded smart objects, pixel layers and
vector/layer masks; what can't be mapped is listed in the warnings. The
embedded preview is now only a fallback when the native data can't be
read, and only that preview is refused by Place and warningless imports.

`cargo xtask corpus --affinity` pins 21 public CC0/MIT documents,
sha256-verified; the io corpus test compares each import with the
thumbnail Affinity saved in it (mean difference 0-4.6 of 255).
Affinity export is not implemented: no Affinity installation was
available to validate written files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 18:46:22 +00:00
DWAA1660 c833294e34 PSD export: write empty layer and mask channels as raw (#1290) 2026-10-08 14:31:24 -04:00
Nigel Ihza Firdausy 5054308d0b fix(ui): allow removing the last polygonal lasso point (#1262)
* Allow removing last polygonal lasso vertex without editing layers

* Route lasso Backspace ahead of destructive edit shortcuts

* Use right-click to retract polygonal lasso points instead of opening menu

* Regress lasso vertex removal via keyboard and actual canvas right click

* Apply rustfmt layout reported by Linux CI

* Apply rustfmt layout reported by Linux CI
2026-10-08 14:31:17 -04:00
Nigel Ihza Firdausy f9ccdda1e2 fix(ui): remember previously applied New Guide Layout values (#1261)
* Remember last applied New Guide Layout parameters in UI state

* Save guide layout preferences only after successful application

* Cover successful, cancelled and failed guide layout dialog state

* Avoid overlapping borrows when reopening guide layout dialog
2026-10-08 14:31:10 -04:00
Elvis33LE f5080f9774 ui.window.open: an invalid document index is an error, not a side effect (#1133)
The document parameter's set_active result was discarded, so an
out-of-range index still switched nothing, changed nothing, and the
window opened for whatever document happened to be active - a caller
was told success for a document that does not exist. Now the call
errors with the index and leaves the active document alone.
2026-10-08 14:31:03 -04:00
317d946f45 PSD: a spec-order mask record is not misread as Photoshop's order when that reading is implausible (#949) (#1129)
real_before_parameters picked Photoshop's order (real mask first) whenever
the record length fit the parameter-flags byte at offset 36, even when that
byte was just part of the real rectangle. A density-only record in the
spec's order then read its density as the real mask's background and lost
the parameter. When both orders fit, Photoshop's reading now has to be
plausible (background 0 or 255, known parameter flag bits) or the spec's
reading with a real mask wins.

Co-authored-by: wstock <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-08 14:30:56 -04:00
Ian Swope 8f5a0817e4 Applying a text data set re-renders the type layer instead of blanking it (#990) (#1025)
apply_to_doc replaced a text-replacement variable's text and set the layer's
render cache to None, but nothing rebuilt it. The compositor draws a type
layer from that cache, so the text vanished from the canvas after
image.applyDataSet and from every file written by
file.export.dataSetsAsFiles. The layer is now re-rendered with
type_cmds::refresh, as type.edit does.
2026-10-08 14:30:50 -04:00
Armaan Sandhu 3179093985 Motion Blur: apply the samples as one merged kernel over whole rows (#902)
Motion Blur took distance + 1 bilinear samples per pixel, each through Image::sample with per-tap edge checks, so distance 100 cost about 400 edge-checked reads per pixel and channel. The sample offsets are the same for every pixel, so merge their bilinear taps once into a fixed kernel (each tap an integer offset and a weight) and apply it tap by tap as a multiply-add over whole rows of a premultiplied window, which the compiler vectorizes. This is the same premultiplied mean as before, summed in a different order.
2026-10-08 14:30:43 -04:00
callmeBallochandClaude Opus 5.5 f94bb1dc9b AF.1: refresh the lockfile for the 0.5.0 workspace version
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 17:10:10 +00:00
callmeBalloch e3932fa7a3 Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2 2026-10-08 16:08:36 +00:00
callmeBalloch 1bb37eda05 AF.1: import bounded Affinity previews with fidelity warnings
Read the indexed version-12 PNG preview with bounds, chunk ordering and CRC checks. Show native-content and resolution warnings, preserve the source through normal and automation opening, and reject auxiliary routes that cannot display the warning. Add format documentation, malformed-input and save-lifecycle tests, native-oracle hooks and parser/import fuzz targets. Editable native layers and Affinity export remain unsupported.
2026-10-08 17:42:23 +02:00
Brandon ThomasandClaude Opus 5.5 e5e3e39752 Bump version to 0.5.0 (from 0.4.1) for release
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 10:01:32 -04:00
bce7e54a63 Release workflow: only signing and draft-release jobs use the release environment (#1220)
version, linux and web read no secrets. With environment: release they were refused on a branch,
so a branch dispatch of release.yml built nothing. Now a dry run builds every unsigned platform;
the signed jobs are still refused and the draft-release job needs them, so a branch can't create
a release. Matches craftrules release/playbook.md §2.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 09:52:09 -04:00
35f7e99965 README Downloads: where to get it first, then builds with Windows before macOS (#1219)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 09:50:10 -04:00
d2a9295050 README: Downloads section listing every release build, grouped by OS (#1210)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 09:24:06 -04:00
b10b37c14b macOS: use the native menu bar instead of the in-window menus (#887) (#894)
* macOS: use the native menu bar instead of the in-window menus (#887)

On macOS the menus move from the title bar to the menu bar at the top of the
screen, built with muda from the same rows as the in-window bar (menu_items).
Linux, Windows and the web keep the in-window menus, and so does macOS with
--in-window-menus or PHOTOCRAFT_IN_WINDOW_MENUS=1.

The Mac layout (native_menu.rs, platform-free and tested everywhere):
- a PhotoCraft app menu: About (from Help), Settings (Edit › Preferences),
  Services, Hide, Hide Others, Show All, and Quit, which is still file.exit so
  the unsaved-changes prompt runs;
- a Window menu AppKit knows (Minimize, Zoom, Bring All to Front, the window
  list) and a Help menu with the system search field;
- PhotoCraft keeps Photoshop's ⌘H (Extras), ⌘M (Curves), ⌘W (Close) and
  ⌘, (Hide Layers): Hide and Settings have no shortcut, Minimize is ⌃⌘M.

Key equivalents are handed back to egui as the key presses they were, so
shortcuts run through shortcuts::handle exactly as before: text fields keep
⌘C/⌘V/⌘Z, dialogs and Camera Raw stay modal. A click runs the command like an
in-window click. The menu is updated only when state changes, in place, and
rebuilt only when its structure does.

On every platform:
- menu rows show the shortcut that runs them: 12 live items (Undo, Redo, Cut,
  Copy, Paste, Hide Layers, …) showed none;
- Image › Mode, View › Proof Setup, Proof Colors, Gamut Warning and Isolate
  Layers stay check items with no document open, so opening one doesn't
  change the menu's structure.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Native menu: test it against AppKit; fix the Window/Help menus and ⌘R routing

tests/mac_menu_appkit.rs (harness = false, main thread, like the tablet
crate's monitor test) installs the real muda menu and reads AppKit's menu
bar back: the PhotoCraft app menu, Quit ⌘Q, no Exit in File, the Window and
Help menus registered with NSApp, and Layer › New › Layer… enabled in place
(same NSMenu) once a document opens. It then sends a real ⌘R key-down
through -[NSMenu performKeyEquivalent:], which must reach egui as ⌘R and run
View › Rulers once, and chooses Rulers by click and by ↩ in the open menu,
which must run as clicks.

It found two bugs, fixed here:
- Window and Help were never registered: muda looks the submenus up in the
  installed main menu, so set_as_*_menu_for_nsapp must come after
  init_for_nsapp. The Window menu didn't list windows.
- AppKit matched ⌘R to Filter › Lens Correction (⇧⌘R), and the menu handed
  egui the matched item's shortcut, so ⌘R ran Lens Correction. Key
  equivalents now go back with the modifiers actually held.

Also a kittest: the title bar draws no menu titles with the Mac menu bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Mac app menu: Language and Appearance, Hide on ⌃⌘H (aligned with VectorCraft)

App settings live in the app menu, as on a Mac and as VectorCraft does:

- Language ▸ after Settings: Auto, then each UI language in its own name,
  the interface.language preference checked; choosing one runs prefs.set
  (unknown codes are refused).
- Appearance ▸: the themes (now check items, in-window too) and Next Theme
  move from Window › Theme to the app menu.
- Hide PhotoCraft takes ⌃⌘H when ⌘H is View › Extras, as Photoshop does
  (Minimize already takes ⌃⌘M).

The other Craft apps use the same app menu, Window menu and
--in-window-menus / <APP>_IN_WINDOW_MENUS opt-out.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: bflatastic <[email protected]>
2026-10-08 20:22:28 +07:00
Brandon ThomasandClaude Opus 5.5 c9a7d26f6a Web build: ICU complex-script dictionaries native-only; bump version to 0.4.1
The v0.4.0 release failed its web job: the wasm was 26,365,303 bytes, over the
24 MiB gate in packaging/web/package.sh and Cloudflare's 25 MiB per-file cap.
The largest single item was ICU4X's CJK segmentation dictionary (3.8 MB),
pulled in by parley's complex-scripts feature. Enable that feature on native
targets only; the web build falls back to the generic Unicode rules for
Japanese word boundaries and Thai/Lao/Khmer/Burmese line breaking.

wasm: 26,368,656 -> 22,578,266 bytes (21.5 MiB), measured with trunk build --release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 08:48:03 -04:00
Brandon ThomasandClaude Opus 5.5 173fc90ecb Bump version to 0.4.0 (from 0.3.0) for release
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 08:12:20 -04:00
Brandon ThomasandClaude Opus 5.5 5896f0b63e Preferences: colour settings take a typed #rrggbb value (#668 item 2) (#1001)
* Preferences: colour settings take a typed #rrggbb value (#668)

The canvas colour (and every colour preference) showed its hex value as a
label next to the swatch. It's now an editable field: a valid value sets the
colour, a partial one is kept while typing.

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

* rustfmt

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 04:01:43 -04:00
Brandon ThomasandClaude Opus 5.5 4c4c58d6e3 Clone Stamp / Healing Brush: the source crosshair shows only while painting (#668) (#969)
Setting the source with Alt-click left a + on the canvas until the next stroke.
As in Photoshop, the crosshair now shows where the stroke samples only while
the stroke is painted.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 04:01:38 -04:00
Brandon ThomasandClaude Opus 5.5 3d003bd9d0 Windows: selection and other crosshair tools stay visible over grey (#737) (#954)
Windows' crosshair cursor inverts the pixels under it, so it vanishes over
mid-grey (the pasteboard, many photos). On Windows the canvas now draws a
black-and-white crosshair itself and hides the system one.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 04:01:33 -04:00
Brandon ThomasandClaude Opus 5.5 d64f809e4d Layer via Copy, Copy and Cut read only the selected area: 33x faster on 24 MP (#668) (#985)
lift() converted the whole layer to a format with alpha before reading the
selection's rectangle, so on a 24 MP Background every Layer via Copy cost about
a second whatever the selection's size, and it sampled the selection mask one
pixel at a time. It now reads just that rectangle and the mask's rectangle.

6000x4000 Background, 2000x1500 selection, release: Layer via Copy 984 ms ->
30 ms. Select All then Layer via Copy: 212 ms.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 03:17:48 -04:00
Brandon ThomasandClaude Opus 5.5 1ae11a4bb1 Shape tools: Shift and Alt pressed during the drag constrain it, as in Photoshop (#668) (#984)
Shape drags only read the modifiers held at the press, so pressing Shift
mid-drag (the usual way) didn't square a rectangle or keep a polygon's
proportions, and Alt mid-drag didn't draw from the centre. They now follow
the modifiers held at each pointer event, like the marquees.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 03:17:44 -04:00
Brandon ThomasandClaude Opus 5.5 74c7980146 Type: Cmd/Ctrl+T while typing shows or hides the Character panel (#668) (#983)
It passed through to Edit > Free Transform, which started transforming the
layer being typed into. As in Photoshop, it now toggles the Character panel
and editing continues.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 03:17:39 -04:00
Brandon ThomasandClaude Opus 5.5 dd963b4b44 Crop tool starts with a frame around the canvas (or the selection), as in Photoshop (#668) (#971)
Picking the Crop tool showed nothing until a frame was drawn. It now frames the
whole canvas, or the selection's bounds when there is one, and frames again
after a crop is cancelled or committed. Like Photoshop, a drag inside the
untouched frame draws a new one, its handles resize it, and committing it
does nothing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 03:17:34 -04:00
Brandon ThomasandClaude Opus 5.5 997acf5885 Layers panel: drop layers on the footer's New Layer, New Group and Delete buttons (#736) (#965)
As in Photoshop, dragging a layer row onto New Layer duplicates it, onto New
Group groups it and onto Delete deletes it. A row that is part of the
selection carries the whole selection; any other row goes alone. The button
under the pointer is highlighted during the drag.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 03:17:29 -04:00
Tanish Manku 11f6f00ccc Variables dialog: malformed entries can't crash an edit (#875)
`ui.dialog.set` can store any JSON in the dialog's `__variables` field, and the Define and Data
Sets pages write fields into its entries (`d["name"] = …`, `set["name"] = …`), which panics when an
entry is a number or a string. The dialog now normalizes the state each frame, keeping only object
entries in `defs`, `dataSets` and each set's `values`.
2026-10-07 23:58:31 -07:00
Tanish Manku 62b0d0bd60 Automation: an allowed command can't run a denied one on its behalf (#861)
Untrusted sessions (MCP, the control channel) checked only the top-level command and the steps of
`actions.play`. Any other command that runs commands internally ran them unchecked, so the allowed
`file.automate.conditionalModeChange` could run `image.mode.*`, which the desktop gate denies
because it resolves the user's configured colour profiles.

`Session::dispatch` now applies `Session::authorize` to every command before any side effect,
nested ones included, which closes the class rather than this one command. Sessions without a
gate (local UI, `photocraft-cli run`) are unchanged.
2026-10-07 23:58:26 -07:00
Brandon ThomasandClaude Opus 5.5 eec4af6551 Fix main: classify the Direct Selection path commands; Czech for "{n} layer" (#1074)
Two tests on main fail on every OS:
- automation: path.moveAnchors/moveHandle/bendSegment/convertPoint (#865) take
  a document vector path ("name"/"layer"), not a host file, so they join the
  list of vector-path commands the filesystem-param audit skips.
- xtask i18n: the plural "{n} layer|{n} layers" key reached every complete
  catalog but cs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 02:36:08 -04:00
452672765d Photo Filter and gradient glows render like Photoshop (#442, #443) (#850)
* Photo Filter and gradient glows render like Photoshop (#442, #443)

Photo Filter multiplied encoded RGB by the filter colour and rescaled to Rec.601 luma. Photoshop
multiplies the pixel's D50 XYZ (relative to white) by the filter colour's, mixed by density, and
Preserve Luminosity restores the encoded luminosity like the Luminosity blend mode (SetLum +
ClipColor); 32-bit documents keep luminance Y. rgb16 photo-filter.psd now matches within 0.6/255
(was 125/255); rgb8 mean error 0.3/255 (was 20.8).

Outer and Inner Glow gradients were laid out across the canvas (a vertical linear gradient), so
the colour bands ran top to bottom. Photoshop colours the glow at strength v with the gradient at
1 - v (first stop at the edge) and the glow is opaque from v = range^2. outer-glow-gradient.psd:
mean 1.4/255, max 9.9 (was 252).

CPU and GPU paths updated together; photoshop oracle floor 132 -> 133.

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

* Merge origin/main; regenerate docs/scorecard.md

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 23:49:10 -04:00
01cd6fffa2 Layer › Smart Objects › Convert to Layers (#535) (#829)
* Layer > Smart Objects > Convert to Layers (#535)

Unpacks a smart object's contents back into layers at its placement:
several layers land in a group named after the smart object (carrying
its opacity, blend mode, masks and style); a single layer replaces it
directly when those fold in exactly. Contents are conformed to the
document's mode, profile and depth; smart filters are discarded.
Also in the Layers panel context menu and the Properties quick actions.

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

* Parity: Convert to Layers is live (627 of 627); raise FLOOR

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

* Convert to Layers: pass no inherited group locks to transform_layer (#847 added the parameter)

The layers are transformed inside released(), which lifts every lock in their tree.

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

* it: translate Convert to Layers (Italian landed after this PR was written)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 23:44:21 -04:00
20fb1903dd Drag layers onto another document's tab to copy them there (#589) (#851)
* Drag layers onto another document's tab to copy them there (#589)

New engine command layer.copyToDocument copies layers into another
open document as one history step there, converting them to the
destination's colour profile and bit depth, and places them at the
same canvas position, centred, on a point, or at an offset.

In the UI, dragging layers from the Layers panel or with the Move tool
onto another document's tab shows that document (spring-loaded tabs),
and releasing over its canvas drops the copies under the pointer;
Shift keeps the position for same-size documents and centres them
otherwise, as in Photoshop.

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

* Tab labels: keep #832's full-title label on Photoshop-style tabs; the layer_transfer tests find a tab by it

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 23:37:42 -04:00
a93fb88064 Invert (⌘I) inverts the targeted layer mask (#780) (#838)
* Invert (⌘I) inverts the targeted layer mask (#780)

With the layer mask targeted (its thumbnail clicked, or viewed with
Alt-click), Image > Adjustments and the filters now edit the mask instead
of the layer's pixels, as in Photoshop. Without a selection Invert also
flips the mask's default, so a reveal-all mask becomes hide-all, past the
canvas too. A targeted mask enables these commands on layers without
pixels (adjustment and fill layers), and dialog previews show the mask.

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

* rustfmt

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 23:30:53 -04:00
Brandon ThomasandClaude Opus 5.5 e694208a06 Slice, layer comp and type style ids out of u32 range are rejected, not wrapped onto a real item (#914) (#930)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 23:13:41 -04:00
Brandon ThomasandClaude Opus 5.5 5bfadbadd0 Save for Web: slices never overwrite each other, and overlapping slices keep their HTML cells (#908, #909) (#929)
Output file names are made unique (case-insensitively; spacer.gif reserved when
writing HTML). In the HTML table a slice shows only where no later slice covers
it; that visible part is cut into rectangles, each its own cell and image, so a
later overlapping slice's link, text and image are no longer dropped.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 23:12:27 -04:00
Brandon ThomasandClaude Opus 5.5 bdd0141cf3 Filter Gallery: Angled Strokes paints strokes instead of a smear; Rough Pastels' texture shows in the dark areas (#879) (#942)
Angled Strokes was two diagonal box blurs, so it read as motion blur and
Sharpness barely registered. It now repaints in discrete, staggered strokes
(each takes the colour at its middle), light and dark areas in opposite
directions, and Sharpness spans a visible range. Rough Pastels drowned the
image in an even canvas relief; its chalk strokes are now discrete too, and
the texture shows mostly where the image is dark (the chalk is thick in bright
areas), as Photoshop describes it. A test checks every slider of both changes
the result and that both stay tile-independent at every extreme.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 23:03:09 -04:00
BFlatandClaude Opus 5.5 244a99c39f Windows and Linux: one title bar with the app icon (no stacked OS bar); long context menus scroll (#986)
* Windows and Linux: one title bar with the app icon, like Photoshop

The window had the OS title bar stacked above PhotoCraft's own top bar. On
Windows and Linux the window now has no OS decorations and the top bar is
the title bar, as in VectorCraft and Photoshop on Windows:

- the app icon (brand.rs) leads the bar, before the menus;
- Minimize, Maximize/Restore and Close sit flush in the top-right corner
  (Close turns red on hover; it runs File > Exit, so unsaved work is asked
  about first);
- the free gap between the menus and the controls still drags the window
  and a double-click there maximizes it;
- invisible 5 pt edges (12 pt corners) resize the window.

The right-hand group gets its own rect between the menus and the caption
buttons (egui's MenuBar takes the whole row, so it used to be laid out from
past the bar's end). Narrow windows drop what is also in a menu before
anything overlaps: Discord, then the theme toggle, then search, then the
workspace switcher narrows. macOS keeps its traffic lights.

New theme tokens caption_close / caption_close_text, Minimize / Maximize /
Restore in all 12 catalogs, and a --custom-titlebar flag for the snapshot
example.

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

* Long right-click menus scroll inside the window instead of running off it

widgets::menu_scroll caps a context menu at the visible window height
(clear of a taskbar a too-tall window runs under, #315). egui moves the
popup up to fit, and it scrolls only when it is taller than the window.
Used by the Layers, canvas tool, Channels, Paths and document-tab menus;
the menu bar's menus already scroll (menu_nav::level).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 10:02:07 +07:00
75ff41a26b Direct Selection tool: reshape pen and shape paths after they're drawn (#790) (#865)
* Direct Selection tool: reshape paths after they're drawn (#790)

Adds Photoshop's Direct Selection tool (A, in the Path Selection flyout) and
the Pen's Cmd/Ctrl (Direct Selection) and Alt (Convert Point) modifiers. It
selects and drags anchors, direction handles and segments of the work path,
the active shape layer's path and a targeted vector mask.

The geometry lives in photocraft_vector::edit; new engine commands
path.moveAnchors, path.moveHandle, path.bendSegment and path.convertPoint
apply it (one history step per drag, drivable over MCP and the control
channel), and the canvas previews a drag with the same code.

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

* Tool::ALL holds 49 tools now that Direct Selection and Magnetic Lasso both landed

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 22:58:31 -04:00
ec7302f4b3 TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8, part of #215) (#859)
* TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8)

Large and multi-page TIFFs now open the way Photoshop opens them, quickly and without a second
full-size buffer, and any directory of the file can be opened on request.

* photocraft-codecs `tiff_ifd`: a bounds-checked directory walker for classic TIFF and BigTIFF
  (LONG8 / IFD8, both byte orders). It follows the whole IFD chain and the SubIFD trees (tag
  330) with a seen-set, a depth limit and a 10 000-directory cap, and checks every value range
  against the file. `tiff_info` lists the pages (size, kind: page / reduced-resolution / mask,
  parent, storage); `default_page` is the first full-resolution page of the main chain, so a
  leading thumbnail is skipped. `decode_tiff_page`, `tiff_orientation`, `tiff_page_orientation`.
* Banded decode: the `tiff` crate reads the chosen directory through a patched byte view (header
  -> that IFD, its next pointer -> 0, WhiteIsZero / palette -> BlackIsZero), so SubIFDs and
  cyclic chains decode. The image is allocated once (fallibly, zeroed in parallel) and each
  strip or row of tiles, across every plane, is decoded straight into it, in parallel on native
  targets, with one capped chunk-sized scratch buffer per worker. New: planar configuration 2
  (was an error), palette images (as RGB), 64-bit integers, associated alpha made straight,
  non-alpha extra samples dropped. Image data cut off by the end of the file decodes with a
  `Truncated` warning. Before the crate sees a file: structural tag values must lie inside it,
  the chunk tables must match the geometry (checked in 64 bits; the crate overflowed and
  panicked on `(h / rows + 1) * planes`), and a decompression-bomb guard refuses files far too
  small for their declared size.
* WhiteIsZero and BigTIFF Orientation keep upstream #746's behaviour (#691, #693) through the
  new decode path; regression tests cover both.
* Encode: `EncodeOptions::tiff_bigtiff` writes BigTIFF; without it, BigTIFF is chosen when the
  file could pass 4 GiB (and a classic write that overflows 32-bit offsets is redone as BigTIFF).
* photocraft-io: `import_tiff_page(name, bytes, page)`; the TIFF branch of import goes through it.
* Tests: codecs `tiff_pages.rs` on hand-built files (`tests/common/tiffgen.rs`): BigTIFF LE/BE at
  8/16/32f, padded tiles and planar strips/tiles, pages and SubIFDs, cycles, the IFD cap,
  truncated and past-EOF strips, huge dimensions / counts / tiles, WhiteIsZero, palette, wide
  formats, BigTIFF write, and a cut / bit-flip / random-mutation zoo (an ignored long run did
  200 000 mutations without a panic); io `tiff_pages.rs`. The `decode_tiff` fuzz target walks
  every page. `tiff_bench.rs` (ignored) times a 30 MP decode against the previous path.
* 30 MP RGB (release, 16 threads, same run): Deflate 344 -> 32 ms, LZW 428 -> 43 ms, 16-bit
  Deflate 497 -> 46 ms, uncompressed 15 -> 10 ms, 256 px tiles 36 -> 10 ms, planar error -> 12 ms.
* Scorecard FILE-215-8 done; README and roadmap updated.

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

* rustfmt the TIFF reader and tests

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 22:37:51 -04:00
Tanish MankuandClaude Opus 5.5 fee08b07b6 CI: craft-fonts run uses the Test step's exact build again (Linux runner out of disk since #800) (#955)
#800 narrowed the craft-fonts test run to -p photocraft-text -p photocraft-ui-egui. A
different package and feature selection resolves a different feature set (no
--features heif), so cargo recompiled the whole dependency graph into the same
target dir on top of the Test step's build, and test (ubuntu-latest) ran out of
disk (No space left on device, linker bus error) on main and every PR.

Back to cargo test --workspace --locked --features heif, which reuses the Test
step's artifacts and rebuilds only the crates that read CRAFT_FONTS_DIR. The comment
says why the selection must match.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 19:31:43 -07:00
772925caf9 Selection tools: Photoshop's full right-click menu (#614, #738) (#831)
Right-click with a Marquee, Lasso, Polygonal Lasso, Magic Wand or Object
Selection tool now lists Photoshop's selection context menu in its order
(Deselect … Fade…), with separators, the menu-bar commands and their
enablement. Without a selection it offers Select All, Reselect, Color
Range… and Load Selection…. Make Work Path… asks for the tolerance first.
All canvas context menus (selection, Pen, Free Transform) now share one
row model, so the control channel lists and chooses them the same way.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 22:26:43 -04:00
Brandon ThomasandClaude Opus 5.5 e040376e15 Release CI: macOS job on macos-15-xlarge
Custom macOS larger runners in a runner group do not pick up jobs; the
built-in M2 Pro label does. Only release.yml uses it, and the
actions_macos_xl budget caps the monthly spend.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:07:53 -04:00
Brandon ThomasandClaude Opus 5.5 d55da47e8d Slice Options and Divide Slice are one undo step, and a rejected one changes nothing (#910, #911, #912) (#928)
An auto slice used to be promoted in its own history step before the command
validated its options, so a rejected slice.set still promoted (and an off-canvas
rect was committed before the error), slice.divide took two undos, and
slice.set folded into any earlier explicit Promote. The promotion now happens
inside the command's own edit, which is all-or-nothing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 22:03:55 -04:00
Brandon ThomasandClaude Opus 5.5 34a87cedea actionlint: declare the org macos-release runner label (#927)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 22:03:51 -04:00
3f605afc81 Menu rows touch, as in native menus: long menus are a fifth shorter (#402) (#844)
Menu rows inherited the dialogs' 6 pt vertical item spacing, so each row
took 30 pt and long menus such as Layer ran far past the window on smaller
displays. Rows now touch (24 pt apart) and separators are 9 pt.

The denser layout exposed a latent gap from #315. In a window that runs
under the taskbar, egui opens a submenu downward whenever it fits the
window, so a submenu row near the bottom of a top-level menu could open
under the taskbar. A top-level menu now ends a scrolling submenu's height
above the taskbar in that case.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 22:00:09 -04:00
Brandon ThomasandClaude Opus 5.5 1d8a32ed83 Release CI: macOS job on the org macos-release runner
The standard macOS runners are shared with all CI and queue for an hour or
more. The macos-release runner (GitHub-hosted M2 Pro) is in a runner group
usable only by release.yml on the release branch, with a monthly spend cap
on the actions_macos_xl SKU.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:54:51 -04:00
5cd50d50ec Brush size and hardness keys are remappable in Keyboard Shortcuts (#626) (#828)
[ ] and Shift+[ Shift+] are now commands (tools.decreaseBrushSize,
tools.increaseBrushSize, tools.decreaseBrushHardness,
tools.increaseBrushHardness), listed under Tools in Edit > Keyboard
Shortcuts as in Photoshop, so they can be rebound, recorded and driven
over MCP/CLI. The shell dispatches them through the shortcut table
instead of hard-coding the bracket keys.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:49:00 -04:00
52ccb8a800 Document tab close button after the title on Windows and Linux, as in Photoshop (#619) (#832)
Photoshop's document tabs put the close button after the title on Windows
and before it on macOS. The Photoshop-style tab strip drew it before the
title everywhere. The tab and its close button are now named for
accessibility, so tests and agents can find them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:35:21 -04:00
reeseandBrandon Thomas 1ea96cb288 fix(automation): judge image.applyDataSet by the files it would read (#870)
Text and visibility data sets resolve no host path, so the unconditional
deny is dropped. An untrusted session's gate now checks each Pixel
Replacement path before the data set is applied.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:25:19 -04:00
Brandon ThomasandClaude Opus 5.5 44303db546 About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#898)
* About: contributors and models tabs compiled in from contributors.json (grab bag + table, sortable, name toggle)

Help > About PhotoCraft gets tabs About · Contributors · Models, following the craftrules
standard (standards/contributors.md): contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as
static tables (nothing read at run time; the web build has them too). Each contributor line:
@username, opt-in display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last
commit. Sortable by every column; alphabetical ignores case and the leading @. The tab is the
dialog field `tab`, so agents can switch it with ui.dialog.set. New labels are translated in
all eleven catalogs.

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

* Contributors docs: craftrules is two levels up (../../craftrules)

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

* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section

build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).

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

* Contributors: regenerate (verified people.toml entries)

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

* i18n: credits labels for catalogs added on main since this branch

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

* Contributors: regenerate after rebase onto main

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

* Contributors: regenerate (verified people.toml entries)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 21:22:11 -04:00
aaa946b56d Resampling reads a bounded window, however small the target (#713) (#860)
* Resampling reads a bounded window, however small the target (#713)

resize sized each band's source read from the filter support, which
grows as 1/scale, and read it in one block: shrinking a 6000x4000
image to one pixel allocated about 3 GB, and a large sparse document
could not be shrunk at all.

- Taps outside the source's content all read the default pixel, so
  their weight is folded into one 'outside' term instead of being read.
  The read window never extends past the content.
- Source rows are read in chunks under a fixed budget and go straight
  into the output rows that use them, so no band holds a full-height
  intermediate (an uneven stretch like 300000x300000 to 300000x1 stays
  bounded too).
- Tile rows with nothing stored are not read: they count as rows of the
  default pixel, so a huge sparse source costs what it holds.

Output is unchanged (within 1e-6 for surfaces with a non-zero default,
such as masks, whose empty rows are now summed rather than read);
6000x4000 to 1x1 drops from 3.2 GB / 3.3 s to
129 MB / 0.1 s, and ordinary resizes are as fast or faster.

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

* Re-run CI (the Ubuntu runner ran out of disk while compiling)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:20:47 -04:00
304322960e Gaussian and Box Blur: large radii no longer slow down (live preview too) (#464) (#867)
Blurs run as box passes now filter the whole area at once instead of tile by tile: the
horizontal passes run over the source rows only and the vertical passes over column strips,
with the canvas edge repeated virtually instead of materialising a 3-sigma margin around every
tile (at radius 500 that margin was ~25 MP of floats per 4 MP tile, copied and transposed
several times). The results are written to the layer's tile columns in parallel.

Output matches the previous running sums (8-bit: identical except 114 of 2.1 M samples one
level apart; 16-bit: within 2/65535) and a direct-convolution oracle at every depth.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:16:38 -04:00
735bb09a98 Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648) (#880)
* Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648)

crates/codecs gains codecs/vp8: a clean-room VP8 key-frame encoder from RFC 6386
(boolean coder, 16x16 and 4x4 intra prediction with per-macroblock mode decision,
forward DCT/WHT paired with the bit-exact inverse transforms, dc/ac quantiser tables,
token coding with per-frame probability updates, skipped macroblocks). webp.rs wraps
the frame in the RIFF container (bare VP8, or VP8X with ALPH alpha and ICCP/EXIF/XMP).
EncodeOptions: webp_lossless now picks the encoder instead of erroring, webp_quality
(0-100) sets the quantiser; a lossy WebP reports a LossyCompression fidelity warning.

Export As: the WebP entry gets Quality and a Lossless switch (new tl! string in all
catalogs). CLI --quality, MCP/RPC doc_save quality and the engine's JPEG-style
quality turn a WebP lossy; without a quality WebP stays lossless as before.

Tests: tests/webp_lossy.rs decodes every file with image-webp (PSNR by quality,
sizes fall with quality and are >= 4x below lossless on noisy photos, flat frames
under 1.2 KB, alpha exact, metadata round trip, odd/tiny sizes, 16-bit input, the
16383 px limit as an error, text edges), plus unit tests for the coder, transforms,
prediction and header.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* i18n(it): Lossless (Export As WebP switch) in the Italian catalog that landed on main

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:12:38 -04:00
Elvis33LEandBrandon Thomas fdd7141788 PSD: decode the merged composite only when something consumes it (#846)
Every PSD/PSB open decoded the merged image unconditionally, but a normal
layered Photoshop save carries a composite nobody reads: with layers
present and nothing behind the colour channels, the decoded planes went
straight to the drop. That composite is typically about half the file's
bytes, and for ZIP-compressed files the parse-time validation had already
inflated the same stream once - so layered ZIP opens paid the composite
twice for nothing (P18: opening a 20 MP layered PSD measures 1753 ms p50
against a 1000 ms budget).

The composite is now decoded only when a consumer exists: a flattened or
multichannel file (the composite IS the image), or extra alpha/spot
channels behind the colour ones. Layered files without extra channels
skip the decode entirely; everything a layered import shows comes from
the layer records either way.

The parse-time ZIP validation stays: deferring it would break the
corrupt-stream rejection the fuzz tests pin down, and with the decode
skipped, layered ZIP opens now inflate the composite once (validation)
instead of twice.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:10:38 -04:00
5902c878e5 fix(codecs): large PNG exports no longer lose bytes at a band's sync flush (#827)
The parallel PNG encoder deflates bands of rows separately and joins them with
sync flushes. Each band started with an output buffer half its size and resumed
the flush when the buffer filled. miniz_oxide (flate2's Rust backend) can
return from a sync flush that filled the buffer before compressing its last
lookahead bytes; the next call only drains the pending output and still
reports the flush done. The band then lost up to 258 bytes, and every band
after it decoded as garbage ("invalid literal/length code"). Detailed images of
4 MB and up, such as a 3000x3000 illustration, exported unreadable PNGs.

deflate_band now gives each band an output buffer past deflate's worst case, so
one call consumes the band and completes the flush. If that single call does
not finish, the parallel path returns None and the serial encoder is used.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:09:04 -04:00
65ea2c973b fix(io): artboard guide indices follow resource 1032 order (#722) (#845)
guides_in numbered the document guide list horizontal-first, but
psd_export writes resource 1032 vertical-first, so an artboard with
guides of both orientations was associated with the wrong guides.
Number vertical-first to match the resource.

Co-authored-by: Brandon Fenty <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:07:50 -04:00
83cfc3d9ef fix(algo): Path Blur clamps per-path speeds (#708) (#840)
* fix(algo): Path Blur clamps per-path speeds (#708)

filter.blurGallery.pathBlur clamps the scalar speed to 0..500, but
each entry of the documented paths array was deserialized without any
clamp. The halo takes the maximum speed and computes
Radius(v.max(0.0).ceil() as i32 + 1): a large finite speed saturates
the float-to-int cast to i32::MAX and the + 1 overflows, so the command
dies in a caught panic (and wraps to a negative halo with overflow
checks off).

Clamp each entry speed to the same 0..500 the scalar path uses, right
where the params are built.

* Path Blur test: drop an unused before-sample (a flat background can't change under blur)

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

---------

Co-authored-by: Brandon Fenty <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 21:06:10 -04:00
53959abc9c fix(warp): reject extreme rect params with BadParams (#808 follow-up) (#849)
Since #808, Rect::width/height return the exact extent for i32-extreme
edges instead of overflowing. That means a `rect` like
[-1e30, -1e30, 1e30, 1e30] now gets through `target()` in warp_cmds.rs,
because the only check there is `is_empty()`. The warp then runs on a
frame that is about 4e9 px on each side and fails deep inside with an
"internal error" (or could OOM before the panic guard catches anything).

Both edit.transform.warp and layer.smartObjects.warp go through
apply() -> target(). Add a size guard there: each side may be at most
MAX_WARP_SIDE (100_000 px), otherwise return BadParams with
"rect too large for warp".

Test: extreme_rect_is_bad_params_not_a_giant_warp sends the issue's rect
to both commands and asserts BadParams, then checks that a normal rect
still warps. Without the guard it fails with
"edit.transform.warp failed with an internal error".

Requested by tanish2k09 in the #808 review.

Co-authored-by: Luna <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:04:05 -04:00
1d78668b25 fix(ui-egui): GPU canvas tests no longer read a setup failure as "no GPU adapter" (#854)
canvas_16f and color_managed_canvas ran their setup commands with
.expect() inside the build_eframe closure. build_eframe calls that
closure synchronously, so the closure ran inside the catch_unwind that
is there to detect a missing adapter. A command returning Err panicked,
the panic was caught, and the test printed "skipping: no GPU adapter"
and passed.

The catch_unwind now wraps only the harness build. The setup commands
(file.new / edit.assignProfile / edit.fill, and the canvas_16f setup
list) run afterwards through harness.state_mut(), once the adapter
checks have passed, so an Err fails the test with the command name.

Manual check on a machine with a float-capable GPU: make a setup command
fail (e.g. pass a bogus profile to edit.assignProfile) and run
cargo test -p photocraft-ui-egui --test color_managed_canvas. It now
fails on "assign" instead of skipping green.

Fixes #760

Co-authored-by: Luna <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:00:43 -04:00
98e347be5f monitor_profile test: find sleep on PATH (NixOS and the Nix sandbox have no /bin/sleep) (#855)
Co-authored-by: Simon W. Jackson <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 20:55:27 -04:00
8f1e0d83ce Filter previews stay inside the selection on large documents (#740) (#836)
The live on-canvas filter preview runs the real command on a downsampled
proxy of the document, but the proxy dropped the selection, so on documents
large enough to need a proxy (over ~5 MP) the preview filtered the whole
layer while OK only changed the selection. The proxy now downsamples the
selection with the pixels, so the preview is limited to the (feathered)
selection exactly like the result.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 20:38:29 -04:00
00c406f589 Test: one press-drag-release gesture crosses menu titles and enters submenus (#775) (#837)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 20:30:18 -04:00
1ee12eb90d fix(engine): Camera Raw clamps the noise amounts to their range (#707) (#839)
filter.cameraRaw deserialized its params verbatim, so an out-of-range
noiseLuminance reached the denoise stage and became a guided-filter
radius of round((1 + 3 * n/100) * pixelScale). A value of 1e9 turned
six box-mean passes into unbounded work: the command never finished.
noiseColor had the same defect through its blur sigma.

raw_params now clamps the four noise fields to the documented 0..100,
matching how the sibling lens commands clamp their ranges at the same
boundary. The in-range path is untouched.

Co-authored-by: Brandon Fenty <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 20:24:42 -04:00
reeseandBrandon Thomas 2873afdda9 fix(layers): a locked group locks the layers inside it (#847)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 20:18:27 -04:00
Tanish MankuandClaude Opus 5.5 6f39aba7f2 Preset store: a store directory that doesn't exist yet reads as empty on Windows too (#878)
load_actions treats a missing actions.json as an empty list via missing_file(), which
matches the error text ("not found", "no such file", "os error 2"). When the store
directory itself doesn't exist yet, as on a first launch, Windows reports os error 3
("The system cannot find the path specified."), which none of those match. So opening
a fresh store warned "actions.json skipped", and two engine tests failed on Windows
(actions_persist_with_the_preset_store, group_with_sampled_8_and_16_bit_tips_round_trips).

DirBackend::read now maps io::ErrorKind::NotFound (os errors 2 and 3) to the same
"{name}: not found" wording the in-memory backend uses. New test: a store directory
that doesn't exist yet opens with no warnings (fails on Windows before the fix).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:57:32 -07:00
Tanish MankuandClaude Opus 5.5 ce87c106a9 agent_tasks: wait for the MCP server task before removing the test folder (Windows os error 32) (#877)
Three agent_tasks tests failed on Windows at remove_dir_all with os error 32. The
headless session's AuthorizedWorkspace opens its roots as cap_std::fs::Dir handles,
and the server task that owns it was still alive when the test deleted the folder:
c.cancel() closes the client, but the spawned server drops the session
asynchronously. Linux deletes directories with open handles, Windows doesn't.

connect/connect_headless now return the server task, and shutdown() cancels the
client and awaits the server, so the handles are gone before the folder is removed.
Test-only change.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:54:01 -07:00
Tanish MankuandClaude Opus 5.5 3689754ef2 tiff_corpus: ask for layers explicitly (corpus job red since #825) (#876)
#825 made ExportOptions::default() write a flat TIFF (headless saves stay flat
unless they ask for layers). corpus_round_trips_through_layered_tiff exported with
the defaults, so every corpus file came back flat and the round trip failed (670
failures). Test-only change: request tiff_layers: true.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:53:56 -07:00
Tanish MankuandClaude Opus 5.5 07174271d5 CI: never cancel main's runs; trim per-PR runner time (#800)
Main's CI was effectively unverified: with cancel-in-progress on every ref, each merge
cancelled the run of the one before. Between 09:56 and 20:34 UTC on 2026-10-07, 80 of 83
push runs on main were cancelled, and a formatting break from two PRs that were each green
alone (#663 + #700) went unnoticed until PRs updated to main started failing.

- ci.yml: cancel-in-progress only for pull_request. Main runs finish; a merge that lands
  meanwhile waits and runs next.
- ci.yml: a `platforms` job picks the test matrix. main, manual runs and PRs labelled
  `full-ci` get Linux, macOS and Windows. Other PRs get Linux only, unless they touch
  code whose behaviour differs by platform (apps/, ui-egui, text, gpu, tablet, heif,
  build.rs, Cargo.lock, ci.yml).
- ci.yml: the second Linux test run with craft-fonts covers only the crates that use the
  fonts (photocraft-text, photocraft-ui-egui), not the whole workspace again.
- freebsd.yml, windows-arm64.yml: nightly on main instead of on every push and every
  Cargo.toml change; PRs touching their own packaging still run them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 19:46:18 -04:00
0f16bce0c5 Add the Magnetic Lasso tool (#864)
* Add the Magnetic Lasso tool

A selection border that snaps to edges as the pointer moves, in the
Lasso (L) flyout after the Polygonal Lasso.

- photocraft-algo `magnetic`: live-wire edge tracing (Mortensen &
  Barrett 1995): Sobel strength, non-maximum suppression and gradient
  direction link costs, a shortest path inside a corridor of the
  detection width around the pointer's path, sub-pixel edge centres.
  Pixels are read lazily in 128 px blocks.
- Engine: `EdgeSource` (the active pixel layer, else the composite) and
  `select.magneticLasso`: rough points in, a border along the edges out;
  `trace: false` selects a given outline, which is how the tool commits.
- UI: click to start, move to trace (button up or down), points fasten
  by themselves (Frequency), click fastens, Alt-click straight, Alt-drag
  freehand, Backspace/Delete removes points, click the first point,
  double-click or Enter closes, Esc cancels, [ ] change the width.
  Options bar: Width, Contrast, Frequency, tablet pressure. `ui.inspect`
  reports the border in progress.
- The Lasso and Polygonal Lasso now apply the options bar's Feather;
  `select.lasso` rejects a feather outside 0..1000.
- Icon adapted from Noun Project's "Magnetic Lasso Tool" by Lil' Seal
  (CC BY 3.0, licence file and ATTRIBUTION.md row).

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

* it: translate the Magnetic Lasso strings (Italian landed after this PR was written)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 19:42:45 -04:00
7a9b591189 Italian UI translation (it) (#853)
* Update mod.rs

* Update system.rs

* Update live_tests.rs

* Update localization.md

* Italian UI translation (it)

* Register Italian fully and bring the catalog up to date

- LANGUAGES grows to 13 entries (the array length was still 12), with the native
  name Italiano.
- The system-locale test expects the code `it`, not `it-IT`.
- Italian joins the i18n coverage report, ATTRIBUTION.md and LICENSE-translations.txt.
- Translate the strings main gained since the catalog was written, and fix two
  comments copied from the Spanish catalog.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 19:13:01 -04:00
b436950ae3 Layer Style: complete multi-instance effects and dialog parity (#157) (#786)
* layer style: instance-aware engine apply (layer.layerStyle.replace) with param overlays, contour presets and factory defaults

* layer style: instance-based dialog state preserves every effect; Preview checkbox gates the live preview

* layer style: Styles swatches and New Style… save the dialog pending state (style.presets.new effects param)

* layer style: Contour picker and Noise slider for shadows and glows (CPU-rendered noise; GPU falls back to the CPU for noisy effects)

* layer style: Make Default / Reset to Default per effect kind (user defaults persist with the presets)

* layer style: double-stroke band regression (manga case) and control-protocol docs for the instance-based dialog

* layer style: make Styles a top-level tab displaying preset swatches in the right panel

* i18n: translate the Layer Style preset strings in de and pt-br

The German and Brazilian Portuguese catalogs landed on main after this
branch added "Style {name} saved", "Make Default" and "Reset to
Default"; every_tl_literal_is_translated needs them.

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

* presets: move the stray BTreeMap import to the top of the module

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 19:05:15 -04:00
8948511eeb Large PSBs open with their pixels: decode planes past 2 GiB, plus 4-5 GB PSB fixtures (#375) (#782)
* PSD: decode planes up to 8 GiB, so large PSBs keep their pixels (#375)

MAX_DECODED_BYTES capped a single decode at 2 GiB. Real PSBs go past
that in one plane set: the merged image of a 30000x30000 RGB file is
2.7 GB, a 33000x33000 16-bit layer channel 2.2 GB. Opening such a file
gave a document with no layers (flattened files) or layers whose
channels were "treated as empty" (layered files). Saving hit the same
cap on the encode side and silently fell back to uncompressed data.

The cap is now 8 GiB on 64-bit targets and 2 GiB elsewhere, the budget
photocraft-codecs already uses for Limits::max_alloc. It stays a
backstop: the decoders bound their output by their input on their own
(RLE expands at most 64x, Raw needs every byte present). The Raw and
RLE output buffers are reserved with try_reserve_exact, so a machine
without that much memory gets an error instead of an abort.

generated with Claude Code

* PSD import: a channel that can't be decoded leaves its layer empty (#375)

The warning says the channel is "treated as empty", but the importer
filled a missing transparency channel with opaque alpha, so a layer
whose channels all failed became an opaque black rectangle over the
document. A channel that is present but undecodable now leaves the
whole layer empty (transparent), as the warning says.

tests/large_psb.rs: the damaged-channel case (PSD and PSB), and an
opt-in release test that opens a flattened 29000x29000 PSB whose
merged image decodes to 2.52 GB (16 s, 11.6 GB peak here).

generated with Claude Code

* Large-file fixtures: generate and check 4-5 GB PSBs (#375, #216)

photocraft-psd example big_psb writes five PSBs straight from the spec,
independent of this crate's writer and its limits: flattened and
layered, raw (4.4 and 4.8 GB files, data past 4 GiB) and RLE (small
files whose planes decode past 2 GiB, up to a 33000x33000 16-bit
layer). Every sample follows one formula, so photocraft-io example
big_psb_check opens a file through photocraft_io::import and checks
pixels without a reference; with SAVE set it saves as PSB and checks
the re-opened file.

Release build, this machine: all five open with correct pixels and
survive a PSB save and re-open (5-14 s per import). Before the 8 GiB
cap, three of the five opened empty or black.

Scorecard FILE-216-2 missing -> partial: no 2 GB PSD, 150-layer or
20 MP fixture yet, and nothing runs in CI.

generated with Claude Code

* psd: ZIP channels reserve fallibly and read in bounded chunks

A small ZIP channel declaring a huge decoded size could make
zip_decompress try a multi-GiB with_capacity/read_to_end and abort on
allocation failure. Reserve with try_reserve_exact/try_reserve, read
through take(expected) in 1 MiB chunks, and report LimitExceeded
instead. Tests: absurd declared sizes, past MAX_DECODED_BYTES, garbage.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:59:39 -04:00
Carter SmithandBrandon Thomas 2a2459f57a Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot (#739)
* Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot

Like Photoshop, a file dropped on the active document's canvas is placed as an
embedded smart object layer and enters Free Transform (Esc takes the place back,
Enter makes place + transform one "Place Embedded" step; several files are placed
one after another). Dropped on the tab strip, it opens with its tab at the slot
under the pointer, shown by an insertion line while dragging. Elsewhere it opens
as before.

winit 0.30 drops carry no position, so the desktop shell reads the pointer from
the OS (apps/photocraft/src/cursor.rs: AppKit, x11rb, winsafe; no unsafe).

* Drop product names from comments, translate the place error, keep move_item panic-free

- Reword the new comments that named another product ("the reference app").
- "Couldn't place {name}: {error}" is a tl! string with entries in every
  complete catalog; `failed` now takes the finished message.
- The now-public `move_item` returns None for an out-of-range index instead of
  panicking (regression test in move_document's test).

* Tab moves as a document.move command, views follow their documents, drop the windows-sys lock bump

- document.move ({document?, to}) reorders tabs for the UI, agents and scripts; tab-slot drops and
  background opens go through it.
- sync_views keeps each view and its windows with their document by DocId, so moving or closing a
  tab never hands its view to another document.
- Restore os_pipe's windows-sys 0.52 lock entry (unrelated to this change).
- cursor::client_points: Windows pixel-to-point conversion, tested across monitors with different DPIs.
- de and pt-br strings for the place error.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 18:57:50 -04:00
0675a253dd M9: create vertical type from the Type tool flyout (#784)
* M9: create vertical type from the Type tool flyout

* Tool::ALL holds 47 tools now that Vertical Type and Content-Aware Move both landed

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:53:56 -04:00
f7be9fbe2c fix(photocraft): reject an unparseable --control port instead of silently dropping it (#701) (#777)
* fix(photocraft): reject an unparseable --control port instead of ignoring it (#701)

The app read --control and PHOTOCRAFT_CONTROL_PORT with parse().ok(),
so a typo (78787), a missing value, or a following flag consumed as the
value left the app running with no control server and no diagnostic.
Validate both sources, name the offending value, and exit non-zero,
matching photocraft-cli serve --port. A missing --control value no
longer swallows the next argument.

* Exit with status 2 on a bad --control port instead of 0

A launcher or agent could not tell a rejected control port from a normal
quit. The collected port errors now map to exit status 2 (command-line usage
error) through control_args_exit_code, which is unit-tested.

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

---------

Co-authored-by: Brandon Fenty <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:47:06 -04:00
57452bfd22 Bound layer-group nesting at import and Group Layers (stack-overflow guard) (#796)
* Bound layer-group nesting at import and Group Layers (stack-overflow guard)

Everything that walks the layer tree (Document::walk behind nearly every
command, the layers panel, PSD export, .pcraft save/load) recurses once per
level, but nothing bounded how deep a document could get: a PSD whose records
nest groups a few thousand deep (each level costs only a divider + folder
record pair) overflowed the stack in the importer's build recursion, in
apply_link_groups, and again in every consumer - an abort no panic hook or
fuzz run catches. The one cap that existed lived in the .pcraft save path
(MAX_GROUP_DEPTH = 100), so such a file even imported 'fine' and then bricked
saving.

- photocraft-doc now owns the limit (MAX_GROUP_DEPTH = 100, the value the
  format crate already enforced) plus Document::max_group_depth(), an
  iterative depth measure that cannot itself overflow.
- PSD/PSB import rejects files nested deeper than the cap with an actionable
  Unsupported error, mirroring what the .pcraft loader already does; the
  never-fail psd_to_document keeps working on such files by capping the
  recursion and warning about the unimported remainder (this was the overflow).
- apply_link_groups (resource 1026) recurses over the same tree and is
  bounded the same way.
- layer.groupLayers refuses to nest past the cap (checked inside the edit, so
  a rejected call records no history step and leaves the document untouched).

* Enforce the group nesting cap in layer.moveTo and Artboard from Layers

Moving a group beside or into a deeply nested layer, or wrapping a deep
group in an artboard, could still build a tree deeper than MAX_GROUP_DEPTH.
A shared check_group_depth helper now rejects those edits with an error and
records no history step, like layer.groupLayers.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:46:45 -04:00
f9479063be Rework Camera Raw filter preview and navigation (#781)
* Add Camera Raw source-resolution preview navigation

* Keep web preview responsive and discard stale refinement errors

* Preserve Alt wheel intent throughout smoothed Camera Raw zoom

* Specify refinement result type for the wasm fallback

* Bound sparse Camera Raw proxy domains and validate detail tests

* Document Camera Raw zoom controls with native screenshots

* Drop the Camera Raw zoom screenshots; keep the guide as docs/camera-raw-zoom.md

We don't commit sizable binary assets. Removes fit.png and actual-size.png,
their LICENSE.txt and ATTRIBUTION row, and moves the controls guide next to
docs/camera-raw-histogram.md.

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

* Translate the Camera Raw full-resolution notice in de and pt-br

Main added German and Brazilian Portuguese catalogues after this branch
forked; the i18n coverage tests need the new label in both.

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

---------

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:40:24 -04:00
c887ed94cc Add the Content-Aware Move tool (#213) (#814)
* Content-Aware Move: move or extend the selected pixels, the gaps filled content-aware (#213)

New command paint.contentAwareMove {offset, mode: move|extend, structure 1..7,
color 0..10, sampleAllLayers}. The content is pasted at the offset, its colour
fitted to the new place by `color` (0 keeps it, 10 is a full seamless clone),
and an edge band whose width `structure` sets is re-synthesised from the
surroundings. In move mode the old place is then filled with
content_aware::fill, never sampling the moved content (no ghost). The
selection follows the content. One undo step; runs as a background job with
progress and cancel. Works on layer pixels, masks and channels, at any depth.

Timing (retouch_bench, release, 6016x6016, elliptical selections dragged
300 px): 250x250 move 240 ms / extend 129 ms, 500x500 0.93 s / 0.53 s,
1000x1000 2.6 s / 1.5 s.

Known limit: at low Structure the wide band comes out smeared (the PatchMatch
vote blurs a thin band between high-contrast regions); that is the shared
completion, not this command.

generated with Claude Code

* Content-Aware Move Tool in the J group (#213)

Tool::ContentAwareMove next to the Patch Tool: outside the selection a drag
draws a lasso, inside it drags the selection and runs paint.contentAwareMove
on release (as a background job). Options bar: Mode (Move/Extend),
Structure, Color, Sample All Layers. The drag shows the moving outline; a
live preview of the result is not part of this change. New strings are
translated in every catalog. Scorecard TOOL-213-4: missing -> partial
(Transform On Drop and the live preview are still open).

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:40:14 -04:00
BFlatandClaude Opus 5.5 727daf5d67 Copy works on smart object, type and shape layers (#541) (#841)
Edit > Copy only reads, so it now takes whatever the active layer shows
(a smart object's, type layer's or shape's rendered pixels), with or
without a selection, as Photoshop does. Cut still needs a pixel layer and
says a smart object is not directly editable.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 05:14:43 +07:00
Brandon ThomasandClaude Opus 5.5 5c94c5e2b6 rustfmt crates/engine/tests/hdr_gamma_direction.rs (Format red on main since #801) (#842)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:09:41 -04:00
Brandon ThomasandClaude Opus 5.5 25f211500b i18n(ko): drop the duplicate Ambient and Warmth rows (main red since #811 + #824) (#834)
Both PRs added Korean labels for Relight's options, so ko.tsv listed each twice and
i18n::tests::bundled_catalogs_are_consistent failed on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:02:45 -04:00
a20c63521d Help › Search: find any menu command by name (#815)
* Help › Search: find any menu command by name

A search field at the top of the Help menu (focused when the menu opens)
lists the menu commands matching what is typed, with their menu path and
shortcut: labels starting with the text first, then a word starting with
it, then anywhere in the label, then a match in the menu path only. Click a
result, arrow down to it and press Return, or press Return in the field to
run the first enabled one. Up to 15 results; the field scrolls with the
menu's rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Help search: match translated menu names as well as English

Untranslated strings ("Search menus", "No matching commands") fall back to English
through tr() until translators add them.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 18:02:34 -04:00
684f85e2e5 ⌘⌥⌃-click on the canvas selects the topmost layer under the pointer (#813)
* ⌘⌥⌃-click on the canvas selects the topmost layer under the pointer

With any tool, ⌘⌥⌃-click runs `layer.pickAt` at the pointer (the topmost
visible layer with pixels there becomes the selected layer) without
switching to the Move tool; the press, drag and release are swallowed so the
active tool never paints or draws. It runs before the ⌃⌥ brush-resize
gesture, which the same keys would otherwise trigger.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* quick_pick: document the macOS-only gesture, report a failed pick as an error

Windows and Linux never report mac_cmd, so the gesture is a documented no-op there and
Ctrl+Alt keeps resizing the brush. Tests cover both, plus a failed pick.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 18:02:23 -04:00
Elvis33LE ec64eb773c Reject geometry params that would silently wrap through i64->i32 narrowing (#803)
`commands::int` returns an unbounded i64, and the geometry call sites narrowed
it with `as i32`, which truncates by multiples of 2^32: layer.translate moved
a layer 50 px for `dx = 2^32 + 50` and to a large negative offset for
`dx = 3e9`; select.rect and document.pixel read unrelated coordinates;
image.crop cropped a rectangle unrelated to the numbers passed; artboard
rects landed elsewhere. No panic, no Err - the document was silently
mutated and then autosaved, and the behaviour is identical in debug and
release, so panic_hunt cannot see it either.

- new `commands::int_i32(cmd, p, key)` narrows with a bad-params error
  instead of a wrapping cast; select.rect, document.pixel, layer.translate,
  image.crop and the artboard rect keys go through it
- image.crop builds its rectangle with saturating_add (x + w overflow)
- edit.contentAwareFill's `area` parser returns a bad-params error for
  malformed arrays (wrong length, non-numbers, non-finite) and saturating
  arithmetic for huge ones - `[1e30, 0, 1e30, 10]` overflowed the i32
  additions before (a debug-build panic, a garbage sampling window in
  release)
- filters' integer helper clamps out-of-range values like the neighbouring
  filter params instead of wrapping (`3e9` came back as a negative offset)
2026-10-07 18:02:12 -04:00
LunaandLuna 9d8a78a17e fix(ui-egui): New Document keeps the physical size when Resolution changes (#758) (#818)
Editing Resolution in the New Document dialog only wrote the `resolution`
field. With Width/Height shown in inches, cm, mm, points or picas the
pixel width/height stayed as they were, so the displayed physical size
was recomputed from the old pixels and the document came out at a
different print size (A4 at 300 -> 150 ppi became 16.53 x 23.39 in).

The Resolution field now goes through a new `set_resolution` helper:
with a physical unit it scales width/height by new_ppi / old_ppi (so
A4 becomes 1240 x 1754 px at 150 ppi, still 8.27 x 11.69 in) and drops
the `__preset` marker; with pixels it keeps the pixel count, matching
Photoshop.

The Pixels/Inch <-> Pixels/Centimeter dropdown needs no change: it only
switches how the number is displayed, the stored resolution stays in
ppi, so width/height and the physical size are already unaffected.

Co-authored-by: Luna <[email protected]>
2026-10-07 18:01:33 -04:00
LunaandLuna 0c976f42d5 fix(geom): Rect::width/height no longer overflow on i32-extreme edges (#675) (#808)
width() and height() computed `x1 - x0` / `y1 - y0` with plain i32
subtraction. For extreme edges (e.g. an inverted i32::MAX..i32::MIN
rect, or a valid rect spanning most of the i32 range) the subtraction
overflows: debug builds panic, release builds wrap. warp.* commands
build a Rect straight from the JSON `rect` param, so an extreme value
reached this from user input.

Both now return the exact extent via `abs_diff` when the far edge is
past the near one, and 0 otherwise. The extent of any i32 pair fits in
u32, so no clamping or casting is needed. A plain `saturating_sub`
would avoid the panic but turn a wide valid rect into a width of
i32::MAX, which is wrong.

Follow-up (not in this change): warp_cmds still accepts absurd `rect`
params; it should reject them with a parameter error up front.

Co-authored-by: Luna <[email protected]>
2026-10-07 18:00:59 -04:00
bfentyandBrandon Fenty a508ee9014 fix(engine): clone source_rect clamps huge mapped coordinates (#716) (#809)
Mapping::source_rect cast input-derived floats with `as i32` and then
added the bilinear apron. A large `source` (e.g. 3e9) with a non-
translation mapping saturates the cast to i32::MAX and the `+ 2`
overflows: debug panics, release wraps to an inverted rect and samples
the wrong region silently.

Clamp to the i32 range before casting and apply the apron with
saturating arithmetic, matching the sibling Rect::inflate convention
AGENTS.md prescribes for input-derived offsets. Normal-sized sources
produce bit-identical rects.

Adds unit tests: the issue's 3e9 + 45-degree repro stays ordered
(panics on main at clone_source.rs:127), a negative huge source
clamps the same way, and a normal mapping's rect is unchanged.

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 18:00:21 -04:00
TiredIsaandClaude Opus 5.5 4b3da49486 Camera Raw: read and write settings as a live PSD Smart Filter (part of #215) (#459)
* Camera Raw: read and write settings as a live PSD Smart Filter (part of #215)

- Map Camera Raw 18.4 (process 5/6) filter settings to and from PSD: custom white
  balance, Light, Presence, parametric and four point curves, HSL, Color Grading,
  sharpening, noise, grain and numeric post-crop vignette controls.
- Keep the original Photoshop descriptor; edits patch only changed fields. Unknown
  settings or versions stay an opaque filter, preserved byte for byte.
- PSD stores integer slider levels: export rounds continuous PhotoCraft values
  (hue 360 -> 0) instead of dropping the filter.
- Tests: field mapping, patching, opaque fallback, malformed input, rounding, and
  8/16/32-bit PSD -> edit -> .pcraft -> PSD with mask, filter order and undo/redo.

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

* Camera Raw PSD: use as_chunks for curve pairs (clippy 1.99)

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

* Camera Raw PSD: no product names in Rust comments, strings or test names

Only the descriptor class key itself keeps its literal name.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:59:16 -04:00
Nigel Ihza Firdausy 2b8be01d6f Handle Stack Mode sources with no visible layers (#770)
* fix(engine): reject empty smart-object stack sources (#717)

* test(engine): cover stack mode with no visible layers (#717)

* style: format regression coverage for #717

* test(engine): reproduce hidden Stack Mode sources through commands
2026-10-07 17:57:43 -04:00
Nigel Ihza Firdausy b62664ad33 fix(ui): cancel stale warp drags when the mesh changes (#757) (#774) 2026-10-07 17:57:33 -04:00
BFlatandClaude Opus 5.5 6b926cdecd DX12: load the shader compiler from System32 or beside the exe, never from PATH (#712) (#817)
wgpu's default DX12 compiler (`Auto`) loads `dxcompiler.dll` by name, and the Windows DLL search
falls through to the current directory and PATH, so another program's DXC build (a Chrome or SDK
folder) was loaded and crashed shader compilation or failed device creation. The app now uses FXC
(d3dcompiler_47.dll, part of Windows) unless a dxcompiler.dll ships beside photocraft.exe, which is
loaded by its full path. WGPU_DX12_COMPILER still overrides.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:48:27 -07:00
bfentyandBrandon Fenty 8ce0684759 fix(engine): view.newGuideLayout rejects absurd column/row counts (#704) (#797)
columns/rows came straight from params with no cap. layout_lines loops
once per requested line and each push_unique scans the growing guide
vector, so a count like 200000000 (well inside u32) never returned -
blocking the app, CLI, MCP and the TCP control channel.

Reject counts above 1000 with an actionable BadParams naming the
offending parameter. Photoshop's own dialog caps at 32; 1000 is a
generous ceiling whose worst case finishes instantly, so legitimate
grids are untouched.

Adds regression tests: the issue repro (200M columns, and rows)
returns an actionable error immediately, and bounded layouts at the
cap still produce their guides.

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:47:42 -07:00
reese 4b80b031bc Auto-Align Layers: the dialog no longer sends reference 0 (#674) (#795)
* fix(dialogs): Auto-Align no longer sends reference 0 (#674)

* style(xtask): rustfmt the i18n coverage test
2026-10-07 14:46:13 -07:00
bfentyandNigel Ihza Firdausy 38d23b621f fix(engine): keyboard shortcut bulk set reports in-call clashes (#719) (#799)
keyboard shortcut bulk set reports in-call clashes in conflicts instead of silently dropping one binding (#719). Supersedes #771.

Co-authored-by: Nigel Ihza Firdausy <[email protected]>
2026-10-07 14:46:08 -07:00
bfentyandBrandon Fenty 385e8979de fix(algo): HDR Toning gamma follows its documented direction (#710) (#801)
The field documents "1 = neutral; lower = more contrast", but the tone
map used gamma directly as the powf exponent, so 0.5 lightened
midtones and 2.0 darkened them - the slider ran opposite to its own
contract. The crate's other two gamma controls (hdr.rs, proof_sim.rs)
already use the reciprocal exponent.

Apply 1.0 / gamma like they do. Neutral (1.0) is unchanged; every
other value now moves midtones the documented way.

Adds a regression test on a flat 50% grey: gamma 0.5 darkens the
midtone, gamma 2.0 lightens it (reversed on main: 0.71 / 0.25).

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:46:04 -07:00
bfentyandBrandon Fenty 080f2c4c8d fix(algo): gallery colour injection covers Colored Pencil and Neon Glow (#709) (#805)
uses_colours() derived from the params notation, but Colored Pencil
(paper tint from the background) and Neon Glow (foreground mixed over
background) read the colours in their pixel code without listing them.
The single-filter commands therefore skipped colour injection and both
filters always ran with the default white/black regardless of the
session colours, while filter.filterGallery honoured them.

Add the two filters to uses_colours() at the source, so the command
injection, the gallery dialog note, and smart-filter re-renders all
agree.

Adds regression tests: filter.gallery.coloredPencil and
filter.gallery.neonGlow output must change with the session
background (both identical on main).

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:45:59 -07:00
bfentyandBrandon Fenty f15fb4ee6e fix(algo): blur small sigmas instead of silently no-oping (#706) (#783)
gauss_box_radii followed Kovesi 2010 exactly, and the rounded triple
degenerates below sigma ~0.577: wl floors to 1 and all three radii
become 0, so every box pass early-returned and gauss_blur_n handed the
buffer back untouched for any sigma in [0.3, 0.577). Callers with
documented small sigmas (Camera Raw sharpenRadius 0.5 and colour noise,
HDR Toning radius 1, Shadows/Highlights mask blur, Oil Paint
cleanliness) silently did nothing in that band.

Clamp the degenerate case to one radius-1 pass - the smallest real
blur - which only engages where the ideal box widths are already at
their floor, leaving every sigma the paper handles well unchanged.

Adds the command-level regression test from the issue: cameraRaw
sharpenRadius 0.5 must change pixels (control: radius 3.0).

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:45:54 -07:00
reese 48bfaa9d0d fix(file): open .psdt templates as new untitled documents (#807)
Templates show up in the Open dialog and open as the first free
Untitled-N without a path, so File › Save asks where to save instead of
offering the template. They still go to Open Recent. This covers every
way a file opens: File › Open and Open Recent, drops and launch
arguments, background opens, the control channel's app.open, file.openAs
and headless doc_open.
2026-10-07 14:45:49 -07:00
reese 9a9eb013db fix(layers): double-clicking just above or below a layer name renames it (#823)
The rename hit box was only the name's glyph height (about 15 px of a 32 px row), so a double-click a few pixels off vertically opened Layer Style. The name's column now spans the row's full height; to the right of the name still opens Layer Style. Refs #651.
2026-10-07 14:45:44 -07:00
BFlatandLuna 03ffca0d54 Dialog buttons follow the OS order; Windows/Linux unsaved prompt asks Yes / No / Cancel (#627, #776) (#826)
Dialog buttons follow the OS order through one shared widgets::dialog_buttons helper (fixes #776, #627). Supersedes #802, whose reordering of the unsaved-changes and Revert prompts this carries to every dialog.

Co-authored-by: Luna <[email protected]>
2026-10-07 14:45:40 -07:00
Tanish MankuandClaude Opus 5.5 41e9c742dc Headless saves write a flat TIFF unless they ask for layers (#825)
Maintainer decision on #672: the app's Save As keeps TIFF layers as Photoshop does (it sets
ExportOptions.tiff_layers from its Layers option), but scripted and agent saves stay flat by
default so conversions keep predictable sizes. #672 merged with the default on, so every
headless TIFF save became layered.

- photocraft-io: ExportOptions::default() has tiff_layers: false. The desktop and web Save As
  already set it explicitly from their settings, so the app is unchanged.
- photocraft-cli: --tiff-layers on convert, run and batch (listed in the usage text).
- engine: file.saveACopy, Batch, Image Processor, the export-to-files commands and the lens
  batch take "tiffLayers": bool=false, through one SaveOpts { quality, tiff_layers }.
- automation: serve doc.save and MCP doc_save take tiffLayers (default false).
- Tests: default ExportOptions write a flat TIFF; the CLI writes flat without the flag and
  layered with it (run and convert); #672's layered-TIFF tests ask for layers explicitly.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:35:31 -07:00
BFlatandClaude Opus 5.5 15af216bd1 Follow-ups to #665, #666, #734: Photoshop's mask-only Remove Background, one-step eye sweep, no-clone Show Only (#824)
* layer.showOnly: no document clone, keep layers added during a solo, more tests

- Compute the rows from a borrow instead of cloning the whole document, and set
  visibility by path (one walk) instead of an id lookup per layer.
- Restoring keeps the current visibility of layers the snapshot doesn't know
  (added while one layer was shown alone) instead of leaving them unspecified.
- Tests: enclosing groups stay visible and a group's contents keep their own
  visibility, an undo ends the solo, one history step, no snapshot shows every
  layer, bad layer ids and no document return errors.

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

* Layers eye sweep: one history step, no skipped rows on a fast drag

- Every layer.setProps of one eye-column drag shares a coalesce key, so the
  whole sweep is one "Layer Visibility" undo step (it was one per row).
- A row counts as swept when the pointer passed it since the last frame, so
  a fast drag that jumps a row in one frame no longer skips it.
- The sweep lives in a small EyeSweep helper instead of inline in layer_row.
- layer.setProps names a visibility-only change "Layer Visibility" also when
  it carries a coalesce key.
- The harness test also checks one undo step and a single-move drag.

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

* Layers panel: hidden effects and smart filters get an empty eye box too

Matches the layer rows (and Photoshop): an effect or smart filter that is off
leaves its eye box empty instead of drawing a struck-out eye, so the whole
eye column of the Layers panel reads the same way.

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

* Remove Background: Photoshop's mask-only Quick Action, reusing Select Subject's sampler

Photoshop's Remove Background is a Properties (and contextual task bar) Quick
Action that analyses the selected layer and adds a layer mask from Select
Subject; it has no Layer menu item and no destructive variant.

- No menu path (drops the Layer › Remove Background placement); it stays
  reachable from Quick Actions, the command palette, the CLI, the control
  channel and MCP.
- Always a layer mask: drop the `output: "transparency"` bake and the `fill`
  layer (both are existing commands: Layer Mask › Apply, New Fill Layer) and
  the `radius` knob.
- Analyse the layer alone by default (`sampleAllLayers` false), as Photoshop
  does for the selected layer; true samples the composite.
- Reuse Select Subject's sampler: smartselect_cmds gains `with_doc_sampler`
  (what `with_sampler` now calls) instead of a second CompositeSampler and
  find/refine helpers.
- Promote the Background only once a subject is found.
- Tests: mask at 8/16/32-bit with untouched pixels, one history step,
  undo/redo, Background promotion, no subject, layer-alone default vs
  sampleAllLayers, disabled states and bad params, replacing a mask.

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

* Korean labels for Relight's Ambient and Warmth (i18n coverage test red on main after #735)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 04:25:22 +07:00
Tanish MankuandClaude Opus 5.5 e1cf321183 rustfmt crates/ui-egui/src/export_dialog.rs (Format red on main since #672 + #733) (#822)
Formatting only: #672 and #733 both changed export_dialog.rs and were each
formatted on their own branches; merged together, cargo fmt --check fails.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:22:39 -07:00
Tanish MankuandClaude Opus 5.5 b6f5df3b3f pcraft directory saves: re-verify a rolling share of cached objects; stronger file signature (#821)
Follow-up to #752. After a directory save verifies an object, later saves trust it while its
file signature is unchanged. A file damaged in place with the same length and modification
time (bit rot, or a tool that restores timestamps) was trusted until the writer was recreated.

- The signature also carries inode, device and change time on Unix, which an in-place rewrite
  changes even when the modification time is restored.
- Each directory save re-reads and re-checks cached objects, oldest-checked first, up to
  DEFAULT_REVERIFY_BUDGET (16 MiB of compressed data; at least one object), so over enough
  saves every object is read again and a damaged one is rewritten. Repeat saves of small
  documents stay a full check; large ones pay a bounded cost per save.
  PcraftWriter::set_reverify_budget tunes it (0 = trust signatures, u64::MAX = check all).
- PcraftWriter's doc comment states what the cache trusts.
- Tests: a same-size, same-mtime tamper is repaired on the next save (fails with the budget
  at 0 on Windows, where there is no change time), and with a 1-byte budget the rolling
  re-check reaches the damaged object within one pass over the objects.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:20:36 -07:00
6ee3e4f752 Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647) (#733)
* Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647)

The document's XMP packet lists the text of every type layer
(photoshop:TextLayers) and one id per placed document
(photoshop:DocumentAncestors) - data a flat web image should not carry:
14-116 KB per export on real designer PSDs, and layer text leaking into
images that show only part of the design.

- io: ExportOptions.xmp (XmpEmbed::All | None, default None) drops the
  packet from flat exports before the codecs see it. Layered saves
  (PSD, PSB, .pcraft) always keep everything; DPI, ICC and EXIF are
  unaffected. Export As, Save a Copy, the CLI, Layers/Artboards to
  Files and Batch all share this path and get the safe default.
- The Export As dialog gains a Metadata dropdown (None / All, default
  None, like the source application's Export As); the file-size
  estimate follows the choice.
- Tests: new xmp_export.rs covers the default (png/jpg/webp/tif carry
  no packet), the All opt-in and the layered always-keep;
  metadata_roundtrip_png and the stale-orientation test document the
  new contract (with All, an embedded stale orientation is still
  rewritten upright).

* Export As alone starts at Metadata: None; Save As and conversions keep the XMP

ExportSettings::default() and ExportOptions::default() also serve File > Save As, file.saveAs,
the CLI and automation, which keep the document's metadata (as Photoshop's Save As does).
Only the Export As dialog defaults to None. A layered TIFF honours the choice too.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:19:17 -04:00
Brandon ThomasandClaude Opus 5.5 83ace1138e rustfmt crates/ui-egui/src/canvas.rs (Format red on main since #711) (#819)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:17:38 -04:00
9ef9992878 fix(automation): close ambient filesystem authorization gaps (#763)
* fix(automation): block ambient filesystem bypasses

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* test(automation): cover ambient path denial

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix(automation): guard filesystem path authorization

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

* automation: judge the first non-empty prefs segment; refuse any path without data

prefs.set skips empty path segments, so `.scriptEvents.enabled` reached
script events past the section check. Preset import / plugin.install
with a whitespace path also read the filesystem.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:14:03 -04:00
Alex GorevskiandCopilot 1b24b5212b Repair corrupt directory-save objects (#752)
* Fix directory save reuse of corrupt objects

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Avoid revalidating unchanged directory-save objects

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:13:09 -04:00
Alex GorevskiandCopilot e9142754a4 Fix text ink bounds overflow (#750)
* Fix text ink bounds overflow

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Validate type size and tracking parameters

Reject out-of-range text metrics at type command boundaries to avoid silently empty renders.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:55 -04:00
4a0a8d0794 Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219) (#622)
* Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219)

The Actions list moves from UiState to the engine Session, so the panel, photocraft-cli, the control channel and MCP share one list. actions.play checks every nested step with the same authorization a top-level command gets in untrusted sessions.

* Regenerate docs/scorecard.md after rebase

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

---------

Co-authored-by: Matt Van Horn <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:12:52 -04:00
Alex GorevskiandCopilot 51a0816f99 Bound paint stroke work (#678, #679) (#751)
* Bound paint stroke work

Cap airbrush catch-up for long timestamp gaps and enforce safe brush dimensions before rendering, with a lower-level renderer guard and regression coverage.

Fixes #678

Fixes #679

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix: align paint brush limits and airbrush catch-up

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:49 -04:00
Alex GorevskiandCopilot 165e11ad6a Fix hostile PSD pattern geometry (#743)
* Fix hostile PSD pattern geometry

Use checked rectangle extents and reject channel rectangles that differ from their parent tile, preventing panic-prone subtraction and huge zero-filled plane allocations. Bound aggregate decoded pattern data and PackBits expansion using parser limits and payload size. Add regression coverage for .pat, PSD global pattern blocks, and .abr embedded patterns.

Fixes #682
Fixes #683

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Preserve bounded PSD pattern channel placement

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:44 -04:00
Nigel Ihza Firdausy 1a052d6115 Reject overflowing slice rectangles (#768)
* fix(engine): reject overflowing slice rectangles (#715)

* test(engine): cover slice rectangle overflow (#715)

* refactor(engine): keep slice rect validation explicit (#715)

* test(engine): exercise overflowing slice commands (#715)

* fix(engine): reject invalid slice.set geometry before mutation
2026-10-07 17:12:39 -04:00
bd632e7eb3 Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215) (#672)
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)

Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.

* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
  the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
  Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
  doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
  masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
  binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
  annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
  warning instead of passing byte-swapped data to other readers. The reader gained an optional
  little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
  bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
  BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
  import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
  options), interleaves its merged planes for the TIFF image and stores the layer section in
  the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
  documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
  and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
  Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
  TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
  byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
  `corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
  round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
  hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.

Co-Authored-By: Claude Mythos 5.1 <[email protected]>

* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs

The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 17:10:35 -04:00
Alex GorevskiandCopilot 8302f57190 Fix vector pixel bounds overflow (#747)
* Fix vector pixel bounds overflow

Saturate exclusive pixel bounds at i32::MAX and cover extreme and ordinary paths with regression tests.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Cover extreme vector path fill commands

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:10:21 -04:00
Alex GorevskiandCopilot d9a0f38280 fix(raw): validate DNG default crop bounds (#745)
* fix(raw): validate DNG default crop bounds

Fixes #692

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix(raw): fall back for invalid DNG default crops

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 17:10:03 -04:00
Tanish MankuandClaude Opus 5.5 7080e6c56a Korean labels for Relight's Ambient and Warmth options (#811)
korean_covers_generated_filter_options_and_gallery_names (from #604) requires a
Korean label for every generated filter option. Relight (#735) added the options
ambient and warmth; its CI ran on a base without #604's test, so main now fails
it. Adds the two catalog entries.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:04:29 -07:00
2baa95d684 Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219) (#623)
* Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219)

Stateless queries in document pixels. The word, line and hit-test helpers move from type_tool.rs into photocraft-text, and the Type tool calls the same code.

* Regenerate docs/scorecard.md after rebase

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

---------

Co-authored-by: Matt Van Horn <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:02:09 -04:00
9e88c65d33 Support Alt-held polygon segments in the freehand lasso (#711)
* Support Alt-held polygon segments in the freehand lasso

* Translate the Alt lasso hints into Simplified Chinese

* Test mixed freehand and Alt-click lasso gestures

* Lasso: a press inside the selection still drags it; Alt lasso hints in every catalog

The Alt-polygon lasso takes over the Lasso's pointer events, so the selection drag from
#686 (press inside the ants to move the outline, Cmd-drag to float the pixels) no longer
reached the canvas. Hand those presses, and the drag they start, back to the canvas path.
Also adds the two new hint strings to every translation catalog.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:57:21 -04:00
2cbb13a79d fix(i18n): follow native UI languages on first launch (#661)
* fix(i18n): follow native UI languages on first launch

* i18n system tests: German and Portuguese are supported now; use Swedish as the unsupported tag

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:52:55 -04:00
reese 1353917f15 fix(retouch): Spot Healing honours Sample All Layers (#769) 2026-10-07 13:50:13 -07:00
fb924dad16 Shapes: a hairline vector stroke no longer darkens the shape's edge pixels (#397)
* Shapes: a hairline vector stroke no longer darkens the shape's edge pixels

A stroked shape with layer effects (or clipped layers) is drawn as our
fill plus a vector stroke fitted to Photoshop's own pixels: where those
pixels reach past our fill, the stroke takes the alpha the fill doesn't
explain, `(pixels - fill) / (1 - fill)`. It kept our stroke's colour for
that alpha. When Photoshop's fill covers more of an edge pixel than ours
(shapes snapped to whole pixels with Align Edges, our path a fraction off
the grid), the fitted alpha is mostly Photoshop's fill, so a 0.25 px grey
border on a white card was painted as a solid grey line: the edge came out
at the stroke's full colour (122) instead of Photoshop's 220.

The fitted stroke now takes the colour that, drawn Normal over our fill,
gives back Photoshop's pixel (un-compositing the premultiplied colour),
and keeps our colour only where the fitted alpha is negligible. Where
Photoshop's pixels agree with our fill this is our stroke's colour, as
before.

Test: hairline_vector_stroke_keeps_photoshops_edge_pixel_colour (edge
pixel 0.478 before, 0.86 after). The compose, gpu, io and engine tests
and the psd/io/engine corpus tests pass.

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

* Shapes: neutral wording in the hairline-stroke fix; test it at 8, 16 and 32 bits

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

---------

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-07 13:50:09 -07:00
a1f4387e63 fix(codecs): correct TIFF polarity and BigTIFF orientation (#746)
Use the TIFF decoder's normalized WhiteIsZero samples without a second inversion, and parse BigTIFF IFD0 Orientation so decode applies the recorded rotation.

Fixes #691

Fixes #693

Co-authored-by: Copilot <[email protected]>

Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:50:04 -07:00
702b4aa6b0 Fix ui.set brush size command journaling (#744)
Co-authored-by: Copilot <[email protected]>

Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:59 -07:00
Nigel Ihza FirdausyandTanish Manku ac57c17b14 Clamp Variables data-set indices from control input (#772)
* fix(ui): bound Variables data-set index from control input (#756)

* test(ui): cover hostile Variables data-set indices (#756)

* style: format regression coverage for #756

---------

Co-authored-by: Tanish Manku <[email protected]>
2026-10-07 13:49:54 -07:00
151afe47b6 fix(automation): downscale bridge screenshots before PNG budget (#753)
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:50 -07:00
3ad26e3b51 Fix document run and ID overflow (#684, #685) (#749)
Co-authored-by: Copilot <[email protected]>

Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:46 -07:00
Tanish MankuandClaude Opus 5.5 f46c462129 rustfmt crates/codecs/tests/deep_exr.rs (Format red on main since #642) (#804)
Formatting only: cargo fmt --check (rustfmt 1.99, as CI) fails on main at
crates/codecs/tests/deep_exr.rs:360 since #642 landed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:47:55 -07:00
b70bf524ea Filter › Render › Relight: redirect a photo's lighting (#735)
* Add Filter › Relight to redirect a photograph's lighting

Introduce a tiled Retinex and Lambertian kernel with angle, elevation,
intensity, ambient, warmth and softness. Wire it as filter.render.relight
with a live-preview dialog, selection support, and smart-filter recording.

* Tidy Relight kernel loops

Rewrite the per-channel colour multiply and alpha restore so the filter
builds cleanly under clippy.

* Format the Relight kernel and enablement check

Keep rustfmt's wrapping on the alpha restore and collapse the lock
predicate to a single expression.

* Step Relight shading gradients at the blur scale

A one-pixel difference vanishes on large documents, so the lamp
collapsed to a flat multiply. Sample S at ±σ and keep a 256 px
sphere direction check so that does not regress.

* i18n: German entry for Filter › Render › Relight

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

* i18n: Brazilian Portuguese entry for Filter › Render › Relight

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:45:57 -04:00
6c7d01a0f3 Selections: drag to move the outline; ⌘-drag floats the selected pixels (engine commands) (#686)
Engine (scripts, the control channel and agents can drive it):
- `select.float {"dx","dy"}`: cuts the selected pixels of the active layer
  into a floating piece the first time, then moves it by whole pixels.
  The document is unchanged while it floats (`DocState::floating`);
  `float_cmds::displayed` gives the document with the piece at its offset.
- `select.drop`: drops the piece into its layer and moves the selection
  with it, one history step ("Move Selected Pixels").
- Any other command drops a floating piece first (a hook at the top of
  dispatch, so it lands where it was shown); Undo puts it back instead.
- The layer is split once, only inside the selection, and the piece is
  cropped to it: placing it costs the piece, not the layer (6000×4000
  layer, 500×400 piece: 18 ms to cut, under 5 ms per move).

Canvas (Marquee / Lasso):
- A drag inside the ants moves the outline (`select.transformSelection`);
  a click inside without moving deselects; ⇧ / ⌥ drags still draw.
- ⌘-drag floats the selected pixels and shows them at the pointer live;
  plain drags on the piece move it again; a press elsewhere drops it.
- A move cursor over the ants; the ants follow the dragged / floating piece.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:45:31 -04:00
107ffe9127 codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured (#642)
* codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured

Deep EXR files errored with "not supported: deep data not supported yet"
(the `exr` crate rejects deep headers, and its chunk framing for them is an
unfinished guess), so the chunk stream is walked directly from the bytes, per
the OpenEXR file-layout specification:

- offset tables after the headers; deep chunks of [y | tile coords, packed
  table size, packed data size, unpacked data size, compressed cumulative
  sample-count table, compressed sample data],
- NONE, RLE and ZIPS (zlib, the +128 difference predictor and the half-split
  interleave - applied to the count table and the sample data alike; a stored
  payload of uncompressed length is raw). ZIP-16 and mip/rip-map deep tiles
  decline with `unsupported`,
- samples are channel-grouped, per pixel; u32 counts cap the allocation and
  go through `Limits` before anything is held in memory.

`decode` composites to the flat image (samples sorted by Z, front-to-back
`over` with premultiplied colour, un-premultiplied to straight alpha) and
reports a new DecodeWarning::DeepFlattened; `decode_deep_exr` returns the
structured DeepImage (per-channel samples plus cumulative counts) for callers
that composite deep data themselves. exr::Error::NotSupported now maps to
CodecError::unsupported instead of malformed, like the TIFF codec.

Validated against the OpenEXR repository's deep test images (11/42/64/objectid
.deep.exr: 256x256, up to 8 samples/pixel, plausible z and alpha), and covered
by a synthetic generator (NONE/RLE/ZIPS x scanline/tile) with hand-computed
compositing oracles, truncation sweeps and hostile offsets/sizes (13 tests).

* Deep EXR: match the OpenEXR layout (RLE tokens and predictor, per-row tile tables and data), keep HDR

Checked against deep files written by the OpenEXR 3.5 library (scan line and tiled,
NONE/RLE/ZIPS): RLE tokens were inverted and skipped the predictor and byte split, the
count table of a tile restarts at every row, a tile's sample data is grouped per row, edge
tiles may store a full-size table, and the flattened colour was clamped to 1.0. The
synthetic writer in the tests now follows the same layout; new HDR / edge-tile test.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:45:02 -04:00
3f637410cb Apply the Interface UI Font Size preference (#616)
* Apply the Interface UI Font Size preference

* Regenerate the scorecard after rebase (58 unread settings); drop the before/after screenshots

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:43:03 -04:00
yangandClaude Opus 5.5 fee9ff9613 Find and Replace Text: no panic searching a non-type active layer (#703) (#762)
With allLayers: false the search keeps only the active layer; when that
layer isn't a type layer the list is empty, and the find loop indexed
layers[0] on it (find, change and changeFind). Read the layer with
get() and stop when there is none, so the command finds nothing and
returns {"found": null}, as it does when nothing matches.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:42:29 -04:00
Tanish MankuandClaude Opus 5.5 6103da5278 rustfmt xtask/src/i18n_coverage.rs (Format red on main since #663 + #700) (#798)
#663 and #700 each added one entry to the complete-catalog list in
source_key_set_is_covered_by_complete_catalogs. Each line passed rustfmt on
its own branch; together they exceed the width limit, so cargo fmt --check
fails on main and on every PR updated to main. Formatting only.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:39:39 -07:00
Jamie Pates 33c58b5ca0 Prevent "Export As" from saving webp files as "*.webp.psd" (#724) 2026-10-07 16:34:38 -04:00
9d46c8ea80 Arbitrary rotation starts at the ruler line's straightening angle (#660)
* fix(image): Arbitrary rotation starts at the ruler's straightening angle

Fixes #602

* Arbitrary rotation: ignore a non-finite ruler angle (from #659)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Alex Gorevski <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:33:52 -04:00
yangandClaude Opus 5.5 1e1f78325c type.edit: a reversed runs range is a bad-params error, not a panic (#714) (#754)
A run with end before start reached style_range with a > b, and the
styled run's length (b - a) underflowed: a panic in debug builds (the
dispatch guard turned it into a generic internal error), a huge stored
run length in release.

type.edit now checks every run before changing anything and rejects
end < start with a BadParams error naming the run, so the layer is left
untouched and no history step is recorded. style_range also treats a
reversed range as empty, so no other caller can underflow it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:33:23 -04:00
35ceaefce2 Fix #695: validate Layer Style effect fields (#729)
* Fix #695: validate Layer Style effect fields

Reject non-object effect parameter fields before preview or confirmation, render a visible validation message, and keep the canvas on the original document when preview input is invalid.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix(ui): keep global light preview fields compatible

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Layer Style: show an invalid-parameters error once

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:33:11 -04:00
135ae8cf96 Image Size keeps the canvas border opaque instead of fading it (#646)
Resampling read the pixels beyond a layer's content as the default pixel
(transparent for layers, white for masks). For content that reaches the
canvas edge, such as a Background or any full-canvas layer, the filter
taps past the border mixed that in, so after Image Size the outermost
rows and columns were translucent: a white Background enlarged 200 %
came out with alpha 162/255 in the corners and 203/255 along the edges.
Export As at any scale other than 100 %, and Generate Image Assets at
200 %, run Image Size first, so every such export had a translucent
frame. Channels, the quick mask and the selection faded the same way.

Image Size now resamples layers, masks, channels and the selection with
resize_surface_in_canvas: taps past the canvas edge (or past a layer's
own pixels where they extend beyond the canvas) read the nearest edge
sample. Inside the canvas nothing changes, so a layer away from the edge
keeps its soft border. resize_surface itself, used by transforms and
placing, is unchanged.

Tests: canvas_edges_repeat_instead_of_fading (algo: every filter, up and
down, 8, 16 and 32 bits; inner layers unchanged) and
image_size_keeps_canvas_edges_opaque (engine; fails before: corner alpha
0.635), which also checks a full selection stays full to the edge.

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:33:04 -04:00
06ec4044ea Improve Korean terminology and dynamic UI translation coverage (#604)
* Improve Korean Photoshop terminology and dynamic UI translations

* Keep untranslated form labels in sentence case; add the new strings to de and pt-br

label_of fell back to the Title Case filter label whenever the sentence-case label had no
translation, which changed English form labels ("Use antialias" -> "Use Antialias"). It now
uses the Title Case translation only when one exists. German and Brazilian Portuguese (merged
since this branch was opened) get "No patterns" and "Enable {name}".

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

---------

Co-authored-by: kijoong <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:32:47 -04:00
ac5338360f JPEG export no longer fails when the document's XMP is over 64 KB (#645)
A JPEG holds XMP in one APP1 segment, at most 65,533 bytes. Documents
from the source application often carry far more: their XMP lists every
document ever placed in them (DocumentAncestors, thousands of ids) and
the text of every type layer (TextLayers). The encoder passed the packet
through whole, the segment was refused, and the export failed with "App
segment exceeds maximum allowed data length of 65533". That stopped
every JPEG path: Export As, Save a Copy, convert, Layers and Artboards to
Files, Batch and Image Processor. 3 of 16 real designer documents in a
sweep hit it.

When the packet doesn't fit, the encoder now leaves out those two
layered-document properties (any namespace prefix), which a flat image
doesn't need; everything else in the packet is kept. If it still
doesn't fit, the XMP is left out, and so is an EXIF block over 64 KB.
Both cases are reported ahead of time by a new fidelity warning,
MetadataTooLarge, so the export shows what was dropped. Formats without
a segment limit (PNG, TIFF, WebP) still keep the packet whole.

Test: jpeg_with_oversized_metadata_still_encodes (fails before: encode
error), at 8, 16 and 32 bits.

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:32:26 -04:00
Alex GorevskiandCopilot 7a45adc913 Fix Hue/Saturation range drag bounds (#727)
Canonicalize imported bounds before rendering or moving range handles. Keep drag constraints ordered and cover both non-canonical and ordinary ranges.

Fixes #696

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:32:22 -04:00
12460d0345 Fix multi-file open dialog (#595) (#657)
* Fix multi-file open dialog (#595)

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Multi-file open test: write its fixtures under the temp dir

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:31:57 -04:00
9a448fe23b Type: a forced line break (U+0003) breaks the line instead of drawing a box (#644)
PSD type stores a line break inside a paragraph (Shift+Return) as U+0003,
and the layer text keeps it as is. The layout passed it to the line
breaker as an ordinary character: it isn't a break opportunity there, so
the line ran on, and the font has no glyph for it, so a missing-glyph box
was drawn in its place. Nothing showed until the text was re-laid out,
because an opened layer draws its saved pixels; any edit of the text, an
Image Size or an export at another scale then turned a two-line headline
into one long line with a box in the middle.

The layout now hands U+0003 to the line breaker as a newline (same byte
length, so every text offset stays put): the line ends, the next one
starts with the paragraph's leading and no paragraph spacing, and no
glyph is drawn. The text itself is unchanged, so PSD save writes it back.

Test: forced_line_break_starts_a_line_in_the_same_paragraph (fails
before: one line), covering point, centred and box text and rendering at
8, 16 and 32 bits.

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:31:33 -04:00
Alex GorevskiandCopilot 2050ca083e Fix #570: connect brush strokes on Shift-click (#654)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:31:15 -04:00
Armaan Sandhu 0aabb48687 document.pixel: no panic at i32::MAX, reject coordinates beyond i32 (#629)
Rect::from_xywh(i32::MAX, y, 1, 1) saturates to an empty rect, so the composite has no pixels and .px[0] panicked. Coordinates are also cast with as i32, so 2^32 silently read column 0. Read the coordinates with i32::try_from (BadParams when out of range) and return transparent for the empty rect, which is what any other off-canvas pixel reads as.
2026-10-07 16:30:52 -04:00
a62b6473f3 Transform modes: Skew / Distort / Perspective as real modes; legacy corners as a preference (#671)
- Edit › Transform › Skew, Distort and Perspective now set a mode on the box
  instead of just starting Free Transform: in Distort a corner moves alone
  with no keys held (Skew: edges skew; Perspective: corners move in mirrored
  pairs). While a box is up they switch its mode, as does a right-click on
  the canvas (Free Transform, Scale, Rotate, Skew, Distort, Perspective),
  with the current mode checked.
- Distort moves only corners (or the whole box from inside): dragging
  outside doesn't rotate, edge handles don't move, nothing snaps; the cursor
  shows when a drag would do nothing.
- Corner drags stay proportional by default (⇧ frees them). Preferences ›
  General › Use Legacy Free Transform (already a setting, hidden until now)
  swaps that: free by default, ⇧ keeps proportions.
- The handle under a press is hit-tested where the press was, before
  snapping, which could push it off a corner and move the whole box.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:28:17 -04:00
reese 2cdff3afb9 feat(ui): numeric fields evaluate typed arithmetic (#726)
Typing 1280*2 or 20*2+5-2 into a numeric field applies the result on
Enter, Tab or click-away (+ - * /, * and / first). Plain numbers still
apply as they're typed. Arithmetic is held until it's committed, so a
caller that rounds a partial result can't cut it short, and changed()
now means a new value rather than a keystroke.

The floating Properties card scopes its fields per layer, so text still
being typed for one layer can't commit to the next.

Fixes #601
2026-10-07 16:28:08 -04:00
Alex GorevskiandCopilot 681dee8c8f Fix type edit undo grouping (#652)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:21:49 -04:00
reese 6c4010cfc4 Color panel: click a chip to edit that color, double-click to open the Color Picker (#655) (#664)
* fix: Color panel chips pick the colour to edit and open the Color Picker on double-click

A click on the foreground or background chip makes the Color panel's field and hue
strip edit that colour; a double-click opens the Color Picker on it, with its RGB,
HSB, Lab, CMYK and hex fields. Each chip remembers its own hue for greys.

Fixes #655

* refactor(ui): keep the Color panel's edited chip in UiState

The chip the Color panel edits is UiState::color_panel.background, so the
control channel and MCP can read it and set it with
ui.set {colorPanel: {background}}. The double-click test now clicks where
the chips overlap, where the foreground chip takes the click.
2026-10-07 16:20:52 -04:00
9aef02cac3 macOS: menus open on press; only the title bar's free gap drags the window (#669)
Menus:
- Titles open their menu on the press and never toggle on the release (no
  blink), so one gesture can press a title, drag to an item and release on
  it to run it. A plain click still opens the menu and leaves it open;
  pressing an open title closes it.

Title bar:
- The window draws under a transparent title bar, so AppKit dragged it from
  anywhere in that strip, menus included. AppKit's own title-bar dragging is
  off (NSWindow.movable = NO via safe objc2 calls, no unsafe; objc2 crates
  pinned to the versions already in Cargo.lock), and the app's drag region
  is only the free gap between the menus and the right-hand controls.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:18:14 -04:00
782ea69f35 fix(linux): AppImage registers its desktop entry and icon on launch (#617)
* fix(linux): AppImage registers its desktop entry and icon on launch

On Wayland the compositor resolves the dock icon from the window's app_id
(ai.storyteller.photocraft, already set) against a host-installed .desktop
file. An AppImage installs nothing, so every run showed the generic Wayland
logo (#593). The app cannot set the icon itself: winit 0.30.13 (egui 0.36.2's
pin) has an empty set_window_icon on Wayland; xdg-toplevel-icon-v1 landed on
winit's 0.31 line only.

- packaging/linux/AppRun is a script instead of a symlink: best-effort
  integration into $XDG_DATA_HOME before launch (desktop entry with Exec
  rewritten to the AppImage's path and TryExec dropped, hicolor icons
  copied), content-compared so an unchanged install costs a few cmp calls,
  refreshed when the AppImage moves, PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1
  opts out, and no failure can block the launch (best-effort steps silent).
- packaging/linux/apprun-test.sh: 8-case smoke (passthrough, Exec rewrite
  with spaces, metadata preserved, idempotence, move refresh, opt-out,
  unwritable data dir, extracted AppDir). packaging-lint runs it and now
  shellchecks AppRun.
- Docs: releasing.md Linux section; README install paragraph.

Fixes #593

* AppRun: skip desktop integration when the AppImage path needs Exec escaping

A quote, backquote, dollar, backslash, percent or line break in the path would need
desktop-entry escaping (a line break could even add keys), so integration is skipped
and the app still launches.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:12:00 -04:00
8d8ea584b8 Brazilian Portuguese UI translation (pt-br) (#700)
* Brazilian Portuguese UI translation (pt-br)

Add a pt-br catalog covering every key in the existing catalogs (2,492
entries), register it as "Português (Brasil)" with Portuguese plural
rules, and resolve pt, pt-BR and pt-PT locales to it. i18n coverage
1404/1404.

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

* Keyboard Shortcuts dialog: show menus and commands in the UI language

The command list showed the raw English menu paths and labels while the
rest of the dialog was translated. Translate them the way the menus do
(tr / tr_id); search matches the translated and the English names, like
the command palette. Also rustfmt the pt-br registry row.

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

* pt-br: gate it in xtask i18n-coverage (from #637, thanks @RicSchonfelder); licence note covers German too

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: RicSchonfelder <[email protected]>
2026-10-07 16:11:38 -04:00
Matt Van Horn 7f486d723f Add Layer Remove Background command (#734)
Isolates the subject of a pixel layer in one undoable step: a layer mask
by default, with optional transparency bake and a solid fill underneath.
The command is available from Properties Quick Actions, the Layer menu,
the CLI, and the control channel.
2026-10-07 16:09:46 -04:00
reese 3bea3f31c5 fix(transform): picking another tool applies an open transform (#732)
Fixes #670.
2026-10-07 16:08:39 -04:00
3a3984075a Surface Blur: 13x to 40x+ faster on 8-bit layers (sliding histogram) (#631)
* Surface Blur: sliding 8-bit histogram instead of the full window sum

Surface Blur summed the whole (2r+1)^2 window for every pixel and channel, so radius 100 read about 40,000 samples per pixel. For 8-bit layers (every colour sample k / 255) keep a 256-bin histogram per channel that slides along the row, one column out and one in per step, and weight only the levels within the threshold, with the same weight expression as the direct sum. 16-bit and float layers, and radii where the direct sum is cheaper, keep the direct sum (now surface_direct).

* Surface Blur test: as_chunks_mut (clippy)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:06:23 -04:00
Armaan Sandhu 09b6572578 Minimum/Maximum (Squareness): running extremes, bit-identical (#630)
The square Minimum and Maximum scanned 2r+1 samples per pixel on each axis, so the cost grew with the radius (0.2..500). Preserve: Roundness already uses the van Herk / Gil-Werman running_extreme in the same file; use it for both passes of the square too. A min or max picks one of the same samples whatever the order, so the output is bit-identical; a new test checks it against the direct window.
2026-10-07 16:05:38 -04:00
43c86ef645 Layers panel: drag down the eyes to show or hide layers; empty eye when hidden (#666)
- The eye column takes drags: pressing an eye toggles that layer and every
  row the pointer then sweeps over gets the same visibility (each row once
  per drag). A drag that starts on an eye no longer reorders the layers.
- A hidden layer's eye box is left empty instead of drawing a struck-out
  eye; it is still clickable.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:03:21 -04:00
Armaan Sandhu 9270a31f24 Box Blur: running sums, so large radii cost the same as small ones (#628)
Box Blur ran a direct separable kernel of 2r+1 taps per pixel per axis, so its cost grew linearly with the radius (1..2000). Gaussian Blur already blurs with running-sum box passes (gaussian_boxes); generalise that into box_passes(widths) and use it for Box Blur radii above 4, keeping the direct kernel for radius 1..4 where it is quicker and output stays as before.
2026-10-07 16:00:29 -04:00
Alex GorevskiandCopilot 0c40ec17c6 fix(color): match Photoshop blend corner values (#699)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:00:27 -04:00
Ian Swope fe4243ba1f Text fields stop resetting the input method every frame, so keys no longer repeat endlessly on Wayland (#585) (#723)
The command palette and the channel rename field called request_focus on
every frame. egui interrupts IME composition on each request, so egui-winit
disabled and re-enabled zwp_text_input_v3 every frame. With an input method
such as fcitx5 the compositor regrabbed the keyboard each time, a key release
landing in the gap was lost, and winit's client-side repeat never stopped.

Both fields now request focus only when they do not have it, matching the
other rename fields. Tests assert the IME is not interrupted after the first
frame.
2026-10-07 15:59:51 -04:00
d5923e49db layer.showOnly: ⌥-click a layer's eye to show only that layer (#665)
New engine command `layer.showOnly {"layer"}`: hides every other layer
(keeping the layer's enclosing groups visible); run again on the same
layer, it restores every layer's previous visibility, layers that were
already hidden included. ⌥-clicking another eye while one layer is shown
alone moves the solo and keeps the original snapshot. The snapshot is
per-document view state (`DocState::show_only`), not history.

The Layers panel sends ⌥-clicks on an eye to it; plain clicks still toggle.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 15:59:29 -04:00
Elvis33LE fc00f1e5bc scorecard: BUG-203 (atomic saves) is done - flip the stale rows (#203) (#687)
The BUG-203-1..6 and REL-4 rows still say missing, but the atomic-save
work landed in #230 (8227071, 2026-10-05) - apparently after the 4b53f62
snapshot these statuses were verified against. Re-verified against the
current tree:

- one shared photocraft_format::atomic_write (same-dir temp, create_new +
  write_all + sync_all, rename, Unix directory fsync, Windows
  PermissionDenied rename retry with backoff), and every document write
  site routes through it (engine file_cmds/preset_store, automation
  files/headless/workspace, app services, format store/autosave);
- no in-place fallback exists; read-only dir, disk-full and cross-device
  errors name the cause and state the original was not changed;
- format/src/atomic/tests.rs failure-injects mid-write, rename and
  read-only-dir errors (11 tests; re-run for this change: green).

The regenerated docs/scorecard.md moves Reliability to 4/1/2 and Open
bugs to 12/3/17. No code changes.
2026-10-07 15:49:13 -04:00
Mawfyy 09f21d99ca fix(ui): the Enter that opens a dialog from the search palette no longer closes it (#640)
Selecting "Keyboard Shortcuts" in Edit > Search did nothing (#536): palette::show
and dialogs::show run in the same frame (lib.rs:959-960), so the dialog's OK footer
(dialogs.rs:183) read the very Enter that launched it as "confirm" and closed the
dialog before it was ever visible. Every palette-launched dialog hit this when
opened with Enter; the mouse path was unaffected.

The palette now consumes the Enter it acts on (input.consume_key, the same idiom
menu_nav already uses), so dialogs::show sees no press in that frame. A second Enter
still confirms and closes the dialog, which the regression test also asserts.

Test: palette_opens_the_keyboard_shortcuts_dialog drives the real UI harness
(search -> type -> Enter -> dialog open, with and without a document). Full ui-egui
suite 677 pass; clippy -D warnings; fmt; wasm32 check of ui-egui.

Fixes #536
2026-10-07 15:49:11 -04:00
e79dcb6f7f i18n: add German (de) UI translation (#309) (#663)
* i18n: add German (de) UI translation (#309)

A complete German catalog plus the registry row:

- de.tsv: the full key set (menus, tl! literals, blend-mode names, Camera
  Raw labels, generated preference labels), plural rule one|other, header
  documenting the format and the clean-room policy like ja.tsv.
- Registered with complete_menus: true; the locale resolver and the
  language-list tests gain de (de-DE/de-AT/de-CH resolve to it).
- ATTRIBUTION.md row and the docs/localization.md language table entry.

Translations are original work from the plain meaning of the English
labels in ordinary German image-editing vocabulary (Ebene, Maske,
Auswahl, Weichzeichnen, Tonwertkorrektur, Smart-Objekt ...); no other
product's localisation resources were consulted. Product and technology
names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab, Camera Raw) stay in
Latin letters; the Camera Raw curve bands are distinct (Schwarz, Tiefen,
Dunkle, Helle, Lichter, Weiss).

Verified: catalog tests and preference-label tests green,
cargo xtask i18n-coverage reports de 1404/1404 (100%), the full
ui-egui suite passes (except the known machine-local wgpu driver
failure that also fails on clean main), clippy and wasm32 clean, and
an offscreen snapshot sequence switches de -> en -> de in one process
and ends fully German.

* de: translate Metadata (Export As gains the label in the #647 fix)

* Revert "de: translate Metadata (Export As gains the label in the #647 fix)"

This reverts commit 948a8d01dd83579a899d938611101daa50a9e1e1.

* de: apply the native-speaker review (#663)

All meaning errors from the review: Auto Tone is the tonal range
(Automatische Tonwertkorrektur), the Divide blend mode is arithmetic
(Dividieren), Note Paper is Notizpapier, Legal is the US paper format,
Fuzziness widens the colour tolerance (Toleranz:), Perspective Warp
planes are Flaechen (not Ebenen, which reads as layers), Revert is
Zuruecksetzen (matching its confirm text), Fade is Verblassen.
Typos: Tonwerthistogramm, Magischer-Radiergummi-Werkzeug,
Einfrier-Lasso. Consistency: Kanalmixer everywhere, Zoom In/Out as
Vergrössern/Verkleinern, a space before % in the scale labels.

* xtask i18n-coverage: gate the German catalog too

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:48:02 -04:00
Alex GorevskiandCopilot e70deecbe5 fix(cms): check LUT declared sizes before arithmetic (#725)
Fixes #689

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 15:41:09 -04:00
才倾梦华V 9e6491e5f5 Keep Select Linked Layers within the linked groups (#764) 2026-10-07 15:41:01 -04:00
才倾梦华V 03b901cdde Persist migrated presets with preferences (#728)
* Persist migrated presets with preferences

* Check only imported entries after preset reload
2026-10-07 15:38:29 -04:00
Brandon ThomasandClaude Opus 5.5 d7df9d8aa6 Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#766)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:40:36 -04:00
47f9306fd0 Open HEIC/HEIF photos (#355) (#372)
* Open HEIC/HEIF photos (#355)

iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no
HEIF code, so .heic was missing from File > Open and failed to decode.

Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust,
forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and
grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha,
ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF
Orientation is rewritten to 1, so photos are never turned twice. Limits
are checked against the declared size before decoding. Read-only (no
pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS.

File > Open (desktop, web) and the batch list accept heic/heif/hif.
Tests compare against Apple's own decode and a 16-bit source, cover
orientation, metadata, limits and every truncation; fixtures from
heic-rs and pillow-heif are attributed in ATTRIBUTION.md.

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

* HEIF: pin heic-rs exactly and add a decode_heif fuzz target

Review follow-up: heic-rs is young and parses untrusted files, so pin it
(=0.1.1) and fuzz the HEIF path. The target covers both orientation
paths and is meant to run with -timeout so a hang is reported, not
only a panic. It is added to the CI fuzz matrix.

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

* HEIF: a heic-rs panic is a malformed-file error, not a crash

Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on
malformed files:
- in its box parser (boxes.rs:130), reachable from a 72-byte file, even
  through the metadata-only probe;
- during HEVC reconstruction (hevc/decode/recon.rs:70).

The background job path catches panics, but the synchronous ones (jobs
off, the CLI) did not. The HEIF codec now runs the decoder under
catch_unwind and reports a panic as CodecError::Malformed, as
gpu/health.rs already does around wgpu.

The fuzz target also calls heic-rs directly, so its panics still reach
the fuzzer. The 72-byte input is a regression test, which fails
without the guard.

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

* HEIF: move the decoder into an optional photocraft-heif crate

The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends
only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit,
ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded,
Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and
photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the
feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't
included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif
between standalone crates.

The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs
(tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against
xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with
`--features corpus,heif` (`cargo xtask test-corpus` enables both).

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

* Build CI and official binaries with HEIC support (--features heif)

CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and
web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so
the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with
`--features heif`.

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

* Docs: HEIC open is an optional build feature, on in official builds

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 10:20:40 -04:00
Brandon ThomasandClaude Opus 5.5 7055f0c51b README: remove a stray "ArtCraft" line before Star history (#636)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:12:51 -04:00
Brandon ThomasandClaude Opus 5.5 2cd470c08e monitor_profile test: Unix only (it runs /bin/sleep and /bin/sh); fixes Windows CI after #591 (#633)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:59:59 -04:00
b07a2e5b43 Remember desktop window and panel sizes across restarts (#587)
* Remember desktop window and panel sizes across restarts

* Window layout: discard a saved ui.ron that would crash startup

eframe restores the window geometry and egui memory before the app runs, and a
zoom factor of 0, NaN or 1e30, or a NaN/huge window size aborted every launch
(dpi/wgpu assertions). Check the file first and remove it if it holds such values.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:12:44 -04:00
Cristian Moroaica b9a24da799 Apply Image onto a transparent layer no longer turns it black (#607)
The blend ran against the colour stored in transparent pixels (black),
so Multiply and the other darkening modes filled an empty layer with
opaque black. The blend now acts only where the layer has pixels, and
the alpha grows by source-over, so an empty layer takes the source in
any mode. Opaque pixels blend exactly as before.
2026-10-07 09:12:11 -04:00
Elvis33LE 09bc2ac0c4 algo: fix flaky cancel/progress test - tile workers report out of order (#609)
`apply_tiled_with` filters tiles with `chunk.par_iter()` and each worker
reports progress (`0.98 * n / total`) from its own thread. The values are
monotonic in the finish counter, but delivery order is not, and the test's
callback did a non-atomic load->assert->store of the last value: when a
worker holding a smaller value was preempted past another worker's store,
the assert fired. Observed once in a full `cargo test --workspace` run;
reproduced 1-in-300 under synthetic CPU load.

`Interrupt` never promised ordered delivery and its engine consumer
already clamps (`JobCtx::progress` keeps `fetch_max` of the f32 bits, see
crates/engine/src/jobs.rs). The test now does the same: fetch_max high-water
mark plus an in-range assert, and requires the mark to reach exactly 1.0.
Renamed to `..._progress_never_regresses` to say what it actually checks.
Stress after the fix: 0 failures in 300 runs.

The identical-looking asserts in content_aware.rs and io/tests/cancel.rs
stay strict: those paths report progress from a single thread.
2026-10-07 09:11:45 -04:00
e77325bd4a Add fixed UI scale steps from 75% to 300% (#611)
* add 75-300% fixed ui scale steps

* UI scale: French and Indonesian labels for the new steps

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

* i18n(ko): UI scale steps

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:11:35 -04:00
d4086ba11a feat(i18n): add Korean and live language switching (#582)
* feat(i18n): add Korean and live language switching

* i18n(ko): Edit Type (#598)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:06:33 -04:00
45bedb0962 Monitor profile auto follows each window's display; what is applied is visible (#569) (#591)
* Monitor profile: say which profile is in use and why; apply a late display reading (#569)

Color Settings › Monitor Profile used to fall back to sRGB without a
word when the display's profile was missing, unreadable, not RGB or
unusable as a destination, and dropped a macOS reading that took longer
than 2 s. `ColorState::monitor_status` now reports the profile actually
applied, its fingerprint, the source (auto / manual / fallback) and the
reason for a fallback. `edit.colorSettings` (`monitorStatus`),
Help › System Info and the Color Settings dialog show it, and a fallback
in auto posts a notice.

The macOS reader also names the display it read. On a dual-display Mac
it reports the menu-bar display ("Built-in Retina Display") while the
canvas is on the external monitor, which is the display-selection bug
in #569; following the window's display is the next step.

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

* Monitor profile auto: each window uses the profile of the display it is on (#569)

The macOS reader now lists every display (id, name, frame, ICC profile
and its System Settings name) instead of asking for the "main screen",
which from a windowless helper is the menu-bar display. Each window
picks the display it overlaps most, as NSWindow.screen does, and its
canvas is shown in that display's profile: moving a window to another
display switches the profile without a restart, and two windows of one
document on different displays each keep their own. The GPU canvas
shares the document texture and keeps a display LUT per (document,
display); CPU textures are per (document, display).

Displays are read again when PhotoCraft comes back to the front (a
profile changed in System Settings or by a calibration tool), and when
a window is on an unknown display or one whose size changed (connected,
removed, rearranged), at most every 5 s. A failed re-read keeps the
previous reading. Help › System Info lists each display's profile.

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

* Monitor profile auto: act on the r1 review; no periodic re-read (#569)

Following the owner's decision on echo round r1:

- No periodic discovery: a read costs about 0.2 s of CPU, and a profile
  reassigned while PhotoCraft stays in front with no window moving
  practically doesn't happen. It is picked up at the next return to the
  front, or when Edit › Color Settings opens, which now reads at once.
- Every document window, not only the main one, asks for a reading when
  it is on an unknown display or one whose size changed.
- The focus re-read is throttled to one per 30 s; a trigger inside that
  window is deferred, not dropped.
- Starting a reading schedules the frame that applies its result, so an
  idle window doesn't wait for unrelated input.
- The osascript helper is stopped after 10 s (killed and reaped, both
  pipes drained meanwhile), so a hung reading can't block later ones.
- The Color Settings dialog recomputes "Monitor profile in use" as it
  draws, and the status says when an earlier reading is kept after a
  failed re-read (detection `retained`).

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

* Monitor profile auto: document windows count for focus and wake the main window (#569)

Echo round r2 on #591:

- Coming back to PhotoCraft through a document window (macOS reports
  focus per window, so the main window stays unfocused) now counts as a
  return to the front and reads the displays again, within the 30 s
  throttle.
- A document window that is on an unknown or resized display, or that
  brings PhotoCraft back to the front, asks the main window for a frame:
  the reading starts there, so it no longer waits for unrelated input.
  One frame per trigger, no continuous repaints.

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

* Monitor profile: pass the notice dismiss preference added on main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 09:02:58 -04:00
3d23a708c2 Edit type layers by double-clicking their thumbnails (#598)
* Edit type layers by double-clicking their thumbnails

* Translate Edit Type for French and Indonesian

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:01:39 -04:00
才倾梦华V 8ebca04aa9 Add Edit Contents to Smart Object context menu (#599) 2026-10-07 09:01:20 -04:00
fa0defc56b Add style warp splits and grid presets (#388)
* Add warp splits and grid presets

* Translate the warp grid labels

* Satisfy clippy on the warp section-edge check

* Address the warp review notes

* Format the warp handle arm

* i18n: French and Indonesian warp grid labels

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 08:40:00 -04:00
90df9cfc15 Report per-language UI translation coverage (#220) (#427)
* feat(xtask): report UI translation coverage

Add a deterministic per-language coverage report backed by a checked English key inventory. Reject malformed, duplicate, and unknown catalog keys, and document the invocation.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* fix(xtask): derive i18n coverage keys from source

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* fix(xtask): handle Windows line endings in i18n scan

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Scorecard: DIST-220-4 is partial now that xtask reports translation coverage

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

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:54:05 -04:00
786a863f9c i18n: "id" indonesian translation (#449)
* "id" Indonesian

* extra space for fomat (ubuntu-latest)

* Update id.tsv

* Update mod.rs

* i18n(id): translate the labels added on main since this catalog; Mixer Brush terms

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:52:51 -04:00
a4e96991f1 i18n: add French (fr) UI translation (#410)
* i18n: add French (fr) UI translation

A complete French catalog (menus, dialogs, panels, plurals), registered with its
plural rule (0 and 1 take the singular). Covers the latest commands, Stamp Visible
and Stamp Down included. A test checks the bundled UI fonts carry every French glyph.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* i18n(fr): translate labels added on main since this PR (from #582 by neko233-com)

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

* i18n(fr): translate the labels added on main since this catalog

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

* i18n(fr): Camera Raw scopes and the Patch tool

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: neko233-com <[email protected]>
2026-10-07 07:49:30 -04:00
b45dfde498 i18n: complete Simplified Chinese catalog and localize brush sections (#359)
* i18n: complete Simplified Chinese UI catalog and brush labels

* i18n(zh-hans): translate labels added on main since this PR (opening/cancel, layer navigation)

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

* i18n(zh-hans): keep main's Mixer Brush entries, translate the rendering-mode strings added since (#434)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:46:52 -04:00
b5eed81a25 Start context-menu parity across canvas, paths, and document tabs (#556)
* Add blend modes to Gradient tool

* Establish context menu parity contract and first workflows

* Align Select menu actions and style selection context rows

* Add Pen canvas Make Selection context action

* Complete Pen path context menu and symmetry actions

* Select menu: no extra top-level Feather (keep Select > Modify > Feather only, for 1:1 menu parity)

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

* Regenerate docs/scorecard.md and docs/parity.md

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

* Regenerate docs/scorecard.md and docs/parity.md

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:45:26 -04:00
Sean Lavery 8aecbffb5c Add blend modes to Gradient tool (#543) 2026-10-07 07:43:36 -04:00
b4f9d56c93 Add the Patch tool with a live preview (#213) (#381)
* Add the Patch tool (#213)

paint.patch heals the selected area with texture from where the
selection is dragged (Source) or copies the selection onto the
dragged-to area (Destination), seamless-cloned like the Healing Brush.
The solve is clipped to the canvas, so patches at the edge stay opaque.

Tool::Patch joins the J group: outside the selection it draws a lasso
(Shift/Alt add/subtract), inside it drags the patch, the outline follows
the pointer and the offset is clamped to the canvas. Options bar:
Source / Destination. Strings translated in all six catalogs.

Tests at 8/16/32 bit, a UI gesture test, panic_hunt green. Scorecard
TOOL-213-3 is partial: the drag previews the outline, not the pixels.
retouch_bench: patch 250x250 22 ms, 1000x1000 362 ms (release).

generated with Claude Code

* Patch tool: live preview of the heal while dragging (#213)

The canvas shows the document healed at the pointer's offset
(patch_preview.rs, modelled on the Move tool's preview) and
recomposites only the healed area; release commits the exact
paint.patch and its damage rect refreshes the same area.

The preview (retouch_cmds::patch_preview) solves the colour fit on a
coarse grid (at most 128x128 cells) and interpolates it back: the
Poisson result is source + a harmonic correction, so the texture stays
exact and only the smooth correction is approximated (worst pixel 0.022
at 8x coarser in the test). Selection coverage is read tile-wise and
the interpolation runs per row in parallel.

retouch_bench (release, 6016x6016): preview 250x250 7 ms, 500x500
17 ms, 1000x1000 52 ms (exact patch: 21 / 90 / 354 ms). Scorecard
TOOL-213-3 done. Also applies rustfmt to the first commit's code.

generated with Claude Code

* Patch tool: cap patches at 16M pixels, drop product names from comments (#213)

The solve needs roughly 150 bytes per pixel, so the old 64M cap allowed
about 10 GB and risked an allocation abort instead of an error.

generated with Claude Code

* Regenerate docs/scorecard.md

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

* Tool::ALL holds both the Patch tool and the Mixer Brush (rebase onto #433)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:42:30 -04:00
cc5de414dc Camera Raw: interactive histogram, clipping warnings and scopes (part of #215) (#407)
* Camera Raw: interactive histogram, clipping warnings and scopes (part of #215)

- RGB histogram of the preview proxy (algo::histogram), drawn as RGB ribbons;
  Before/After, five draggable tonal zones, shadow/highlight clipping warnings
  (U/O) and Alt-drag channel diagnostics.
- Pixel hover readout, up to nine RGB/Lab colour samplers (S), an ICC-managed
  vectorscope (algo::vectorscope) with skin-tone line, and a floating scope panel.
- Settings regrouped as Light, Color, Effects, Curve, Color Mixer, Color Grading
  and Detail; Camera Raw labels translated through the cameraRaw context.
- One point-curve editor (point_curve.rs) shared by Camera Raw, the Curves dialog
  and adjustment-layer Properties; fixes Camera Raw curve points not dragging.
- Double-clicking a Camera Raw smart filter reopens the Camera Raw dialog with its
  settings and updates that filter, instead of the generic parameter form.
- New point curves are validated (2-16 points, increasing inputs); stored Smart
  Filters keep loading.
- camera_raw_bench for P31/P36 (not enforced until a baseline includes them).

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

* Histogram tests: as_chunks for triangle indices (clippy 1.99)

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

* Regenerate docs/scorecard.md after rebasing onto main

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

* i18n tests: close the Mixer Brush test before the Camera Raw one (rebase)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 07:40:55 -04:00
ljkxandClaude Opus 5.5 a8afec4455 Open documents in bounded reads, with a clear out-of-memory error (#375) (#401)
Every open path read the whole file with std::fs::read, which asks
Windows for up to 4 GiB in a single ReadFile. Some file systems (exFAT,
network redirectors) reject a read that large with
ERROR_INVALID_PARAMETER, "The parameter is incorrect" (os error 87),
as reported for a 4.75 GB PSB in #375.

Add photocraft_format::read_file and read_all beside atomic_write:
64 MiB reads into a buffer reserved up front, and an OutOfMemory error
that says how much the file needed. Route every document read through
them:
- the background open job (engine jobs.rs read_path);
- the engine's read_file;
- Open Recent and OS open events;
- desktop drag and drop, which now goes through open_path (and so
  through the background job when jobs are on);
- the File > Open dialog;
- .pcraft loading;
- the automation workspace read and automation::files::open;
- smart object Replace Contents and linked files.

The dialog no longer fails silently: PickOpenFn returns the picked name
with the read result, and a failure is reported through open_failed and
the notices like any other open failure.

Speed is unchanged (1.11 s vs 1.13 s for a warm 4.75 GB file).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:40:35 -04:00
fbe5f80ba4 PSD: read smart-filter masks (FEid) with one section per smart object (#396)
* PSD: read smart-filter masks (FEid) with one section per smart object

Photoshop writes the global Filter Effects block (`FEid`/`FXid`) as a
version followed by one length-prefixed section per filtered smart
object, each padded to 4 bytes outside its length. The reader took the
first section's length as the whole body, so any document with two or
more filtered smart objects failed with "filter effects: N unexpected
bytes after the data": the smart filter masks were not imported and the
open reported a warning. The 28 FEid files in the public corpora each
hold a single section, which is why the corpus never hit it.

The reader now walks every section (still accepting several items in one
section, as earlier PhotoCraft versions wrote), and the writer emits one
padded section per item, as Photoshop does.

Tests: reads_one_section_per_item (fails before: parse error) and
reads_several_items_in_one_section; malformed_data_is_an_error now cuts
only before the end of the section.

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

* PSD FEid: an empty item list still writes one section, so it reads back

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

---------

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 07:40:10 -04:00
7dc1575efc Content-Aware Scale: 13x faster seam removal, bit-identical output (part of #211) (#385)
* Content-Aware Scale: 13x faster seam removal, bit-identical output (part of #211)

Every removed seam recomputed the whole energy map, ran the seam search with an allocation per seam and copied the whole image into a new buffer, all on one thread: about 320 ms per seam on 24 MP. Now the image, protection and energy buffers keep their row stride and a seam is removed by shifting the rest of each row in place (in parallel); only the pixels next to the removed seam get their energy recomputed, with the same per-pixel arithmetic; the seam search reuses its buffers and its interior loop has no edge tests, so it vectorizes. Ties are broken exactly as before.

A new test pins the output bits of eight carves (shrink, enlarge, both axes, protection, 1/3/4 channels, tie-heavy noise), taken from the previous implementation.

* Content-Aware Scale: oracle test against the previous carver; keep the 1 px enlarge fix

carver_matches_reference compares the new carver bit for bit with the
implementation it replaced (now kept in the tests) over 400 random carves and
200 seam searches and energy maps. The rebase keeps main's #387 fix: a 1 px
wide image's one seam is taken so it can be duplicated.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:39:49 -04:00
0cb27712c8 Paste: a copied image file pastes its pixels (part of #338) (#384)
* Paste: a copied image file pastes its pixels (part of #338)

Copying a file in GNOME Files, Finder or Explorer puts its path on the clipboard, not pixels, so Edit > Paste found no image and said the clipboard was empty. When the clipboard has no image, the native clipboard service now reads arboard's file list and pastes the first file that decodes (upright, as RGBA8), skipping non-images after reading only their header. TGA, which has no magic number, also needs its extension.

arboard splits text/uri-list on LF only, but the list is CRLF-terminated (RFC 2483) and GTK writes it that way, so paths copied in GNOME Files arrive with a trailing '\r'; it is trimmed before opening.

* Paste: copied files before the clipboard image; require arboard 3.6

Finder puts a copied file's icon on the clipboard as an image next to
the file URL, so reading the image first pasted the icon on macOS. The
file list is now read first and the plain image is the fallback.
file_list() needs arboard 3.5 or later, so the requirement says 3.6
(what the lockfile already has).

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

---------

Co-authored-by: bflatastic <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:39:30 -04:00
Alex GorevskiandCopilot bf545e0e81 Hide unconfigured layer effects from panel (#559)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 8fbf5558-5421-4e91-8c33-89a347bb27da
2026-10-07 07:14:04 -04:00
08e3e610c0 Preferences › Interface: Show bounding box when dragging layer (#484)
* Preferences › Interface: Show bounding box when dragging layer

New pref `interface.showBoundingBoxWhenDraggingLayer` (default off).
On: a Move drag shows the layer's outline and an arrow; the pixels stay
put until release. Off: the layer follows the pointer live with no
outline (Photoshop's behaviour). Label translated in all six catalogs.

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

* Keep the drag outline by default; the preference only turns off the live pixels

Off (the default) keeps today's Move drag: pixels follow the pointer and
the moved layers' outline is drawn. On, only the outline and arrow move.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 07:12:58 -04:00
047aeb747a Wayland: explain native file drag-and-drop limitation (#386) (#430)
* Wayland: add persistent file drop guidance (#386)

Co-authored-by: Copilot <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix Wayland file-drop guidance review

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* fix(app): pass no dismissal preference for portable notice

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Pass no dismissal preference for the prefs save-failure notice

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

* notices::post takes the dismiss preference: pass None for the GPU retry notice (#434)

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

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:12:25 -04:00
50d52a0848 Clarify integer and float bit-depth labels (#324) (#428)
* Clarify integer and float bit-depth labels

Add type-specific labels to New Document and Image Mode with translations and focused UI/catalog tests.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix bit-depth labels and tooltips

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Keep the reference menu names for the depth commands and preference

The Image > Mode items keep their menu-catalogue labels, so the engine
labels (command palette, MCP, Keyboard Shortcuts) and the preference
choice stay "8/16/32 Bits/Channel" to match; the integer / floating point
hint comes from the new tooltips.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:09:51 -04:00
Alex GorevskiandCopilot 2c02d41eaf Show active ICC profile in the status bar (#321) (#432)
* Show active ICC profile in status bar (#321)

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix active profile status review feedback

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Copilot <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:08:51 -04:00
d07eecf66f PSD: read/write the artboard lock at Photoshop's lspf bit 3 (#395) (#406)
* PSD: read/write the artboard lock at Photoshop's lspf bit 3 (#395)

Photoshop writes layer locks with the artboard bit at 0x08 (a Background
layer carries 0x0D). PhotoCraft read it at 0x10, so 0x0D came back as
0x05 and the lock bytes drifted on every re-save. Map artboard to bit 3
on both read and write; still accept 0x10 on read so older PhotoCraft
files keep their lock, rewriting it at 0x08.

Co-Authored-By: Qwen 3.8 Flash <[email protected]>

* style(io): fix PSD artboard lock review comments

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Qwen 3.8 Flash <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:08:00 -04:00
Sean Lavery 395a52e7f1 Focus PhotoCraft when unsaved-close prompt appears (#547) 2026-10-07 07:07:00 -04:00
45d0f4ad93 docs: sync scorecard with implemented behavior (#348)
* docs: sync scorecard with implemented behavior

* Scorecard: cite symbols instead of line numbers in the updated bug notes

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:06:21 -04:00
floflo777 6422219226 Control protocol docs: file.open and file.save are refused over --control (#391)
Two passages said they worked like app.open/app.save. Say what the policy
really refuses, and pin those cases in its test.
2026-10-07 07:05:38 -04:00
d91ab25772 Expose the Mixer Brush as a UI tool (#213) (#433)
* feat(ui): expose Mixer Brush tool (#213)

Wire the existing Mixer Brush command into the selectable UI tool model, options bar, and control-driven painting path. Cover shortcut cycling, options persistence, and selection clipping.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* docs(scorecard): mark Mixer Brush slice complete (#213)

Reflect the new Mixer Brush toolbar, options, control dispatch, and selection tests in TOOL-213-2 and regenerate the scorecard.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* fix(i18n): translate Mixer Brush labels in zh-Hans

Add Simplified Chinese entries for the Mixer Brush tool, its options, and the brush-settings heading. Test all Mixer Brush UI strings against every registered language catalog.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Polish Mixer Brush UI controls

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:04:31 -04:00
bd7d47bf0f Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for … (#453)
* Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for visibility

* Liquify Freeze Lasso: scanline fill, tests, one translated tooltip, theme colours

- apply_lasso fills node rows with an even-odd scanline pass (O(rows x edges) plus the nodes
  written) instead of a point-in-polygon test per node; non-finite points are skipped and huge
  or off-canvas polygons clamp to the field.
- LassoMask joins LiquifyTool::ALL, so the strip draws its button in the loop; its tooltip is
  one translated string (all catalogs); the outline uses theme tokens.
- A closed lasso clears Redo (#435).
- filter.liquify params doc lists lassoMask.
- Tests: concave polygon vs. a reference even-odd test at several cell sizes, thaw, degenerate
  and hostile polygons, replay; the UI lasso records a stroke, Alt thaws, undo/redo.

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

---------

Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:03:49 -04:00
48bd08150e feat(ui): add bounded Liquify redo history (#435)
* feat(ui): add bounded Liquify redo history

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 06:59:03 -04:00
5856866f89 Smart objects keep their four-corner (Distort / Perspective) placement (#426)
* Smart objects keep their four-corner (Distort / Perspective) placement

A smart object's placement was an affine transform, so a perspective
placement lost its fourth corner: a mockup PSD's distorted screen looked
right on open (Photoshop's cached pixels) but re-rendered as a
parallelogram after Edit Contents → Save.

- SmartObject gains `perspective`, the full projective map (source px →
  document px); `transform` keeps its affine approximation
- Re-renders go through it (place_source_projective), as do moves,
  canvas rotations and edit.transform, so Distort / Perspective now work
  on smart objects instead of being refused
- PSD import reads the quad (nonAffineTransform / Trnf) and export writes
  it back; .pcraft stores the field (absent for affine placements)
- Test: a distorted smart object survives Edit Contents → Save

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Smart objects: warp maps through a distorted placement; perspective round-trip tests

Warp handles and stored warps on a Distort / Perspective smart object go through its full
projective placement (smart_cmds::placement) instead of the affine approximation, so they sit
on the distorted corners. Tests: SoLd perspective round trip and quad_moved (io), a perspective
smart layer in the .pcraft rich_doc, warp on a distorted smart object (engine). Comments no
longer name another product.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 06:58:01 -04:00
Brandon ThomasandClaude Opus 5.5 da9adf82bc rustfmt discard_ui.rs (CI Format step red since #533) (#597)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:55:10 -04:00
7313e08141 32-bit documents: Levels don't clip the input and output ranges (#403)
* 32-bit documents: Levels don't clip the input and output ranges

Photoshop's float Levels keep the curve going past the black and white points
and use a plain power for the gamma. rgb32 and gray32 levels.psd now match
within 0.5/255 (was 10/255).

* Raise the Photoshop oracle floor to 132

* Levels: destructive Image › Adjustments › Levels doesn't clip in 32-bit either; neutral comment wording

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:53:56 -04:00
reese 25d198ceb1 Free Transform: Undo steps through the transform; handles are easier to grab (#365)
* fix(transform): undo inside Free Transform steps through the transform

Undo while transforming used to undo the document's last step under the
open box. Now, as in Photoshop, Undo and Redo step back and forth through
the session's own changes: each handle drag, nudge or option edit is one
step. Toggle Last State and the History panel's rows wait until the
transform is committed or cancelled. A press on a preset warp turns it into
a custom mesh only when it grabs a point, so a miss records no step.

* fix(transform): widen the Free Transform handle grab radius

Handles now grab within 12 pt instead of 8, so a corner is easier to catch
without rotating instead. The Move tool's transform controls and snapping
use the same radius. On boxes smaller than the grab areas the nearest
handle wins, so the reference point doesn't take presses on corners.

* fix(transform): route every Undo through the open transform

The interception moves from the menu path into PhotocraftApp::run, so edit.undo and edit.redo from the control channel and MCP step through the box like the menu and shortcuts do. edit.toggleLastState is refused while the box is open instead of changing the document under it.

* chore(transform): describe behaviour without naming other products
2026-10-07 06:52:31 -04:00
reese 0b7cd68f8b fix(type): recolour selected type from the Color and Swatches panels (#364)
With characters selected while editing type, a new foreground colour from
the Color panel, the Swatches or the Color Picker now recolours them, as in
Photoshop, within the editing session's history step. The HSB sliders now
act only on an edit: their h/s/v round trip isn't exact, so comparing
values rewrote the foreground every frame.
2026-10-07 06:52:12 -04:00
reese 68831a4336 Polygonal Lasso: double-click closes the polygon; a new polygon replaces the selection in one step (#361)
* fix(ui): close the polygonal lasso on double-click

As in Photoshop, double-clicking closes the polygon at the last point. The
second click is handled in the click dispatch, so it never starts a new
polygon, including when the first click already closed it on the first
vertex. egui reports it as a triple click when a vertex went down shortly
before, so that counts too.

* fix(ui): a new polygonal lasso replaces the selection in one step

The first click fixes the selection mode. A new-selection polygon hides the old outline while it is drawn instead of deselecting, so closing it is one history step and Esc leaves the selection untouched. Enter closes the polygon in the mode it started in.
2026-10-07 06:49:53 -04:00
b41442c466 Color Picker: sample colours from the image with a pipette (#508)
* Color Picker: sample colours from the image with a pipette

With the Color Picker open, the pointer over the image turns into a
pipette, as in Photoshop. A click or drag there samples the composite
colour into the picker's new colour, and OK applies it. This works
whatever tool is selected; Space and middle-drag still pan. Over the
dialog the pointer is the normal arrow.

Agents get the same: `ui.pointer` (MCP `ui_pointer`) samples into an
open picker instead of driving the tool.

* Color Picker tests: rename the eyedropper test's colour helper after the rebase

#485 added a `color` test helper in the same module.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:46:25 -04:00
6237f6e46e Layer Style: the shadow/bevel Angle slider now moves the light (#350) (#411)
* Layer Style: the shadow/bevel Angle slider now moves the light (#350)

The Drop Shadow, Inner Shadow and Bevel & Emboss dialogs exposed an Angle
slider but no "Use Global Light" checkbox. The engine defaults
use_global_light to true, so the compositor used the document's fixed
global light angle and ignored the per-effect angle — dragging Angle did
nothing.

Add Photoshop's "Use Global Light" checkbox to those three effects,
round-trip useGlobalLight through the dialog fields, show the effective
(global light) angle when it is on, and — when it is on — drive the
document's shared light angle from the slider so the shadow follows it,
exactly as in Photoshop.

Co-Authored-By: Claude Haiku 4.5 <[email protected]>

* fix(ui): preserve edited layer-style global light

Apply a layer-style global-light angle only when it differs from the dialog's initial angle, so later unchanged effects cannot override a Bevel & Emboss edit. Use the current light angle for new global-light effects and add translations for Use Global Light.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 06:12:23 -04:00
sommerperandClaude Opus 5.5 9f0c049b53 Unsaved-changes prompt: (D)on't Save / (C)ancel / (S)ave keys, Tab order (#533)
The buttons show their key in parentheses and the D, C and S keys answer
the prompt (Revert prompt: (C)ancel, (R)evert). Translated labels that
don't start with the key letter get it appended ("Guardar (S)"), so the
catalogs are unchanged.

Tab / Shift+Tab move right / left through the buttons and wrap. egui's
own Tab order follows the right-to-left layout, so Tab used to move left.

Buttons from widgets::primary_button / secondary_button now draw an
accent ring while they have keyboard focus.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:12:18 -04:00
yangandClaude Opus 5.5 3c3dd881ce Type layers keep their name in step with their text (#483) (#501)
* Type layers keep their name in step with their text (#483)

A type layer is named after its first line when it's created, but
only the Type tool's first editing session renamed it again. Editing
the text later (clicking back into the layer, type.edit, Paste Lorem
Ipsum into it) left the old name, so a layer re-typed from the
placeholder kept saying "Lorem Ipsum".

Like Photoshop, a type layer whose name is still the one its text
gives it now follows every text edit, in the same history step. A
layer named anything else (given a name at creation or renamed later)
keeps it. Paste Lorem Ipsum no longer gives its new layer a fixed
"Lorem ipsum" name, so that layer follows its text too.

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

* Type layer names: the first non-empty line, the same in the Type tool and the engine

Review on #501: the Type tool named a new layer after its first
non-empty line, the engine after its first line, so text starting with
a blank line stopped the name following the text. layer_name now takes
the first non-empty line and commit() uses it. Also drop a product name
from a doc comment.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:12:13 -04:00
eb2fb2634f Add rendering modes and CPU fallback recovery (#434)
* Add rendering policy with legacy preference migration and safe adapter selection

* Add rendering mode preferences and GPU fallback recovery warning

* Complete CPU fallback recovery and validate rendering mode UI

* Simplify GPU recovery and reuse preference persistence

* Rendering modes: rebase onto the preferences merge/retry save path; drop docs screenshots

save_preferences now writes through write_prefs (merging other instances' changes) and
only marks the revision saved when the write succeeds, so a failed recovery choice is
still retried. Removes a leftover diff3 marker in es.tsv, the docs/images/rendering-*.png
screenshots and their ATTRIBUTION row (no binary assets in the repo), and regenerates
docs/scorecard.md for the new performance.renderingMode preference.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:11:06 -04:00
BFlatandClaude Opus 5.5 60224d3fb7 Release 0.3.0 (#577)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:10:08 +07:00
Carter Smith 06b0ad7af0 Color Picker and Color panel fields accept typed values (#485)
The Color Picker's hex field was rebuilt from the colour every frame,
so anything short of six valid digits was thrown away and it stayed at
000000. It now shows what is being typed while it has focus.

The Lab and CMYK fields applied every keystroke through clamped sRGB,
so typing "60" into L went through L = 6 first and changed a and b.
The dialog now keeps the typed Lab or CMYK values while they still
produce the colour, as it already did for HSB.

The Color panel's Hue and Saturation fields (Studio and Classic
themes) reset to 0 on black and greys; they now keep the typed values.

parse_hex no longer panics on multi-byte text.
2026-10-07 05:56:00 -04:00
51d85c1170 Linux: AppImage update information and a .zsync beside it (#349) (#399)
* Linux: AppImage update information and a .zsync beside it (#349)

appimagetool now embeds
gh-releases-zsync|<owner>|<repo>|latest|photocraft-*-linux-<arch>.AppImage.zsync
(owner/repo from GITHUB_REPOSITORY, default storytold/photocraft), so
AppImageUpdate, AppImageLauncher and the like can find the next release and
fetch only the blocks that changed. With zsyncmake on the host it also writes
the .zsync, into its working directory, so the script runs it in $DIST and
warns when the file is missing.

The release job installs zsync and checks the embedded string and the .zsync
before uploading; docs/releasing.md lists the new asset.

Verified locally with packaging/linux/package.sh --skip-build --formats
appimage and zsyncmake from Ubuntu 22.04: --appimage-updateinformation prints
the string, and the .zsync's URL is the AppImage's basename.

* package.sh: absolute AppDir and appimagetool paths before running in $DIST

appimagetool now runs inside $DIST so zsyncmake writes the .zsync there,
which broke relative CARGO_TARGET_DIR / APPIMAGETOOL paths (AGENTS.md
suggests target/agent-<name>). Resolve both first, remove a stale .zsync
so a missing zsyncmake is reported, and list the .zsync and zsyncmake in
the header.

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

---------

Co-authored-by: bflatastic <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 05:26:01 -04:00
BFlatandClaude Opus 5.5 8a8371395b Import warns when it keeps only the first frame or page, or a JPEG ends early (#518, #523) (#563)
Decoders now report what the decoded image doesn't show in a new
`Image::warnings` (`photocraft_codecs::DecodeWarning`), and `image_to_document`
turns them into import warnings, so they reach `photocraft-cli info` /
`convert` stderr, `doc.open` over MCP and serve, and the app's open notice
through the existing import-warnings path.

* #523: APNG (acTL frames, plus a default image outside the animation),
  animated GIF (image descriptors counted by walking the blocks), animated
  WebP and multi-page TIFF (IFDs walked without decoding; reduced-resolution
  copies and transparency masks are not pages) say "only the first of N
  frames/pages was imported". Single images stay quiet; the kept frame's
  pixels are unchanged.
* #518: a JPEG whose data runs out before its end-of-image marker (marker
  segments skipped by length, scan data by byte stuffing) still decodes
  leniently but warns "JPEG data ends early (the file is truncated or
  damaged); part of the image is missing". A JPEG that ends before any scan
  data is an error.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:20:02 +07:00
BFlatandClaude Opus 5.5 d9413cec0f FreeBSD CI: run the GPU integration tests one at a time (#576)
The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
device at once: about half of recent FreeBSD runs died within 0.05 s of a GPU test
binary starting 2-3 tests in parallel (drag_preview_canvas, color_managed_canvas,
adjust_preview_gpu), before any of them finished. Run photocraft-ui-egui's
integration tests with --test-threads=1 there; everything else is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:19:48 +07:00
BFlatandClaude Opus 5.5 2628b0dd3d Preferences merge with other tabs/windows and retry failed saves; web startup shows a Reload hint (#475, #476, #477) (#561)
* Preferences: saves merge with what another tab or window stored, and failed writes are retried (#475, #476)

A save now writes only the values this instance changed since it last
loaded or saved them over what storage holds (a three-way merge over the
JSON tree; arrays are leaves), so a second browser tab no longer reverts
the other's changes. The saved revision moves only when the write
succeeds: a failure is retried after 2 s, doubling to 30 s (sooner after a
newer change), with a status message on the first failure and a notice
while it keeps failing. Shared by the desktop and web builds.

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

* Web: a startup that never gets going shows what may have failed and a Reload link (#477)

When the .wasm fails to download or doesn't match the page, no Rust runs,
so the loading page itself (CSS only, no script) adds the hint after 20 s.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:07:19 +07:00
BFlatandClaude Opus 5.5 2868220032 Formats: deep .pcraft groups reopen; zero-size PSD rejected; JPEG mattes over white; no-ICC formats get sRGB (#517, #519, #521, #522) (#555)
* PSD: reject a header with zero width or height (#519)

A 0xN header opened as an empty document and saved back as another
zero-sized PSD or .pcraft. Header::validate now requires at least 1x1
(the spec range starts at 1), and PSD export validates the header so a
zero-sized canvas is never written.

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

* .pcraft: documents with deeply nested groups load again (#517)

serde_json's 128-level recursion limit made bundles with 40 or more
nested groups unreadable after a successful save. The manifest is now
parsed with that limit disabled behind a linear JSON depth check, layers
are decoded and converted one level at a time with small stack frames,
and groups may nest up to MAX_GROUP_DEPTH (100) levels, which load and
save within a few hundred KiB of stack. Saving a deeper document fails
instead of writing a bundle that can't be opened.

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

* Export: convert non-sRGB RGB to sRGB for formats without a profile (#522)

GIF, BMP, QOI, TGA, ICO and Netpbm can't embed ICC, so a linear-light
or Display P3 document was written with values that only meant something
under the dropped profile (a linear EXR came out dark). export_flat now
converts such RGB to sRGB through photocraft-cms (perceptual, as Quick
Export does) for every photocraft_io::export route. The EXR/HDR linear
conversion shares the same banded helper.

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

* Export: composite transparency over white for formats without alpha (#521)

The CLI, MCP, Save As, Layers to Files and Layer > Export As dropped the
alpha channel when writing JPEG (or Radiance HDR), exposing the colours
stored under transparent pixels, while Export As and Quick Export
composite over white. export_flat now flattens over white too (no ink
for CMYK) and says so in its warnings.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:06:55 +07:00
BFlatandClaude Opus 5.5 7eb3b2e072 Automation: non-object params rejected; headless sees finished jobs; serve stdio survives a bad line; MCP batch keeps results (#503, #504, #505, #506, #529) (#546)
* Command params that are not a JSON object are an error (#506)

`Session::dispatch` now rejects array, string, number and boolean params
with "invalid parameters for `<id>`: params must be a JSON object" instead
of running the command with every default; `null` still means no params.
The one check covers the control channel, `serve`, MCP (headless and
bridge) and the CLI, and batch steps fail per step. `photocraft-cli run
--new` prefixes its error with `--new`.

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

* Headless requests apply finished background jobs first (#503)

A `wait: false` job was applied to its document only by a later command
or `jobs.list`, so `doc.save`, export, `doc.inspect`, `doc.render` and
`session.list` (and their MCP tools) still read and wrote the document
without it. `Headless::sync_jobs` now runs at the top of every JSON-lines
request and before every headless MCP tool call.

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

* `serve` stdio keeps the session after a rejected request line (#505)

An over-long request line (or one that is not UTF-8) ended the stdio
session, losing every open document. It now gets one error reply, the
rest of that line is skipped without being dispatched, and the next
request is served. `read_bounded_line` reads bytes, so a cut inside a
multi-byte character is reported as too long instead of an I/O error.
TCP connections still close after an oversized line.

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

* MCP `command_batch` keeps its step results when the reply budget stops it (#504)

The batch budget charged each step's plain JSON size, but MCP sends the
batch reply as escaped text content, so a batch the budget had already
stopped could still exceed the tool-result ceiling and come back as a
bare "response exceeds" error. MCP (headless and bridge) now uses
`BatchReplyBudget::escaped`, which charges the escaped size, through the
new `Headless::batch_with_budget`; `serve` keeps the plain budget.

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

* docs: the 1 MiB request-line limit does not apply to MCP stdio (#529)

The book stated the 1 MiB request-line limit without naming a transport.
It applies to desktop and headless TCP and to the headless JSON-lines
stdio server; MCP over stdio reads its own lines and only its tool
results are size-checked. Say so in the automation security pages and in
the control protocol's transport limits.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:00:36 +07:00
BFlatandClaude Opus 5.5 8f0f95959d Control channel: menu/dialog requests wait for their job; right-click menus at the point; MCP ui_pointer button (#510, #511, #512, #514) (#548)
* ui.inspect descriptions point to ui.menu.list for the menu tree (#510)

The control-protocol doc, the control.rs method list and the MCP
`ui_inspect` description said `ui.inspect` returns the menu tree; it
never did. They now say the menu tree comes from `ui.menu.list` (over
MCP, through `control_call`).

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

* ui.menu.invoke and ui.dialog.confirm wait for the job they start (#511)

Since filters became background jobs, invoking a no-dialog filter from
the menu or confirming a filter dialog over the control channel replied
`{job, pending: true}` before the filter was applied, unlike v0.2.0 and
unlike `engine.execute`. All three now share one helper that runs the
command with script events off and, when it started a job, defers the
reply until the job's result lands; `"wait": false` keeps the immediate
`{job, pending}` reply. The now-unused `run_automation` is gone.

The new test drives Blur More through `ui.menu.invoke` and Gaussian Blur
through its dialog's OK, with and without `wait` (it failed before).

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

* ui.pointer right-click opens its menu at the pressed point (#512)

A `ui.pointer` right-click listed the layers at the requested document
point but drew the layer menu at the canvas centre, and the Brush Preset
picker opened there too. Both now open at the point's screen position,
where a mouse right-click on it opens them. `ViewXform::active` gives the
active canvas's document-to-screen mapping as last laid out (the
construction several tests repeat); the centre stays the fallback when
the point can't be mapped.

The ui.pointer doc now lists `button`, both menus, and that top-level
modifier flags are ignored when `modifiers` is given.

The new test checks two points against the mouse path and the picker
position (it failed before).

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

* MCP ui_pointer forwards button and rejects unknown arguments (#514)

Bridge clients couldn't right-click through `ui_pointer`: it forwarded
only `events` and `modifiers` and silently dropped anything else
(`button`, a typo, a top-level `space`). It now forwards an optional
`button`, and `PointerParams` denies unknown fields, so the schema says
`additionalProperties: false` and a stray argument is an error.

The new bridge test checks the forwarded params, the rejection of
`buton` and top-level `space`, and the tool schema (it failed before).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:55:01 +07:00
BFlatandClaude Opus 5.5 13b0d856b6 Shortcuts run in the order pressed; Shift+Delete fills on Windows; colour pickers stay put while dragging (#440, #530, #534) (#552)
* Shortcuts pressed in one frame run in the order they were pressed (#440)

Undo then Save in the same frame dropped the Undo: only the first binding in table order ran. Each key press now dispatches in event order, and a shortcut that opens a dialog, prompt, menu or inline type leaves the frame's later presses to it.

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

* Shift+Delete opens Edit › Fill on Windows instead of cutting (#530)

egui-winit reports Shift+Delete as a Cut on Windows. Outside text fields, a Cut with Shift and without Ctrl is now the Shift+Delete press again, so it fills like Shift+Backspace; Ctrl+X still cuts and text fields keep their Cut.

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

* Shape and text colour pickers stay put while dragging (#534)

The picker's width follows its RGB readouts, and egui placed the popup anew every frame: near the right edge of the window it flipped between opening right and left of the swatch, under the pointer. A swatch's picker now keeps the left edge it opened with, shifted left of the swatch when the window edge needs it.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:54:31 +07:00
BFlatandClaude Opus 5.5 e30b121969 History: tool and preset names, Frame from Layers as one step, undo/redo restore the targeted layers (#513, #528, #446, #496, #495) (#551)
* Elliptical Marquee selections record "Elliptical Marquee" in History (#513)

Both marquee tools commit through `select.rect`, which named every step
"Rectangular Marquee" whatever its `ellipse` param. The step is now named
after the tool that made it; the rectangular marquee keeps its name.

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

* Preset filters record their own names in History (#528)

Blur, Blur More, Sharpen, Sharpen More, Sharpen Edges and Despeckle run
a generic algorithm with fixed parameters and recorded its name
("Gaussian Blur", "Unsharp Mask", "Surface Blur"). The presets now live
in one table with their names, used for the history step and the job
progress text; Gaussian Blur, Unsharp Mask and Surface Blur keep theirs.

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

* Vertical type command no longer claims it renders horizontally (#446)

Vertical type has rendered in columns since #199, but the params text of
`type.orientation.vertical` still said it was "rendered horizontally for
now". It now documents the same `{"layer":id?}` as Horizontal.

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

* Frame from Layers is one history step (#496)

It grouped the layers through `layer.new.groupFromLayers` (its own
"Group Layers" step) and then recorded "Frame from Layers", so one undo
left the group behind. The grouping step is now folded into the frame
step with `purge_last`, as Transform Again on a copy does; if the group
can't be framed, the grouping is undone with nothing left to redo.

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

* Undo and redo restore the layers each history state targeted (#495)

History states held only the document, so after undo or redo the active
layer stayed whatever it was: redoing New Layer left the old layer
active (a Fill then painted it), and undoing Delete Layer left the
restored layer unselected. Each state now also keeps the active and
selected layers it had when it was created (when the document was
opened, or right after the step that made it), and undo and redo target
them again where they still exist. Selecting a layer is still not a
step and doesn't change any state's target, so selecting another layer
and then undoing or redoing goes back to that state's target, as the
reference app's Step Backward does.

The History panel lists redo labels from a new `redo_labels` accessor
instead of cloning and replaying the history every frame.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:54:10 +07:00
BFlatandClaude Opus 5.5 bd1322a5e4 Recovered documents keep their recovery data until saved or closed (#444) (#549)
Launch no longer deletes recovery entries right after loading them. A
recovered document adopts the entry it came from: its next autosave
replaces it in place (atomically) and saving or closing the document
removes it, so a second crash before the next autosave loses nothing.
New documents autosave under per-launch keys, so a document id reused
from an earlier launch never overwrites a surviving entry, and sidecars
whose key doesn't match their file name are ignored.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:53:48 +07:00
BFlatandClaude Opus 5.5 faaa42db95 Gradients honour explicit transparency stops; smart filter commands accept an explicit layer target (#465, #466) (#544)
* Gradients honour explicit transparency stops with colors, a preset or the current gradient (#465)

`paint.gradient` and `gradient.fill.create` dropped `transparency` unless `stops` was
given. Explicit transparency stops now replace the gradient's own opacity stops whatever
supplies the colours. One shared parser (`transparency_param`) serves paint.gradient,
gradient.fill.create/set and the presets; malformed stops are an error, not dropped.

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

* Smart filter commands check an explicit layer target, not the active layer (#466)

`layer.smartFilter.*` with `"layer":id` were refused when the active layer was not a
smart object with filters, although they edit the named layer. Their precondition is now
checked on that layer; the active layer stays unchanged, and without `layer` (menus)
nothing changes.

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

* Gradients: reword the opacity-merge doc comment

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:21:22 +07:00
BFlatandClaude Opus 5.5 1df68e9a5d CLI batch: recorded-action steps, --format .ext, strict --quality, refuse --out = --in (#489, #490, #491, #492) (#545)
* CLI --quality refuses every value outside 1-100 (#491)

--quality was parsed as a u8 and clamped, so 0 and 101-255 were silently
accepted while 256 and above were rejected. Every value outside 1-100 is now
a bad --quality error, checked before any file is read or written.

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

* CLI batch: --format with a leading dot names outputs <stem>.<ext> (#490)

batch built output names from the raw --format value, so --format .jpg wrote
a..jpg. One leading dot is now dropped, as the save path and
file.automate.batch already do.

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

* CLI batch --actions takes recorded-action and droplet steps (#489)

batch read only {"command", "params"} objects, so the [id, params] pairs a
recorded action or droplet stores failed with 'missing "command"'. It now
uses the engine's action parser (automate_cmds::parse_action, split out of
parse_script), which takes [id, params], objects and bare ids, bare or
wrapped in {"steps": ...} or a droplet; {"actions": ...} still works.

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

* CLI batch refuses an --out folder that is the --in folder (#492)

Results are saved as <out>/<stem>.<ext>, so --out naming the input folder
silently replaced the originals. batch now compares the canonical folders
(relative spellings, trailing separators, symbolic links) and fails before
writing anything, naming the folder; the new --in-place flag opts in.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:21:07 +07:00
BFlatandClaude Opus 5.5 29c280a2c6 Docs: roadmap, scorecard and README after the 2026-10-07 user-report fixes (#538)
- Roadmap (updated 2026-10-07): the M11 row notes the stricter CLI,
  batch collision reports, per-step `wait` in MCP batches and safe
  headless saves; the UI / UX row lists the keyboard-only shortcuts
  (layer navigation, number keys, hardness, step and repeat), Alt-click
  sampling, double-click for Layer Style, New from Clipboard, the
  centred window and remembered Liquify settings.
- Scorecard: UI-217-1 is partial (Fill and Liquify settings persist);
  UI-217-2's note points at the new `native_options()`. docs/scorecard.md
  regenerated; docs/parity.md is unchanged (no new catalogue items).
- README: `<subcommand> --help` in the CLI snippet.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 12:57:23 +07:00
sommerperandClaude Opus 5.5 4337a6227a Ctrl+Insert copies and Shift+Insert pastes on Linux; Shift+Insert pastes images on Windows (#531)
On Linux, Ctrl+Insert / Shift+Insert become Copy / Paste on the canvas (the pixel
commands' Ctrl+C / Ctrl+V) and in text fields (Event::Copy; Shift+Insert asks
egui-winit for the clipboard text with ViewportCommand::RequestPaste).

On Windows egui-winit already maps both keys, but drops Shift+Insert when the
clipboard holds only an image. Its key release now pastes, like the existing
Ctrl+V fallback.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 12:05:59 +07:00
floflo777 8a5e89faa6 32-bit documents: Exposure works on the stored linear values (#400)
* 32-bit documents: Exposure works on the stored linear values

Their samples are linear already, so going through the 2.2 curve applied
the gamma twice. rgb32 and gray32 exposure.psd now match Photoshop within
0.5/255 (was 69/255 and 44/255).

* Raise the Photoshop oracle floor to 130
2026-10-07 11:49:29 +07:00
Alex GorevskiandCopilot a312c3d0c7 PSD export: select PSB from size estimate (#431)
Estimate encoded raster and retained-data size with checked arithmetic, and select PSB when the PSD limit is exceeded. Keep forced PSB choices and document the completed scorecard slice.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 11:41:08 +07:00
sommerperandClaude Opus 5.5 4fdb71dfc3 Dialogs keep their top-left when their content resizes (#487)
Dialogs were anchored at the screen centre, so a body that grew (Layer
Style switching effects) re-centred the dialog and moved its title bar.
They now open centred, then pin their top-left after the first laid-out
frame: they grow down and right, and title-bar drags move the pinned
corner (still clamped on screen).

The Color Range resize test now asserts the top-left stays put (it
failed before the fix).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 11:40:54 +07:00
nsn 72e2eb9644 Spanish: add missing tool, filter and path translations (#488) 2026-10-07 11:40:40 +07:00
sommerperandClaude Opus 5.5 2051c0caf4 Move drag reveals a larger-than-canvas layer's off-canvas pixels (#526)
The live Move preview redrew only the moving layers' bounds clipped to
the canvas at drag start, then translated. For a layer larger than the
canvas, the strip sliding in from beyond the edge was never
recomposited, so the edge pixels smeared along the drag path. Keep the
bounds unclipped and clip them to the canvas after translating.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 11:40:23 +07:00
Coy GeekandContributor 5824e3afeb docs: add star history chart to README (#469)
Show PhotoCraft star growth at the bottom of the README with a linked Star History chart using the date view and top-left legend.

Co-authored-by: Contributor <[email protected]>
2026-10-07 11:32:34 +07:00
Carter Smith f373fdb52f Raise rust-version to 1.95, the minimum egui 0.36 needs (#471) 2026-10-07 11:32:23 +07:00
BFlatandClaude Opus 5.5 d38eefc8f7 Free Transform a Copy (Cmd+Alt+T); Esc takes the copy back (part of #352) (#482)
The other hidden "quick transform" shortcut. Cmd+Alt+T (no menu item,
in Keyboard Shortcuts as "Free Transform a Copy") duplicates the active
layer, or with a selection copies the selected pixels to a new layer
(Layer via Copy), and opens Free Transform on the copy. OK leaves the
original in place and the transformed copy as one history step (the
duplicate's state is folded away); an untouched OK keeps the copy where
it is; Esc, or a transform that fails, undoes the copy and leaves
nothing to redo. `TransformSession::copy` marks such a session.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:56:58 +07:00
Alex GorevskiandCopilot 7b3d131bc8 docs: correct VectorCraft sibling name (#429)
Replace the stale DrawCraft name in the contributor sibling list.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 10:43:46 +07:00
Pedro Jose Soto Rivera cb00e2921b Type: dragging a paragraph box handle resizes the box (#351) (#376)
The frame drawn around paragraph text while editing had eight handles,
but a press on them only placed the caret. They now resize the box:
corners move two edges, side handles one, with a 4 px minimum. The drag
goes through type.edit with the session's coalesce key, so it is one
history step.
2026-10-07 10:43:31 +07:00
ljkxandClaude Opus 5.5 d2576b27eb Profile mismatch: compare profiles by colour, not bytes (#394)
Opening any file Photoshop tagged with sRGB IEC61966-2.1 asked
"Embedded Profile Mismatch: sRGB IEC61966-2.1 vs sRGB IEC61966-2.1":
open_policy compared a hash of the ICC bytes, and Photoshop's v2 profile
(1024-entry tables) and the built-in v4 sRGB (parametric curves) are the
same colours in different bytes. 32-bit documents in Photoshop's linear
sRGB missed the stay-linear exception for the same reason.

Add Profile::same_colors: same colour space and every sample of a device
grid within dE76 0.5 through both (re-encoding costs 0.025; moving the
red primary 2% costs 1.0). Colours are compared rather than round-tripped
because CMYK -> PCS -> CMYK is not an identity even for one profile.

Of the 75 profile-tagged PSDs in the test corpora, all 75 prompted on
open; now 6 do (Adobe RGB, Gray Gamma 2.2, SWOP), each a real mismatch.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:43:18 +07:00
Nauman Ahmed 870741a5cd fix: use wasm-safe timing for Liquify (#393) 2026-10-07 10:38:32 +07:00
Alex GorevskiandCopilot 1cb5af9218 Build desktop app by default (#425)
Include apps/photocraft in workspace default-members so a plain cargo build also builds the native UI.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 10:34:59 +07:00
Armaan Sandhu 9cf7c42f1d Content-Aware Scale: enlarging a 1 px wide or tall layer no longer panics (#387)
To enlarge, carve_width_with finds seams on a shrinking copy and duplicates them, but it stopped once the copy was 1 px wide, so a 1 px wide image found no seam and came back at its old width. The caller then wrote a short buffer into the new size (the "region data length mismatch" assertion in write_region) or, with a height change too, transpose indexed past the end. The session's guard turned both into "failed with an internal error". The single column is now taken as the seam, so it is repeated; wider images are unaffected, because a round takes at most half the columns.
2026-10-07 10:34:44 +07:00
HEOJUNFOandClaude Opus 5.5 533566c5ad prefs.reset resets a single colorSettings key (#445) (#467)
`prefs.get` and `prefs.set` route `colorSettings.*` to the colour state,
but `prefs.reset` only special-cased the whole section. A nested path
fell through to `Preferences::reset`, which has no colorSettings, so it
failed with "unknown preference". Take the default from
`ColorSettings::default()` and set it the way `prefs.set` does.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 10:34:30 +07:00
HEOJUNFOandClaude Opus 5.5 778e8e187d Image Size shows the one-pixel minimum it applies (#441) (#468)
`dim_field` allows negatives for Canvas Size's relative mode, so a typed
0 width stayed 0 in the Image Size dialog, which previewed "0 px × 1 px"
while `image.imageSize` resized to 1×1. Clamp edited dimensions to one
pixel so the preview shows the size that will be applied.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 10:34:17 +07:00
floflo777 9ae984c0ae Use the real Gaussian Blur id in the docs and MCP schema (#390)
filter.blur.gaussian isn't registered, so the documented CLI run and the
agent loop failed with "unknown command". Pin the schema examples with a test.
2026-10-07 10:34:03 +07:00
Pedro Jose Soto Rivera a0997942d3 Export: TGA in Save As and Export As (#363) (#377)
The TGA codec already encoded and decoded, but neither Save As nor
Export As listed it. Add it to both and cover the round trip at
RGBA, RGB and gray.
2026-10-07 10:28:01 +07:00
BFlatandClaude Opus 5.5 93bd44899b Number keys set opacity, Shift+[ / Shift+] step hardness, Shift+Cmd+A opens Camera Raw (part of #352) (#473)
* Number keys set opacity, Shift+[ / Shift+] step hardness, Shift+Cmd+A opens Camera Raw (part of #352)

More of the shortcuts users expect without a menu item:

- 1–9 set 10–90% opacity and 0 sets 100%; two digits typed quickly
  give an exact value (4 5 = 45%, 0 0 = 0%). With the Brush, Pencil or
  Eraser that's the brush's Opacity, and Shift+digit its Flow (Brush,
  Eraser); with the Gradient or Paint Bucket their Opacity; with tools
  that have no opacity of their own (Move, selection, Type…) the active
  layer's Opacity, Shift for Fill. The locked Background, and tools
  whose number is a strength or exposure, are left alone. Digits are
  read by physical key, so Shift+digit works on any layout.
- Shift+[ / Shift+] step a painting tool's hardness by 25%. They're
  checked before [ and ], whose egui key test ignores Shift.
- Filter › Camera Raw Filter… gets its default Shift+Cmd+A.

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

* opacity_keys: module doc without a product name

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:19:04 +07:00
BFlatandClaude Opus 5.5 f697e43ef7 Transform Again on a Copy (Cmd+Alt+Shift+T): step and repeat (part of #352) (#474)
The hidden shortcut for step-and-repeat was missing. A new command,
`edit.transform.againCopy` (no menu item, Cmd+Alt+Shift+T), duplicates
the active layer and repeats the last transform on the copy, as one
history step labelled "Transform Again", so repeated presses lay out a
row (or a ring) of copies one transform apart. The transform is worked
out before anything changes (`transform_again_params`, now shared with
Edit › Transform › Again), so nothing to repeat leaves no copy; a copy
that can't be transformed (position locked) is taken back with nothing
left to redo (`History::clear_redo`).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:55:50 +07:00
BFlatandClaude Opus 5.5 f49b5a4853 Keyboard layer navigation: Alt+[ / Alt+] / Alt+, / Alt+. and Shift to add (part of #352) (#470)
The layer shortcuts with no menu item were missing. A new module,
`layer_nav_cmds`, adds six commands that walk the Layers panel's rows
(a group's row before its children, collapsed groups as one row):
Select Layer Above / Below (Alt+] / Alt+[), Select Top / Bottom Layer
(Alt+. / Alt+,), and Add Layer Above / Below to Selection
(Alt+Shift+] / Alt+Shift+[). They stop at the first and last rows,
never dirty the document, and are listed under Layer in Keyboard
Shortcuts so they can be rebound. Agents run them like any command.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:31:15 +07:00
BFlatandClaude Opus 5.5 58bfb4ee8c The main window opens centred on the screen (#419) (#462)
Windows places a new window that has no position by cascading it from
the top-left corner, so PhotoCraft opened at a different offset from
the corner on every launch. The window now starts centred on the main
monitor (eframe's `centered`), at the same 1440 × 900, which eframe
shrinks to the monitor first and `work_area::fit_window` still
maximizes when it doesn't fit (#315). Wayland compositors keep placing
windows themselves. The window options moved into `native_options()` so
a test can check them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:19:57 +07:00
BFlatandClaude Opus 5.5 c8e0a34792 Liquify remembers its brush settings between uses and launches (#418) (#461)
Every time Liquify opened, the brush went back to the defaults (Density
50 and so on). Its settings (tool, size, density, pressure, rate, mesh
and backdrop options) are now kept in `prefs.dialogs["filter.liquify"]`
whenever the dialog closes (OK, Cancel, Esc or the control channel) and
restored the next time it opens, also after a restart. The first use
still starts from the defaults with a brush about a tenth of the image.

`LiquifyOpts::apply` and `to_json` read and write the settings; the
control channel's setter now uses `apply` too, and a damaged saved value
is clamped or skipped, never a failure to open. The three cancel paths
share one `cancel`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:19:38 +07:00
BFlatandClaude Opus 5.5 14207b76c8 CLI: unknown flags are usage errors, <subcommand> --help prints usage, usage lists --quality (#421, #423) (#460)
Any `--flag` a subcommand didn't know was recorded and ignored, so
`batch --fromat jpg` wrote the source formats and exited 0, `run
--param '{…}'` turned the JSON into a stray file name, and
`<subcommand> --help` ran the subcommand (`mcp --help` started a
server). One table, `SUBCOMMANDS`, now lists each subcommand's value
flags, bare flags and handler, replacing the shared VALUE_FLAGS list:
an unknown flag in either form (`--flag value` or `--flag=value`) is a
usage error naming it (exit 2) before any work, a value on a bare flag
is an error, and `--help` or `-h` after a subcommand prints usage and
exits 0. Every documented flag keeps working in both forms.

The usage text now lists `--quality` for `run` and `batch`, which
already honoured it, and the book's CLI page describes the stricter
parsing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:00:38 +07:00
BFlatandClaude Opus 5.5 3c6310e559 New from Clipboard, and Paste with nothing open makes a document (#368) (#458)
File › New from Clipboard (`file.newFromClipboard`, right after New…)
opens the clipboard image as a document of its own: sized to the image,
holding it as one layer, in the pixel format it was copied in. It has
no default shortcut; one can be assigned in Keyboard Shortcuts. Edit ›
Paste (Ctrl+V) with no document open does the same, so a screenshot is
one keystroke from the start screen. Both read the OS clipboard only
when invoked, stay enabled from the clipboard service like Paste, and
are a quiet no-op when it holds no image.

Agents reach it through MCP and `serve` too: the command only uses the
session clipboard, so it joins the automation allowlist of file
commands. The menu label is in every catalog.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:00:22 +07:00
BFlatandClaude Opus 5.5 c0a7f8e7e4 Batch runs report inputs that share an output name instead of overwriting (#420, #422) (#448)
`file.automate.batch`, `file.scripts.imageProcessor`,
`file.automate.lensCorrection`, droplets (which run batch) and
`photocraft-cli batch` named every output `<stem>.<ext>` in one folder.
When two inputs mapped to the same name (`a.png` and `a.jpg` saved as
JPEG, or the same file name in two input folders), the later save
replaced the earlier result while both were listed as written and the
run exited 0.

`engine::file_cmds::OutputClaims` remembers the outputs written in a
run, ignoring case as macOS and Windows file systems do. An input whose
output was already written is skipped before any work and reported in
`errors` (the CLI prints FAIL and exits non-zero); the first result is
kept. Runs whose names don't collide, including the same stem kept in
its own formats, are unchanged. The commands' params docs say so.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:00:05 +07:00
BFlatandClaude Opus 5.5 6119c00962 Layers panel: double-click beside a layer's name opens Layer Style (#350) (#452)
A double-click anywhere on a Layers row started a rename. Now only a
double-click on the name renames; the rest of the row follows the
layer's kind: an adjustment or fill thumbnail opens its Properties
(Layer Content Options), a Smart Object thumbnail opens its contents
(Edit Contents), and anywhere else opens Layer Style (Blending Options).
The Background still becomes a normal layer, mask thumbnails keep their
own clicks, and a command that's disabled for the layer does nothing.
docs/ui-design.md lists the row's double-click targets.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 08:37:47 +07:00
BFlatandClaude Opus 5.5 b926677549 Alt-click with the Brush, Pencil, Gradient or Paint Bucket samples a colour (#417) (#450)
Holding Alt (Option) with a painting tool now works as the Eyedropper
for that press: a click sets the foreground colour and a drag keeps
sampling, without painting or recording a history step. The choice is
made when the press starts, so Alt pressed or let go mid-stroke never
switches between painting and sampling, and Control+Alt stays the
brush-resize drag. While Alt is held the cursor shows the Eyedropper's
crosshair. Agents get the same through `ui.pointer` with `alt`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 08:27:06 +07:00
BFlatandClaude Opus 5.5 3443833532 Headless saves without path never flatten over the opened file (#416) (#447)
MCP `doc_save {}` and `serve` `doc.save {}` fell back to the document's
own path for any format, so an agent that saved its work replaced the
input PNG or JPEG with a flattened, re-encoded copy, and a `format`
override wrote PNG bytes under a `.psd` name. The desktop's File › Save
already writes back only to layered files.

The rule now lives in one place, `engine::file_cmds::saves_in_place`
(plus `extension`), shared by File › Save, the control channel's
`app.save` and the headless save. Without `path`, a save writes back
only to a PSD, PSB or .pcraft file in its own format; anything else is
an error naming `path` and the file is left unchanged. An explicit
`path`, even the opened file's own, still writes as before. The MCP
`doc_save` description and `path` doc, and docs/control-protocol.md,
state the rule.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 08:20:43 +07:00
BFlatandClaude Opus 5.5 55054b5fcc MCP command_batch and serve batch honour each step's wait (#415) (#439)
`command_batch` steps reuse `command_run`'s params, so its schema
advertised `wait`, but the tool mapped each step to `{command, params}`
and ran it to completion: `"wait": false` blocked on the filter and
started no job. Steps now carry `wait` (default true): headless batches
start the command through `command_start`, and bridge mode forwards it
to `engine.execute`, which already honours it. `serve`'s `batch` reads a
step's `wait` the same way. The schema doc and docs/control-protocol.md
describe the per-step `wait`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:59:57 +07:00
BFlatandClaude Opus 5.5 7930dd0adf serve: methods lists jobs.list and jobs.cancel (#414) (#438)
The headless server dispatched `jobs.list` and `jobs.cancel` but left
them out of `METHODS`, so a client discovering the server through
`methods` couldn't find the job methods `engine.execute {"wait": false}`
depends on. Add them, and name them in the rpc.rs module doc and the
`photocraft-cli serve` usage text. A test checks that the job methods
are listed and that every listed name is dispatched.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:59:39 +07:00
BFlatandClaude Opus 5.5 2620460267 ui.set: reject unknown fields; the theme error names all five themes (#412, #413) (#437)
`ui.set` skipped keys it doesn't read and replied with success, so the
`dark` field advertised by the MCP `ui_set` tool (and any typo) changed
nothing while reporting ok. It now checks every key against
`UI_SET_FIELDS` before applying any of them and names the accepted
fields in the error. The MCP tool description, its `fields` doc, the
control.rs method list and docs/control-protocol.md list the real fields.

An unknown theme listed only studio, studioLight and classic; the error
now comes from `ThemeKind::ALL` through the new `ThemeKind::id`, so it
names pro and proMedium too, and each listed name selects its theme.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:59:14 +07:00
Brandon ThomasandClaude Opus 5.5 ba9272be7d Alt+scroll zooms the canvas in 5% steps around the pointer (#293) (#371) (#371)
Merged with #367: the wheel also navigates while hovering the canvas scrollbars.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 18:22:47 -04:00
Brandon ThomasandClaude Opus 5.5 0940f82d34 Alt+right-drag resizes the brush and changes its hardness (#297) (#370) (#370)
Merged with #360's right-click layer menu: Cmd/Ctrl+right-click lists layers, Alt+right-drag resizes, plain right-click keeps the brush picker / erase.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 18:22:35 -04:00
Brandon Thomas fbcd0c238e Canvas scrollbars, and Preferences > Tools > Overscroll works (#300) (#367) (#367) 2026-10-06 18:22:23 -04:00
Brandon Thomas 0da7d4b0f5 Right-click on the canvas lists the layers under the pointer (#307) (#360) (#360) 2026-10-06 18:22:11 -04:00
Brandon Thomas 90a46d3f89 Stamp Visible (Cmd+Alt+Shift+E) and Stamp Down; Alt+Merge keeps the originals (#217) (#356) (#356) 2026-10-06 18:21:59 -04:00
Brandon Thomas c161eaec73 Fonts: use craft-fonts as an optional build input; move the Japanese fonts out (#353) (#353) 2026-10-06 18:21:46 -04:00
Brandon Thomas 0349b0c0f1 Harden EXIF orientation: limits after rotation, fallible allocation, strict IFD parsing (#345) (#345) 2026-10-06 18:21:33 -04:00
Brandon ThomasandClaude Opus 5.5 909efc0f6d Background jobs in the app: progress, Cancel, open-as-job (part of #210) (#373)
Re-lands #290, which was merged into its stacked base branch (jobs/engine-210) instead of main.
Same tree as the gated #290 commit 39152db.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 15:44:40 -04:00
Brandon ThomasandClaude Opus 5.5 3b7870bee1 Background jobs: engine job API, progress and cancel (part of #210) (#289)
* Test: retry the GPU startup sentinel check while a forked child briefly shares the lock

monitor_profile tests spawn osascript in the same binary; between fork and exec the child holds
a copy of the marker file descriptor, so flock reports WouldBlock and begin() returns Busy.

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

* Background jobs: engine job API, progress and cancel (part of #210) (#289)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 15:41:38 -04:00
Brandon Thomas a0d49de047 Menus stay above the taskbar; oversized windows open maximized (#315) (#343) (#343) 2026-10-06 11:30:10 -04:00
Brandon Thomas f8563856a3 Menus: no menu or submenu covers the menu bar (#319) (#337) (#337) 2026-10-06 11:29:57 -04:00
Petr Leo Compel 5a6dd79852 i18n: add Czech (cs) UI translation (#328) (#328) 2026-10-06 11:29:44 -04:00
才倾梦华V b3c44cf070 Preferences: add an Apply button (#325) (#325) 2026-10-06 11:29:06 -04:00
TiredIsa abd6f6e591 Localise the UI into Russian: a complete catalog and the missed tl! call sites (#298) (#298) 2026-10-06 11:28:52 -04:00
Mawfyy edc441a8d4 Add a Spanish catalog (menus, dialogs, panels) (#299) (#299) 2026-10-06 11:28:37 -04:00
Andrew Shen 321e986f7b i18n: add Traditional Chinese (zh-hant) UI catalog (#302) (#302) 2026-10-06 11:28:23 -04:00
Sebastian Solano 4b76aff35d Fix logic Show the clone crosshair only while Option is held (#301) (#301) 2026-10-06 11:28:09 -04:00
leonaci 7544b649f2 fix(ui): feed every pointer move per frame so fast strokes stay smooth (#317) (#317) 2026-10-06 11:27:54 -04:00
Brandon Thomas dd2f566daa macOS CLI: verify the shipped CLI is Developer ID signed and notarized in the release workflow (#308) (#320) (#320) 2026-10-06 10:48:05 -04:00
Brandon Thomas ae9e1896cf Keyboard Shortcuts: Ctrl+F records as Ctrl+F, not Ctrl+ControlLeft (#292) (#323) (#323) 2026-10-06 10:47:50 -04:00
Brandon Thomas 48e6e668b9 Layers panel: rename commits on click elsewhere, Enter or Tab; one at a time (#314) (#326) (#326) 2026-10-06 10:47:33 -04:00
Brandon Thomas b6e9ec97e5 Cmd+Enter (Ctrl+Enter) loads the path as a selection (#306) (#329) (#329) 2026-10-06 10:47:11 -04:00
Brandon Thomas c046337ddf Move tool: Auto-Select on by default; a click picks and drags the layer under the pointer (#296) (#330) (#330) 2026-10-06 10:46:55 -04:00
JGF f091c7fbf0 Windows: Alt-click clone hint and Ctrl+N channel errors (#251 follow-up) (#336) (#336) 2026-10-06 10:46:41 -04:00
才倾梦华V 4e2a83ddbd Fix preference usage scanning on CRLF checkouts (#331) (#331) 2026-10-06 10:46:21 -04:00
Brandon Thomas e8677ff851 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware (#333) (#333) 2026-10-06 10:09:55 -04:00
Brandon Thomas 44bf13e890 PSD export: 32-bit files open in Photoshop (HDR toning Color Mode Data, #291) (#327) (#327) 2026-10-06 09:23:20 -04:00
Brandon Thomas 7ee4bb2858 Apply EXIF orientation on open, write Orientation = 1 on export (#285) (#318) (#318) 2026-10-06 08:20:36 -04:00
Brandon Thomas eaf92dcd9d Smart Objects and Smart Filters survive PSD save and open (#288) (#288) 2026-10-06 07:46:37 -04:00
1232 changed files with 315157 additions and 14440 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
#
# cargo nextest run --workspace # all tests except doctests
# cargo test --doc --workspace # nextest does not run doctests
#
# GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer
# serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so
# this group caps them at 4.
[test-groups]
gpu = { max-threads = 4 }
[[profile.default.overrides]]
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
test-group = 'gpu'
+18
View File
@@ -0,0 +1,18 @@
.git
.github
.agents
.codex
.aws
**/target
dist
corpus
log
plan
secrets
temp
craft-fonts
book/output
**/.DS_Store
**/.env
**/.env.*
*.log
+4
View File
@@ -0,0 +1,4 @@
# Custom runner labels actionlint can't know about.
self-hosted-runner:
labels:
- macos-release # org larger runner (M2 Pro), used by release.yml
+151 -5
View File
@@ -17,25 +17,79 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# 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).
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
# that lands while main's run is in progress waits for it, so every main state that gets a run is
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
name: select platforms
runs-on: ubuntu-latest
outputs:
os: ${{ steps.pick.outputs.os }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: pick
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.event.pull_request.base.sha }}
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
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"]'
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
# packages changed: the web app and its packaging, crate sources and manifests (the web
# build compiles nearly every crate), the workspace manifests and this workflow.
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
echo "web=true" >> "$GITHUB_OUTPUT"
else
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"
else
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
fi
test:
name: test (${{ matrix.os }})
needs: platforms
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: ${{ fromJSON(needs.platforms.outputs.os) }}
steps:
- uses: actions/checkout@v4
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
# background: the space is free before the craft-fonts build needs it, and nothing waits.
- name: Install Linux system dependencies (egui/winit/rfd)
if: runner.os == 'Linux'
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 \
@@ -45,17 +99,50 @@ jobs:
with:
components: clippy, rustfmt
# 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.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
- name: Test
run: cargo test --workspace --locked
run: cargo test --workspace --locked --features heif
# 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'
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
# 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).
- name: Check out craft-fonts
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace --locked --features heif
- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
run: cargo xtask layers
@@ -73,12 +160,21 @@ jobs:
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
name: corpus tests
runs-on: ubuntu-latest
env:
# test-corpus builds with --release, but here without the release profile's thin LTO and
# single codegen unit: they ran LTO over the dependency graph for each of the ~95 corpus test
# binaries, so building took 23.5 of the job's 37 minutes while the tests ran for 1.5.
# Turning them off builds 2.7x faster; the code under test is the same.
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: corpus
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Restore test corpora
uses: actions/cache@v4
with:
@@ -100,9 +196,55 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Check wasm-safe crates
run: cargo xtask wasm
web:
name: web build and wasm size gate
# PRs run it only when web-relevant files change (see `platforms`); main always does.
needs: platforms
if: needs.platforms.outputs.web == 'true'
runs-on: ubuntu-latest
env:
# Match release.yml so PR results reflect what would actually ship.
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: ci-web
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
# wasm limit and exercises the same packaging step used by release.yml.
- name: Build and verify browser release
run: packaging/web/package.sh
- name: Report wasm size
if: success()
run: |
{
echo "### Browser WebAssembly size"
echo
echo "| File | Bytes | MiB | Limit |"
echo "| --- | ---: | ---: | ---: |"
for wasm in dist/web/*.wasm; do
[ -f "$wasm" ] || continue
bytes=$(wc -c < "$wasm" | tr -d ' ')
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
done
} >> "$GITHUB_STEP_SUMMARY"
fuzz:
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
# Placeholder: manual trigger only until a nightly schedule is enabled.
@@ -114,11 +256,15 @@ jobs:
target:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
- { crate: codecs, name: decode_tiff }
- { crate: codecs, name: decode_exr }
- { crate: codecs, name: decode_heif }
- { crate: affinity, name: preview }
- { crate: affinity, name: import_preview, args: "--features io" }
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
@@ -130,7 +276,7 @@ jobs:
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
run: cargo fuzz run ${{ matrix.target.name }} -- -max_total_time=${{ inputs.fuzz_seconds }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
+12 -8
View File
@@ -5,16 +5,15 @@
# uploaded and no secrets are used.
name: FreeBSD
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
on:
push:
branches: [main]
schedule:
- cron: "17 2 * * *"
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- .github/workflows/release.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
@@ -51,8 +50,13 @@ jobs:
# 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
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
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
+7 -40
View File
@@ -19,45 +19,12 @@ concurrency:
jobs:
lint:
# 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/packaging-lint.yml@main
verified-appimage-inputs:
name: Verified AppImage inputs
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
./packaging/windows/check-icons.ps1
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/photocraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash packaging/linux/verified-download-test.sh
+17 -347
View File
@@ -1,9 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "PhotoCraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
name: Release
on:
@@ -17,351 +11,27 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### PhotoCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Install build and packaging tools
run: |
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 \
desktop-file-utils appstream python3-yaml
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/photocraft$|metainfo|applications'
ldd target/release/photocraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
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 --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/photocraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_VERSION"; fi
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$'
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="PhotoCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# 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/release.yml@main
with:
app-name: photocraft
app-title: PhotoCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+31
View File
@@ -0,0 +1,31 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
# change the Windows packaging itself.
on:
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * *"
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-arm64:
# 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/windows-arm64.yml@main
with:
app-name: photocraft
app-dir: PhotoCraft
+3
View File
@@ -8,3 +8,6 @@ target/
/secrets
/temp
/book/output
/craft-fonts
*.log
__pycache__/
+37 -11
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
@@ -11,8 +11,12 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
| `ROADMAP.md` | One-page summary: stage, the two parity numbers, every dimension, languages, what's next |
| `docs/target-app-parity.md` | The authoritative parity assessment against Photoshop 2026: numbers, weights, evidence, hours |
| `docs/gaps.md` | Every known shortfall, ranked: the work list to pick from |
| `docs/roadmap.md` | Milestones, **current focus**, what's next with estimates |
| `docs/ui-parity.md`, `docs/brush-parity.md`, `docs/context-menu-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` | Per-dimension parity checklists |
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
@@ -57,7 +61,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
@@ -70,15 +74,16 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
round trips, workflow tests, performance), update that section with the dated figure.
0. **Read `ROADMAP.md`, then `docs/gaps.md`.** `docs/target-app-parity.md` says, dimension by
dimension, where PhotoCraft is lacking; `docs/gaps.md` ranks the work. `docs/parity-checklist.md`
(menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap,
update the parity doc and gap entry it belongs to, with the date, and bump their status line
and revision history (craftrules `standards/progress-docs.md`).
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
the performance scenarios that are over budget or not measurable yet, and the count of
settings that do nothing. Its numbers are measured; prefer them to estimates.
2. `docs/roadmap.md` → **Current focus**.
3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
3. `cargo xtask parity` → `docs/parity-checklist.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
4. `log/devlog.md` → the "Still open" bullets of recent entries.
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
@@ -90,7 +95,7 @@ cargo test -p <crates you touched>
cargo clippy -p <crates> --all-targets -- -D warnings
cargo xtask layers
cargo xtask wasm # if you touched L0–L6
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
@@ -99,6 +104,8 @@ cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --upd
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
```
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
@@ -117,7 +124,8 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
- `docs/parity.md`: generated Photoshop menu coverage.
- `ROADMAP.md`, `docs/target-app-parity.md`, `docs/gaps.md` and the `docs/*-parity.md` checklists: where we stand against Photoshop.
- `docs/parity-checklist.md`: generated Photoshop menu coverage.
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
@@ -131,3 +139,21 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
nothing is silently dropped from `.pcraft` saves. When you add a doc field, add it to
`crates/format/src/manifest.rs` and `convert.rs` with `#[serde(default)]` so older files still load.
If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown PSD blocks verbatim.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
+49 -2
View File
@@ -10,15 +10,57 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/pl.tsv` | Polish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-add-anchor.svg`, `assets/icons/pen-delete-anchor.svg` | Add Anchor Point and Delete Anchor Point tool glyphs: Lucide `pen-tool` plus a + or − mark | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from <https://github.com/lucide-icons/lucide> `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/symmetry-{vertical,horizontal,dual,diagonal}.svg` | Painting symmetry axis glyphs | @shorbank | Original work; supplied SVG line geometry centred on a common square canvas, stroke adjusted to match the painting-symmetry glyph | MIT OR Apache-2.0, [`assets/icons/LICENSE-symmetry-axes.txt`](assets/icons/LICENSE-symmetry-axes.txt) |
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
### Optional build input: craft-fonts (not files in this repo)
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
from it is committed here. Per-file authors, sources and licences:
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
| Font (embedded at build time) | Use | License |
|---|---|---|
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
@@ -30,6 +72,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|---|---|---|---|---|
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
@@ -58,6 +101,10 @@ next to the files:
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
Generated
+1242 -52
View File
File diff suppressed because it is too large. Load diff
+29 -7
View File
@@ -1,15 +1,16 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
photocraft-geom = { path = "crates/geom" }
photocraft-color = { path = "crates/color" }
@@ -20,6 +21,7 @@ photocraft-ops = { path = "crates/ops" }
photocraft-psd = { path = "crates/psd" }
photocraft-codecs = { path = "crates/codecs" }
photocraft-raw = { path = "crates/raw" }
photocraft-affinity = { path = "crates/affinity" }
photocraft-compose = { path = "crates/compose" }
photocraft-gpu = { path = "crates/gpu" }
photocraft-paint = { path = "crates/paint" }
@@ -43,6 +45,9 @@ arc-swap = "1"
bytemuck = { version = "1", features = ["derive"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
image = { version = "0.25", default-features = false }
# Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg.
zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
egui = "0.36"
@@ -65,6 +70,10 @@ opt-level = 2
[profile.dev]
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.
debug = "line-tables-only"
[profile.release]
lto = "thin"
@@ -72,11 +81,10 @@ codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
@@ -87,6 +95,20 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
# retain their speed-oriented optimization level.
[profile.wasm-release.package.photocraft-ui-egui]
opt-level = "z"
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
# builds and the speed-oriented pixel-processing overrides below retain their settings.
[profile.wasm-release.package.photocraft-engine]
opt-level = "z"
[profile.wasm-release.package.photocraft-io]
opt-level = "z"
[profile.wasm-release.package.photocraft-web]
opt-level = "z"
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
+68
View File
@@ -0,0 +1,68 @@
# syntax=docker/dockerfile:1
# Community-maintained self-hosting recipe (see packaging/web/README.md#docker); the official
# web build is the `web` job in .github/workflows/release.yml (packaging/web/package.sh).
#
# Source of truth for the toolchain: release.yml and ci.yml build with the latest *stable* Rust
# (dtolnay/rust-toolchain@stable), not a pinned version. This default is the stable release CI
# used when it was last bumped; raise it when CI's stable moves on. It must stay >= the
# workspace `rust-version` in Cargo.toml.
ARG RUST_VERSION=1.99
ARG NGINX_VERSION=1.28-alpine
FROM rust:${RUST_VERSION}-slim-bookworm AS builder
# Must match `TRUNK_VERSION` in the `web` job of .github/workflows/release.yml.
ARG TRUNK_VERSION=0.21.14
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add wasm32-unknown-unknown
RUN set -eu; \
case "$(uname -m)" in \
x86_64) arch=x86_64 ;; \
aarch64) arch=aarch64 ;; \
*) echo 'The web builder supports amd64 and arm64' >&2; exit 1 ;; \
esac; \
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
cd /tmp; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
tar -xzf "$asset" -C /usr/local/bin trunk; \
rm "$asset" "$asset.sha256"; \
trunk --version
WORKDIR /src
COPY . .
WORKDIR /src/apps/photocraft-web
# Docker serving has no static-host per-file size cap. Thin LTO uses less build
# memory than the release zip's fat LTO; retain the profile's other optimizations.
ARG CARGO_PROFILE_WASM_RELEASE_LTO=thin
ARG CARGO_BUILD_JOBS=1
# Optional build provenance shown in About and `--version` (crates/engine/src/build_info.rs).
# .git is not in the build context, so pass it in, e.g.
# --build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F)
# Left empty, the build reports itself as a dev build.
ARG PHOTOCRAFT_BUILD_SHA=
ARG PHOTOCRAFT_BUILD_DATE=
# index.html selects the wasm-release profile, HEIF support and wasm-opt -Oz.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
--mount=type=cache,target=/root/.cache/trunk \
trunk build --release --locked \
&& test -s /src/dist/web/index.html \
&& gzip -9 -k /src/dist/web/*.wasm /src/dist/web/*.js /src/dist/web/index.html
FROM nginx:${NGINX_VERSION} AS runtime
COPY packaging/web/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /src/dist/web/ /usr/share/nginx/html/
COPY LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md /usr/share/nginx/html/
COPY assets/fonts/OFL-*.txt assets/icons/LICENSE-*.txt assets/dict/LICENSE-*.txt \
crates/ui-egui/src/i18n/LICENSE-translations.txt /usr/share/nginx/html/licenses/
# Bypass the stock entrypoint: this configuration needs no runtime rewriting.
USER nginx
EXPOSE 8080
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
+5
View File
@@ -16,3 +16,8 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
ship with the package as OFL-<family>.txt.
+83 -10
View File
@@ -52,6 +52,7 @@
<a href="#built-for-agents">Agents</a> ·
<a href="#under-the-hood">Under the hood</a> ·
<a href="#get-started">Get started</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">Crafting Apps</a> ·
<a href="https://discord.gg/artcraft">Discord</a>
</p>
@@ -121,7 +122,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<br>
<sub>A headline edited in place, with a byline and a paragraph of body text.<br><i>Among the Sierra Nevada, California</i>, Albert Bierstadt, 1868</sub>
<h3>Type that sets beautifully</h3>
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour.
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour. The <a href="docs/color-picker.md">full Type Color Picker</a> also supports screen sampling with a pixel loupe.
<br><br>
Type layers stay editable, take layer styles, and round-trip through PSD.
</td>
@@ -132,7 +133,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<br>
<sub>A badge made of shape layers: a gradient-filled lotus, a star and a dotted ring.<br><i>Water Lilies</i>, Claude Monet, 1906</sub>
<h3>Pixel-perfect vectors</h3>
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen tool, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen, Freeform Pen and Curvature Pen tools, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
<br><br>
Combine shapes (unite, subtract, intersect, exclude), keep paths in the Paths panel, use them as vector masks, or stroke and fill them. 116 of 116 shape layers in our PSD corpus match Photoshop's pixels.
</td>
@@ -177,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 34 tools</h4>
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
<h4>🧰 67 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
@@ -186,7 +187,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗃️ Layers, done properly</h4>
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 blend modes, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
</td>
</tr>
<tr>
@@ -198,11 +199,11 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, and the native <code>.pcraft</code> format.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
Auto Tone, Contrast and Color · Equalize · Image and Canvas Size · Crop and Trim · Reveal All · Edit › Fill and Stroke · Copy Merged · Paste in Place · guides, rulers, grid and snapping · Actions record and replay · a command palette (⌘K).
Auto Tone, Contrast and Color · Equalize · Image and Canvas Size · Crop and Trim · Reveal All · Edit › Fill and Stroke · Copy Merged · Paste in Place · guides, rulers, grid and snapping · Actions record and replay · a command palette (⌘F).
</td>
</tr>
</table>
@@ -231,6 +232,9 @@ photocraft-cli run wave.psd \
# Apply one action list to a folder of images
photocraft-cli batch --actions grade.json --in ./raw --out ./graded
# Every subcommand explains itself
photocraft-cli batch --help
# Let an agent drive it over MCP (headless, or bridged to the running app)
photocraft-cli mcp
```
@@ -255,6 +259,13 @@ cargo run --release -p photocraft -- image.psd # the desktop app
cargo test --workspace # the test suite
```
Japanese fonts for the UI and Type tool come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (desktop release builds always include it). Without it PhotoCraft uses your system's CJK fonts:
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
```
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
@@ -264,6 +275,18 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
flatpak run ai.storyteller.photocraft
```
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
```sh
ditto -x -k photocraft-cli-<version>-macos-universal.zip .
spctl --assess --type install -vv photocraft-cli-<version>-macos-universal/photocraft-cli
# ... accepted, source=Notarized Developer ID
```
On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there:
```sh
@@ -275,12 +298,14 @@ photocraft
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Documentation
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
To self-host the browser version with Docker, see [Docker web hosting](packaging/web/README.md#docker).
## Security
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
@@ -292,6 +317,53 @@ PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
## Downloads
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `photocraft-<ver>-windows-x64.msi` | `photocraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `photocraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `photocraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `photocraft-<ver>-linux-x86_64.AppImage` | `photocraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `photocraft-<ver>-linux-x86_64.flatpak` | `photocraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `photocraft-<ver>-linux-x86_64.deb` | `photocraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `photocraft-<ver>-linux-x86_64.rpm` | `photocraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `photocraft-<ver>-linux-x86_64.tar.gz` | `photocraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `photocraft-<ver>-linux-riscv64.tar.gz` only, built against Ubuntu 24.04 (needs glibc 2.39+).
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `photocraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `photocraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
## The Crafting Apps
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
@@ -304,7 +376,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
@@ -358,5 +430,6 @@ Forks and modified versions must remove them.
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
ArtCraft
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/photocraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
+142
View File
@@ -0,0 +1,142 @@
# PhotoCraft roadmap
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
(methodology, weights, evidence), [gaps](docs/gaps.md) (the ranked work list),
[roadmap](docs/roadmap.md) (milestones, current focus), [scorecard](docs/scorecard.md) and
[menu checklist](docs/parity-checklist.md) (both generated and measured),
[architecture](docs/architecture.md).
Hours are Opus 5.5 agent wall-clock hours for one agent working sequentially, calibrated from this
repository's 1,004 merged PRs (see [calibration](docs/target-app-parity.md#remaining-effort-and-calibration)).
About 80% of the work parallelizes.
## Headline numbers
| | Value | Kind |
|---|---|---|
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 56/68, panels 30/35, formats 13+4 of 31 |
| **Ready for real work** | **~45%** (40–50%) | estimated: weighted by dimension below |
| Mainstream practitioner | ~43% (38–48%) | estimated: weekly areas of a typical pro, with discounts ([method](docs/target-app-parity.md#mainstream-practitioner-43)) |
| Essentials user | ~61% (55–67%) | estimated: core features only ([method](docs/target-app-parity.md#essentials-user-61)) |
| Remaining to **beta** | **~1,100–1,800 h** | estimated |
| Remaining to **full parity** | **~2,300–4,100 h** | estimated; AI and plug-ins need owner decisions |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent-hours to ~95% | Work that dominates |
|---|---:|---:|---|
| Full target (ready for real work) | ~45% (additive weighted sum: 46%) | 2,300–4,100 | Everything below, plus AI tier, plug-in/scripting ecosystem, video, localization, hardware |
| Mainstream practitioner | ~43% | 1,700–2,800 | Depth in the 11 weekly areas (1,180–2,000), performance budgets, stability on real machines, PSD exchange with Photoshop |
| Essentials user | ~61% | 400–700 | Startup and GPU robustness, brush and transform responsiveness, Type tool, discoverability (flyouts, commit buttons), AVIF/PDF open |
Hours calibrated as in [Remaining effort](docs/target-app-parity.md#remaining-effort-and-calibration) (2–9 h per checklist
item, 8–20 h per format or subsystem, from the repo's 1,004 merged PRs); each tier is a subset of
the one above. About 80% parallelizes (independent crates and command modules); performance
architecture and the PSD writer are the serial parts.
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
four are partial in depth, none blocked.
## By dimension
| Dimension | % ready | Hours to full | Doc |
|---|---:|---:|---|
| Features (depth) | 55% (breadth 76%) | 600–1,000 | [target-app-parity](docs/target-app-parity.md#feature-areas) |
| UI/UX fidelity (tools, handles, modifiers, brush feel, panels) | 40% | 350–600 | [ui-parity](docs/ui-parity.md), [brush-parity](docs/brush-parity.md), [context-menu-parity](docs/context-menu-parity.md) |
| File formats | 60% (presence ~80%) | 300–500 | [file-format-parity](docs/file-format-parity.md) |
| Hardware (GPU, pen, displays) | 50% | 80–150 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 55% | 80–140 | [localization-parity](docs/localization-parity.md) |
| Performance | 25% (3/25 budgets met, measured) | 200–350 | [scorecard](docs/scorecard.md#performance) |
| Stability | 45% | 150–250 | [gaps](docs/gaps.md#g3-user-reported-bug-backlog) |
| Platforms | 80% | 30–60 | [scorecard](docs/scorecard.md#distribution) |
| Ecosystem / plug-ins / scripting | 15% | 250–450 | [gaps](docs/gaps.md#g16-plug-in-compatibility) |
| AI features | 10% | 300–600 | [gaps](docs/gaps.md#g15-ai-and-generative-features) |
## Features
| Area | % ready | Hours |
|---|---:|---:|
| Layers, masks, blend modes, smart objects, styles | 65% | 150–250 |
| Selections, Select and Mask, channels | 55% | 120–200 |
| Painting and brush engine | 55% | 150–250 |
| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 |
| Transform, warp, puppet, liquify | 55% | 100–180 |
| Adjustments and filters | 70% | 120–200 |
| Type | 40% | 120–200 |
| Vector shapes and paths | 55% | 80–140 |
| Colour management and modes | 70% | 60–100 |
| Camera Raw and computational photo | 45% | 120–200 |
| Automation, actions, scripting | 45% | 120–200 |
| File I/O | 60% | 300–500 |
| Print, export, Save for Web | 50% | 60–100 |
| Video / timeline, data-driven graphics | 30% | 100–200 |
| Workspace, panels, preferences | 40% | 150–250 |
| AI / generative | 10% | 300–600 |
Rows and evidence: [target-app-parity › Feature areas](docs/target-app-parity.md#feature-areas).
## Languages
Measured with the `cargo xtask i18n-coverage` key universe (1,608 keys). `partial` everywhere
because engine messages are still English. Detail: [localization-parity](docs/localization-parity.md).
| Language | Code | UI strings | Status |
|---|---|---:|---|
| English | `en` | 100% | full |
| Simplified Chinese | `zh-hans` | 100% | partial |
| Spanish | `es` | 100% | partial |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 0% (RTL shell only) | none |
| French | `fr` | 100% | partial |
| Portuguese (Brazil) | `pt-br` | 100% | partial |
| Indonesian | `id` | 100% | partial |
| Japanese | `ja` | 100% | partial |
| German | `de` | 100% | partial |
| Korean | `ko` | 100% | partial |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
## Upcoming
| Rank | Milestone | Estimate |
|---|---|---:|
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
| 3 | User bug backlog to under 100 open | 250–400 h |
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
| 5 | The 12 missing tools | 60–100 h |
Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
## Progress log
| Date | Entry |
|---|---|
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
| 2026-10-07 | 0.3.0. 10 UI languages; shortcut audit 214 → 0 failures; TIFF decode 10× faster |
| 2026-10-05 | 0.2.0, first signed and notarized release. Honest assessment: ready for real work 25–35% |
| 2026-10-03 | Menu breadth 625/625 |
| 2026-10-01 | Menu breadth 224 → 532 of 625 in a day |
| 2026-09-30 | First commit |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
| 2026-10-10 | major | First version, per craftrules `standards/progress-docs.md` |
+1 -1
View File
@@ -49,4 +49,4 @@ General bugs without a security impact should use the project's normal issue and
## Current limitations
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. These protections do not provide a capability model: an authenticated client still receives the complete exposed control surface. Automation file operations are not restricted to configured workspace roots, and JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. Authentication does not provide per-client or per-tool capabilities, so an authenticated client still receives the exposed control surface. Remote filesystem methods use separately configured read and write workspace roots, and engine commands that still use ambient filesystem paths are denied at the automation boundary. Automation can change individual safe preferences, but whole-preferences updates and filesystem-bearing preference sections are refused; applying the Preferences dialog through automation is also refused. JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
+10
View File
@@ -4,6 +4,12 @@ description = "Headless Photocraft: convert, inspect, run commands, batch, MCP s
version.workspace = true
edition.workspace = true
license.workspace = true
build = "build.rs"
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
@@ -25,3 +31,7 @@ photocraft-codecs = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+36
View File
@@ -0,0 +1,36 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `photocraft-cli.exe`.
//!
//! On every other target this does nothing, even when `PHOTOCRAFT_REQUIRE_WINRES` is set. A
//! missing resource compiler is a warning, so a cross-compile from macOS or Linux still links,
//! unless `PHOTOCRAFT_REQUIRE_WINRES=1` (set by the release packaging script) turns it into a
//! build error.
fn main() -> std::io::Result<()> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/photocraft.ico");
println!("cargo:rerun-if-env-changed=PHOTOCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return Ok(());
}
let mut res = winresource::WindowsResource::new();
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
// `1.2.3-rc.1` stays in the strings; the numeric fields are `1.2.3.0`.
res.set_icon("../../assets/app-icon/photocraft.ico")
.set("ProductName", "PhotoCraft")
.set("FileDescription", "PhotoCraft command-line interface")
.set("CompanyName", "Learning Machines LLC")
.set("LegalCopyright", "Copyright (c) the PhotoCraft authors. MIT OR Apache-2.0.")
.set("OriginalFilename", "photocraft-cli.exe")
.set("InternalName", "photocraft-cli");
if let Err(err) = res.compile() {
if std::env::var_os("PHOTOCRAFT_REQUIRE_WINRES").is_some() {
return Err(std::io::Error::other(format!(
"embedding Windows resources into photocraft-cli.exe failed: {err}. \
Install the Windows SDK resource compiler (rc.exe) or mingw-w64 windres and rebuild."
)));
}
println!("cargo:warning=photocraft-cli.exe built without icon/version resources: {err}");
}
Ok(())
}
+391 -72
View File
@@ -7,6 +7,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
use photocraft_engine::actions_cmds::Action;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
@@ -14,15 +15,25 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>]
Apply an action list ([{\"command\": id, \"params\": {…}}, …]) to every image in a directory.
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
when the set holds more than one) with the whole set loaded, so its steps can play other actions
of the set. An --out folder that is the --in folder is refused, as the results would replace
the originals; --in-place allows it.
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
Run a droplet (File › Automate › Create Droplet) on images and folders.
photocraft-cli commands [--json] [--filter <text>]
@@ -33,8 +44,12 @@ USAGE:
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, engine.commands, doc.open/new/save/inspect/render/
select/close, session.list, batch, methods (docs/control-protocol.md#headless-server).
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
an error.
";
struct Args {
@@ -42,43 +57,77 @@ struct Args {
flags: Vec<(String, Option<String>)>,
}
const VALUE_FLAGS: &[&str] = &[
"--format",
"--quality",
"--new",
"--cmd",
"--params",
"--out",
"--actions",
"--in",
"--filter",
"--bridge",
"--port",
"--control-token",
"--control-token-file",
"--automation-read-root",
"--automation-write-root",
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
struct Subcommand {
name: &'static str,
values: &'static [&'static str],
bare: &'static [&'static str],
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
Subcommand {
name: "mcp",
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, _| mcp(a),
},
Subcommand {
name: "serve",
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, err| serve(a, err),
},
];
fn parse(args: &[String]) -> Result<Args, String> {
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
let mut i = 0;
while i < args.len() {
let s = &args[i];
while let Some(s) = args.get(i) {
if s == "--help" || s == "-h" {
return Ok(None);
}
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
if sub.bare.contains(&k) {
return Err(format!("{k} takes no value"));
}
if !sub.values.contains(&k) {
return Err(unknown(k));
}
a.flags.push((k.to_owned(), Some(v.to_owned())));
} else if VALUE_FLAGS.contains(&s.as_str()) {
} else if sub.values.contains(&s.as_str()) {
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
a.flags.push((s.clone(), Some(v.clone())));
i += 1;
} else if s.starts_with("--") {
} else if sub.bare.contains(&s.as_str()) {
a.flags.push((s.clone(), None));
} else if s.starts_with("--") {
return Err(unknown(s));
} else {
a.positional.push(s.clone());
}
i += 1;
}
Ok(a)
Ok(Some(a))
}
impl Args {
@@ -92,28 +141,30 @@ impl Args {
type R = Result<(), String>;
/// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error
/// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts
/// the process on one (issue #1108).
pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let mut strings = Vec::with_capacity(args.len());
for (i, a) in args.iter().enumerate() {
match a.clone().into_string() {
Ok(s) => strings.push(s),
Err(raw) => {
let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{USAGE}", i + 1, raw.to_string_lossy());
return 2;
}
}
}
run(&strings, out, err)
}
/// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage).
pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let Some(cmd) = args.first() else {
let _ = write!(err, "{USAGE}");
return 2;
};
let parsed = match parse(&args[1..]) {
Ok(p) => p,
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
}
};
let r = match cmd.as_str() {
"convert" => convert(&parsed, out, err),
"info" => info(&parsed, out),
"run" => run_cmds(&parsed, out, err),
"batch" => batch(&parsed, out, err),
"commands" => commands(&parsed, out),
"droplet" => droplet(&parsed, out, err),
"mcp" => mcp(&parsed),
"serve" => serve(&parsed, err),
match cmd.as_str() {
"-h" | "--help" | "help" => {
let _ = write!(out, "{USAGE}");
return 0;
@@ -122,12 +173,25 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
return 0;
}
other => {
let _ = writeln!(err, "error: unknown command `{other}`\n\n{USAGE}");
_ => {}
}
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
return 2;
};
let parsed = match parse(sub, &args[1..]) {
Ok(Some(p)) => p,
// `<subcommand> --help`: usage, without reading inputs or starting a server.
Ok(None) => {
let _ = write!(out, "{USAGE}");
return 0;
}
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
}
};
match r {
match (sub.run)(&parsed, out, err) {
Ok(()) => 0,
Err(e) => {
let _ = writeln!(err, "error: {e}");
@@ -137,10 +201,17 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
}
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
let mut o = ExportOptions::default();
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
if let Some(q) = a.get("--quality") {
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
o.encode.jpeg_quality = q.clamp(1, 100);
let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
if let Some(b) = a.get("--tga-bits") {
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
}
Ok(o)
}
@@ -151,6 +222,12 @@ fn warn_all(err: &mut dyn Write, ws: &[String]) {
}
}
/// The engine knows which type families aren't installed. Warn before an export
/// uses a fallback face rather than silently changing the appearance of the text.
fn missing_font_warnings(fonts: Vec<String>) -> Vec<String> {
fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect()
}
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
.map_err(|error| error.to_string())
@@ -160,9 +237,11 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
}
@@ -179,7 +258,11 @@ fn info(a: &Args, out: &mut dyn Write) -> R {
let mut h = Headless::trusted_local();
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
doc["warnings"] = opened["warnings"].clone();
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String));
}
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file);
// Drop per-session noise.
if let Value::Object(m) = &mut doc {
@@ -207,6 +290,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
}
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
@@ -216,7 +300,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
h.command_run("file.new", p).map_err(|e| e.to_string())?;
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
}
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
}
@@ -224,38 +308,93 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
print_json(out, &json!({"command": id, "result": r}), true)?;
let new_warnings: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &new_warnings);
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
}
print_json(out, &result, true)?;
}
// A `run` with only `--out` still reports missing fonts before exporting.
let remaining: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
}
Ok(())
}
/// Parse an actions file: `[{"command": id, "params": {…}}]` or
/// `{"actions": [...]}`; `"id"` is accepted for `"command"`.
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = match &v {
Value::Array(a) => a.clone(),
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
_ => return Err("actions JSON: expected an array".into()),
steps_of(&v)
}
fn steps_of(v: &Value) -> Result<Steps, String> {
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string())
}
/// Engine commands to run in order: `(id, params)`.
pub type Steps = Vec<(String, Value)>;
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = v.get("actions").unwrap_or(&v);
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
if !is_set {
if name.is_some() {
return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into());
}
return Ok((steps_of(&v)?, Vec::new()));
}
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?;
let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, set.as_slice()) {
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?,
(None, [only]) => only,
(None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action <name> ({})", set.len(), names())),
};
list.into_iter()
.enumerate()
.map(|(i, a)| {
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
})
.collect()
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, set))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
fn is_input(p: &Path) -> bool {
let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read);
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
@@ -264,22 +403,45 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let actions = parse_actions(&text)?;
let (actions, set) = load_actions(&text, a.get("--action"))?;
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
return Err(format!(
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
out_dir.display()
));
}
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let mut inputs: Vec<PathBuf> =
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>, String> {
written.check(&target_text)?;
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input).map_err(|e| e.to_string())?;
for (id, p) in &actions {
h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
continue;
}
let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
return Err(format!("`{id}`: {e}"));
}
}
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
@@ -287,6 +449,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
@@ -382,3 +545,159 @@ fn mcp(a: &Args) -> R {
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod missing_font_warning_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let mut output = Vec::new();
let mut errors = Vec::new();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
}
#[test]
fn missing_typeface_is_reported_by_run_info_and_convert() {
let family = "DefinitelyMissingPhotoCraftTypeface1251";
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("missing.pcraft");
let png = folder.join("missing.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
let (code, stdout, stderr) = invoke(&[
"run",
"--new",
r#"{"width":256,"height":96,"background":"white"}"#,
"--cmd",
"type.create",
"--params",
&params,
"--cmd",
"type.resolveMissingFonts",
"--out",
&native_str,
]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
assert_eq!(commands.len(), 2, "{stdout}");
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
assert!(png.is_file());
// Even with no commands, run --out checks the opened document before export.
let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn convert_reads_and_writes_openraster() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let path = |name: &str| folder.join(name).to_string_lossy().to_string();
let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png"));
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]);
assert_eq!(code, 0, "{stderr}");
let (code, _, stderr) = invoke(&["convert", &native, &ora]);
assert_eq!(code, 0, "{stderr}");
let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}");
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn no_type_layers_do_not_produce_missing_font_warnings() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("blank.pcraft");
let png = folder.join("blank.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
}
#[cfg(test)]
mod batch_action_set_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut output, mut errors) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
}
/// A batched action that plays another action of its set finds it in every file's session; a
/// failure inside the called action fails the file, and a self-call stops instead of looping.
#[test]
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
for name in ["a", "b"] {
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
assert_eq!(code, 0, "{stderr}");
}
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
let (code, stdout, stderr) = batch("Outer");
assert_eq!(code, 0, "{stdout}{stderr}");
for name in ["a", "b"] {
let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
}
for action in ["Calls bad", "Loop"] {
let (code, stdout, stderr) = batch(action);
assert_ne!(code, 0, "{stdout}");
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+4 -2
View File
@@ -1,7 +1,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let code = photocraft_cli::run(&args, &mut std::io::stdout(), &mut std::io::stderr());
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a
// usage error, not a panic before anything is printed (issue #1108).
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr());
std::process::exit(code);
}
+405 -2
View File
@@ -43,6 +43,63 @@ fn usage_and_unknown_command() {
assert!(out.starts_with("photocraft-cli "));
}
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
/// written, no server started), whatever else is on the line.
#[test]
fn every_subcommand_answers_help() {
let d = tmp("help");
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
for help in ["--help", "-h"] {
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
}
}
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
}
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
#[test]
fn unknown_flags_are_usage_errors() {
let d = tmp("flags");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let out_dir = d.join("out");
let fails = |cmd: &mut Command, flag: &str| {
let o = cmd.output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
};
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
assert!(!out_dir.exists(), "nothing written");
}
let x = d.join("x.png");
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
assert!(!x.exists());
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
"80",
]));
assert!(out_dir.join("a.jpg").exists());
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
assert!(!out.trim().contains('\n'), "compact JSON is one line");
}
#[test]
fn convert_png_pcraft_png_is_lossless() {
let d = tmp("convert");
@@ -79,13 +136,45 @@ fn info_prints_layer_tree_json() {
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["width"], 10);
assert_eq!(v["height"], 6);
assert_eq!(v["layers"][0]["name"], "Background");
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
assert_eq!(v["layers"][0]["name"], "Layer 0");
assert!(v.get("history").is_none());
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
assert_eq!(out.trim().lines().count(), 1);
std::fs::remove_dir_all(d).unwrap();
}
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
#[test]
fn info_and_convert_report_decode_warnings() {
let d = tmp("decode-warnings");
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
// Three 1x1 frames.
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
for _ in 0..3 {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
let cases = [
("whole.jpg", &jpeg[..], None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
];
for (name, bytes, warning) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_commands_and_save() {
let d = tmp("run");
@@ -100,9 +189,11 @@ fn run_commands_and_save() {
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
assert_eq!(lines[2]["result"]["name"], "Run");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["name"], "Run");
// Saving a native document gives the reopened bundle its output filename.
assert_eq!(v["name"], "r.pcraft");
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
assert_eq!(names, ["Ink", "Background"]);
// Continue from the saved file and export PNG.
@@ -121,6 +212,33 @@ fn run_errors() {
assert_eq!(o.status.code(), Some(1));
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
// Params must be a JSON object, for `--new` and for each `--params`.
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let e = String::from_utf8_lossy(&o.stderr);
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let e = String::from_utf8_lossy(&o.stderr);
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
}
#[test]
fn run_rejects_invalid_filter_parameters_without_exporting() {
let d = tmp("invalid-filter-params");
let out = d.join("result.png");
for (params, key) in [(r#"{"radius":"big"}"#, "radius"), (r#"{"radus":3}"#, "radus"), (r#"{"radius":5000}"#, "radius"), (r#"{"radius":-4}"#, "radius")] {
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#, "--cmd", "filter.blur.gaussianBlur", "--params", params, "--out"])
.arg(&out)
.output()
.unwrap();
assert_eq!(result.status.code(), Some(1));
let error = String::from_utf8_lossy(&result.stderr);
assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}");
assert!(!out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
@@ -148,6 +266,37 @@ fn batch_applies_actions_to_directory() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn batch_skips_recorded_view_and_save_steps() {
let d = tmp("batch-shell-steps");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
let source = input.join("a.png");
write_png(&source, 12, 8, 3);
let original = std::fs::read(&source).unwrap();
let recorded_save = d.join("recorded-save.psd");
let mut cases = Vec::new();
for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] {
cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string()));
}
cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string()));
cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string()));
for (name, actions) in cases {
let o = batch_with(&d, &input, name, &actions, &[]);
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
let output = d.join(name).join("a.png");
let (info, _) = ok(bin().arg("info").arg(&output));
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn parse_actions_formats() {
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
@@ -155,6 +304,183 @@ fn parse_actions_formats() {
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
assert!(photocraft_cli::parse_actions("7").is_err());
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
for text in [
r#"[["a",{"x":1}],["b"]]"#,
r#"[["a",{"x":1}],"b"]"#,
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
] {
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
}
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
}
}
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
let file = d.join(format!("{name}.json"));
std::fs::write(&file, actions).unwrap();
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
}
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
/// as the object form; a malformed step fails before anything is written.
#[test]
fn batch_accepts_recorded_action_steps() {
let d = tmp("batch-steps");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 6, 5, 9);
let shapes = [
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
("pairs", r#"[["image.adjustments.invert",{}]]"#),
("ids", r#"["image.adjustments.invert"]"#),
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
];
for (name, actions) in shapes {
let o = batch_with(&d, &input, name, actions, &[]);
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
for f in ["a.png", "b.png"] {
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
}
}
let o = batch_with(&d, &input, "bad", "[42]", &[]);
assert_eq!(o.status.code(), Some(1));
assert!(!d.join("bad").exists(), "nothing written");
}
/// #490: a leading dot on `--format` doesn't double the dot in output names.
#[test]
fn batch_format_with_leading_dot() {
let d = tmp("batch-dot");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 6, 5, 9);
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
let o = batch_with(&d, &input, name, invert, &["--format", format]);
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
}
}
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
/// and nothing is written.
#[test]
fn quality_outside_1_to_100_is_refused() {
let d = tmp("quality");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
let a = input.join("a.png");
write_png(&a, 8, 4, 3);
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
let out = d.join("q.jpg");
let (mut convert, mut run) = (bin(), bin());
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
for cmd in [&mut convert, &mut run] {
let o = cmd.output().unwrap();
assert_eq!(o.status.code(), Some(1), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
assert!(!out.exists(), "--quality {q} wrote a file");
}
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
}
// The documented range still encodes, lower quality making a smaller file.
let sizes: Vec<usize> = ["1", "50", "100"]
.iter()
.map(|q| {
let out = d.join(format!("q{q}.jpg"));
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
std::fs::read(out).unwrap().len()
})
.collect();
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
fn tga_bits_choose_the_targa_depth() {
let d = tmp("tga-bits");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] {
let out = d.join("a.tga");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--tga-bits", b]);
}
ok(&mut convert);
assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}");
}
for bad in ["16", "0", "abc", ""] {
let out = d.join("bad.tga");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
}
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
fn batch_refuses_to_write_over_its_inputs() {
let d = tmp("batch-in-place");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let snapshot = || {
let mut files: Vec<(PathBuf, Vec<u8>)> =
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
files.sort();
files
};
let before = snapshot();
let batch = |out: &Path, extra: &[&str]| {
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
};
#[cfg(unix)]
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
let link = cfg!(unix).then(|| PathBuf::from("link"));
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
for extra in [&[][..], &["--format", "tif"][..]] {
let o = batch(out, extra);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
}
}
// A separate folder runs as before, leaving the originals alone.
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
assert_eq!(snapshot(), before);
// `--in-place` opts in: each result replaces its original.
let o = batch(Path::new("in"), &["--in-place"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let after = snapshot();
assert_eq!(after.len(), 2);
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
#[test]
@@ -218,6 +544,38 @@ fn mcp_stdio_handshake_and_tool_call() {
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
let d = tmp("batch-collide");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
// Same stem in other formats; `A.tif` differs only in case.
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
}
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let out_dir = d.join("out");
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert!(
!o.status.success(),
"a lost result must fail the run
{out}
{err}"
);
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
assert_eq!(err.matches("not written").count(), 2, "{err}");
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
}
#[test]
fn droplet_runs_an_action_on_files() {
let d = tmp("droplet");
@@ -237,3 +595,48 @@ fn droplet_runs_an_action_on_files() {
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
assert!(!o.status.success(), "not a droplet");
}
#[test]
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
let d = tmp("tiff-layers");
let layer_count = |path: &std::path::Path| -> usize {
let (info, _) = ok(bin().arg("info").arg(path));
let v: Value = serde_json::from_str(&info).unwrap();
v["layers"].as_array().unwrap().len()
};
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
let out = d.join(name);
let mut cmd = bin();
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
if let Some(flag) = flag {
cmd.arg(flag);
}
ok(cmd.arg("--out").arg(&out));
assert_eq!(layer_count(&out), layers, "{name}");
}
// convert honours the same flag.
let src = d.join("layered.tif");
let flat = d.join("converted.tif");
ok(bin().arg("convert").arg(&src).arg(&flat));
assert_eq!(layer_count(&flat), 1);
let kept = d.join("kept.tif");
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
assert_eq!(layer_count(&kept), 2);
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
/// `std::env::args()` before anything was printed. It is a usage error naming the argument.
#[cfg(unix)]
#[test]
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] {
let o = bin().args(&args).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}");
assert!(!err.contains("panicked"), "{err}");
}
}
+151
View File
@@ -0,0 +1,151 @@
//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a
//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright
//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip
//! changes nothing.
use std::path::{Path, PathBuf};
use std::process::Command;
use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation};
use serde_json::Value;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn ok(cmd: &mut Command) -> String {
let o = cmd.output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
out
}
/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian.
fn exif(o: u16) -> Vec<u8> {
let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec();
v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]);
v.extend_from_slice(&o.to_le_bytes());
v.extend_from_slice(&[0, 0]);
v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]);
v.extend_from_slice(b"PC1\0");
v.extend_from_slice(&[0, 0, 0, 0]);
v
}
/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half.
fn upright_color(x: usize, y: usize) -> [u8; 3] {
match (x < 8, y < 16) {
(true, true) => [230, 20, 20],
(false, true) => [20, 230, 20],
_ => [20, 20, 230],
}
}
/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF.
fn phone_jpeg() -> Vec<u8> {
let (w, h) = (16, 32);
let mut px = vec![0u8; w * h * 3];
for y in 0..h {
for x in 0..w {
// Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image.
let (sx, sy) = (y, w - 1 - x);
px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y));
}
}
let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap();
let jpeg =
photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap();
let mut seg = b"Exif\0\0".to_vec();
seg.extend_from_slice(&exif(6));
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xFF, 0xE1]);
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&seg);
out.extend_from_slice(&jpeg[2..]);
out
}
/// The file as stored (no orientation applied), so its own dimensions and tag can be checked.
fn stored(path: &Path) -> Image {
decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap()
}
fn assert_portrait_pixels(img: &Image) {
let img = img.convert(ChannelLayout::Rgb, SampleType::U8);
for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] {
let i = (y * 16 + x) * 3;
let got = &img.data()[i..i + 3];
let want = upright_color(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}");
}
}
#[test]
fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let d = tmp("e2e");
let jpg = d.join("IMG_0006.JPG");
std::fs::write(&jpg, phone_jpeg()).unwrap();
// convert → PNG: the stored pixels are already upright, and any EXIF says 1.
let png = d.join("upright.png");
ok(bin().arg("convert").arg(&jpg).arg(&png));
let raw = stored(&png);
assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape");
assert_portrait_pixels(&raw);
if let Some(e) = raw.meta.exif.as_deref() {
assert_eq!(exif_orientation(e), 1, "written EXIF orientation");
}
assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1);
assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen");
// An explicit Image › Image Rotation › 90° Clockwise makes it landscape again.
let turned = d.join("turned.png");
ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned));
let t = stored(&turned);
assert_eq!(t.dimensions(), (32, 16));
if let Some(e) = t.meta.exif.as_deref() {
assert_eq!(exif_orientation(e), 1);
}
// The portrait's top-left (red) is now top-right; its bottom (blue) is on the left.
let t = t.convert(ChannelLayout::Rgb, SampleType::U8);
let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap();
let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12);
assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3));
assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12));
assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8));
// .pcraft save, reopen and save again: identical document and pixels.
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path and saved document name must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
v.as_object_mut().unwrap().remove("path");
v
};
let (mut ia, mut ib) = (info(&a), info(&b));
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia["name"], "a.pcraft");
assert_eq!(ib["name"], "b.pcraft");
ia.as_object_mut().unwrap().remove("name");
ib.as_object_mut().unwrap().remove("name");
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
ok(bin().arg("convert").arg(&b).arg(&pb));
let (xa, xb) = (stored(&pa), stored(&pb));
assert_eq!(xa.dimensions(), (16, 32));
assert_eq!(xa.to_rgba8(), xb.to_rgba8());
assert_eq!(xa.meta.exif, xb.meta.exif);
assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion");
std::fs::remove_dir_all(d).unwrap();
}
+11 -1
View File
@@ -6,6 +6,11 @@ edition.workspace = true
license.workspace = true
publish = false
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
@@ -29,4 +34,9 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
photocraft-paint = { workspace = true }
photocraft-psd = { workspace = true }
+15 -4
View File
@@ -4,16 +4,27 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#photocraft_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
@keyframes photocraft-stalled { to { visibility: visible; } }
</style>
</head>
<body>
<canvas id="photocraft_canvas"></canvas>
<div id="photocraft_loading">Loading PhotoCraft&hellip;</div>
<div id="photocraft_loading">
<p>Loading PhotoCraft&hellip;</p>
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
</body>
</html>
+255
View File
@@ -0,0 +1,255 @@
//! IndexedDB owns durable bytes; the engine sees only the bounded, Send memory bridge.
//! Each batch commits tips, groups and index together, and success means transaction completion.
use js_sys::{Array, Function, Promise, Uint8Array};
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
use wasm_bindgen_futures::JsFuture;
use web_sys::{Blob, Event, EventTarget, IdbDatabase, IdbRequest, IdbTransactionMode};
use crate::preset_bridge::{Batch, Bridge, MAX_RECORDS, record_limit};
use photocraft_engine::preset_store::MAX_STORE_BYTES;
const DATABASE: &str = "photocraft.presets";
const FILES: &str = "files";
const TIMEOUT_MS: f64 = 10_000.0;
fn js(e: JsValue) -> String {
if let Some(exception) = e.dyn_ref::<web_sys::DomException>() {
format!("{}: {}", exception.name(), exception.message())
} else {
e.as_string().unwrap_or_else(|| format!("{e:?}"))
}
}
/// Listeners are detached even when startup or a write times out.
type Listener = (EventTarget, &'static str, Closure<dyn FnMut(Event)>);
struct Listeners(Vec<Listener>);
impl Listeners {
fn new() -> Self {
Self(Vec::new())
}
fn add(&mut self, target: &EventTarget, name: &'static str, f: impl FnMut(Event) + 'static) -> Result<(), JsValue> {
let cb = Closure::<dyn FnMut(Event)>::new(f);
target.add_event_listener_with_callback(name, cb.as_ref().unchecked_ref())?;
self.0.push((target.clone(), name, cb));
Ok(())
}
}
impl Drop for Listeners {
fn drop(&mut self) {
for (target, name, cb) in &self.0 {
let _ = target.remove_event_listener_with_callback(name, cb.as_ref().unchecked_ref());
}
}
}
struct Pending {
promise: Promise,
_listeners: Listeners,
}
fn request(request: &IdbRequest) -> Pending {
let mut listeners = Listeners::new();
let promise = Promise::new(&mut |resolve, reject| {
let req = request.clone();
let failed = reject.clone();
if let Err(e) = listeners.add(request, "success", move |_| match req.result() {
Ok(v) => {
let _ = resolve.call1(&JsValue::NULL, &v);
}
Err(e) => {
let _ = failed.call1(&JsValue::NULL, &e);
}
}) {
let _ = reject.call1(&JsValue::NULL, &e);
}
let req = request.clone();
let failed = reject.clone();
if let Err(e) = listeners.add(request, "error", move |_| {
let e = req.error().ok().flatten().map(JsValue::from).unwrap_or_else(|| JsValue::from_str("IndexedDB request failed"));
let _ = failed.call1(&JsValue::NULL, &e);
}) {
let _ = reject.call1(&JsValue::NULL, &e);
}
});
Pending { promise, _listeners: listeners }
}
/// A single deadline bounds the whole startup read, not ten seconds per record.
async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {
let window = web_sys::window().ok_or("no window")?;
let mut timer = None;
let mut callback = None;
let timeout = Promise::new(&mut |_, reject| {
let cb = Closure::<dyn FnMut()>::new(move || {
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("browser preset storage timed out"));
});
timer = Some(window.set_timeout_with_callback_and_timeout_and_arguments_0(
cb.as_ref().unchecked_ref(),
(deadline - js_sys::Date::now()).clamp(0.0, TIMEOUT_MS) as i32,
));
callback = Some(cb);
});
let id = timer.ok_or("could not start storage timeout")?.map_err(js)?;
let result = JsFuture::from(Promise::race(&Array::of2(promise, &timeout))).await.map_err(js);
window.clear_timeout_with_handle(id);
drop(callback);
result
}
async fn open(deadline: f64) -> Result<IdbDatabase, String> {
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB unavailable")?;
let req = factory.open_with_u32(DATABASE, 1).map_err(js)?;
let mut upgrade = Listeners::new();
let upgrade_req = req.clone();
upgrade
.add(&req, "upgradeneeded", move |_| {
let result = upgrade_req.result().and_then(|v| v.dyn_into::<IdbDatabase>()).and_then(|db| db.create_object_store(FILES));
if result.is_err()
&& let Some(tx) = upgrade_req.transaction()
{
let _ = tx.abort();
}
})
.map_err(js)?;
let pending = request(&req);
match before(&pending.promise, deadline).await {
Ok(value) => value.dyn_into().map_err(|_| "invalid preset database".into()),
Err(e) => {
// An open request cannot be cancelled (another tab may block its upgrade). Keep
// its handlers alive and close any late connection; never hydrate a live session.
wasm_bindgen_futures::spawn_local(async move {
if let Ok(value) = JsFuture::from(pending.promise.clone()).await
&& let Ok(db) = value.dyn_into::<IdbDatabase>()
{
db.close();
}
drop(pending);
drop(upgrade);
});
Err(e)
}
}
}
pub async fn load() -> Result<(IdbDatabase, Bridge, Vec<String>), String> {
let deadline = js_sys::Date::now() + TIMEOUT_MS;
let db = open(deadline).await?;
let result = read(&db, deadline).await;
match result {
Ok((bridge, warnings)) => Ok((db, bridge, warnings)),
Err(e) => {
db.close();
Err(e)
}
}
}
async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec<String>), String> {
let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readonly).map_err(js)?;
let req = tx.object_store(FILES).map_err(js)?.open_cursor().map_err(js)?;
let mut records = Vec::new();
let mut warnings = Vec::new();
let mut unreadable = Vec::new();
let mut total = 0u64;
let mut seen = 0usize;
let mut next = request(&req);
loop {
let value = before(&next.promise, deadline).await?;
if value.is_null() {
break;
}
let cursor = value.dyn_into::<web_sys::IdbCursorWithValue>().map_err(|_| "invalid preset cursor")?;
seen += 1;
if seen > MAX_RECORDS {
return Err(format!("browser preset record limit ({MAX_RECORDS}) exceeded"));
}
let key = cursor.key().map_err(js)?;
// Inspect key length in JS before allocating its Rust String.
let name = if key.is_string() && js_sys::JsString::from(key.clone()).length() <= 133 { key.as_string().unwrap_or_default() } else { String::new() };
let blob = cursor.value().map_err(js)?.dyn_into::<Blob>();
let record = record_limit(&name).and_then(|limit| {
let blob = blob.map_err(|_| "record is not a Blob".to_string())?;
if blob.size() > limit as f64 {
return Err("record is too large".into());
}
Ok(blob)
});
match record {
Ok(blob) => {
total = total.saturating_add(blob.size() as u64);
if total > MAX_STORE_BYTES {
return Err("browser preset store exceeds size limit".into());
}
records.push((name, blob));
}
Err(e) => {
// Leave an unreadable placeholder in the engine's file inventory. In
// particular, a skipped group must protect its tip records from orphan GC.
if record_limit(&name).is_ok() {
unreadable.push(name.clone());
}
if warnings.len() < 8 {
warnings.push(format!("Brush presets: {name} skipped: {e}"));
}
}
}
// Register the next callback before continuing the cursor. Blob reads happen after
// enumeration, since awaiting them here would let this transaction become inactive.
next = request(&req);
cursor.continue_().map_err(js)?;
}
let bridge = Bridge::default();
for name in unreadable {
bridge.load(&name, Vec::new())?;
}
for (name, blob) in records {
let buffer = before(&blob.array_buffer(), deadline).await?;
bridge.load(&name, Uint8Array::new(&buffer).to_vec())?;
}
Ok((bridge, warnings))
}
pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> {
let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)?;
let store = tx.object_store(FILES).map_err(js)?;
let queued = (|| -> Result<(), String> {
for (name, bytes) in batch {
let key = JsValue::from_str(name);
if let Some(bytes) = bytes {
let blob = Blob::new_with_u8_array_sequence(&Array::of1(&Uint8Array::from(bytes.as_ref()))).map_err(js)?;
store.put_with_key(&blob, &key).map_err(js)?;
} else {
store.delete(&key).map_err(js)?;
}
}
Ok(())
})();
if let Err(e) = queued {
let _ = tx.abort();
return Err(e);
}
let mut listeners = Listeners::new();
let promise = Promise::new(&mut |resolve, reject| {
if let Err(e) = listeners.add(&tx, "complete", move |_| {
let _ = resolve.call0(&JsValue::NULL);
}) {
let _ = reject.call1(&JsValue::NULL, &e);
}
for event in ["abort", "error"] {
let tx = tx.clone();
let failed: Function = reject.clone();
let target: EventTarget = tx.clone().into();
if let Err(e) = listeners.add(&target, event, move |_| {
let e = tx.error().map(JsValue::from).unwrap_or_else(|| JsValue::from_str("preset transaction aborted"));
let _ = failed.call1(&JsValue::NULL, &e);
}) {
let _ = reject.call1(&JsValue::NULL, &e);
}
}
});
let result = before(&promise, js_sys::Date::now() + TIMEOUT_MS).await.map(|_| ());
if result.is_err() {
let _ = tx.abort();
}
result
}
+21
View File
@@ -16,6 +16,12 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod preset_bridge;
#[cfg(target_arch = "wasm32")]
mod indexed_presets;
#[cfg(target_arch = "wasm32")]
mod web;
@@ -28,3 +34,18 @@ fn main() {
fn main() {
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
}
#[cfg(test)]
mod tests {
/// A WebAssembly module that never downloads (or doesn't match the page) runs no Rust, so the
/// page itself replaces the endless "Loading" with what may have gone wrong and a way to
/// retry, without a hand-written script.
#[test]
fn loading_page_offers_a_reload_when_startup_stalls() {
let html = include_str!("../index.html");
let (_, hint) = html.split_once("<p id=\"photocraft_stalled\">").expect("stalled-startup hint in the loading element");
assert!(hint.contains("failed to") && hint.contains("<a href=\"\">Reload</a>"), "{hint}");
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
}
}
+389
View File
@@ -0,0 +1,389 @@
//! Synchronous engine boundary for asynchronous browser storage. No browser handles cross the
//! engine's `Send` boundary. Byte buffers are shared with the single in-flight transaction.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex, MutexGuard};
use photocraft_engine::{
Session,
preset_store::{self, PresetBackend, *},
};
pub type Batch = BTreeMap<String, Option<Arc<[u8]>>>;
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records.
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2;
#[derive(Default)]
struct State {
files: BTreeMap<String, Arc<[u8]>>,
bytes: u64,
dirty: BTreeSet<String>,
writing: bool,
error: Option<String>,
rejected: BTreeMap<String, String>,
preserve_tips: bool,
}
#[derive(Clone, Default)]
pub struct Bridge(Arc<Mutex<State>>);
pub fn session(backend: Bridge) -> (Session, Vec<String>) {
let mut session = Session::new();
let opened = preset_store::open(Box::new(backend.clone()));
// An unreadable group may reference any tip, including one shared with a good group.
// Keep all tips until the damaged library can be recovered; unrelated edits still save.
backend.state().preserve_tips = !opened.warnings.is_empty();
let warnings = session.attach_preset_store(opened);
(session, warnings)
}
/// Check metadata before copying a browser record into Rust memory.
pub fn record_limit(name: &str) -> Result<u64, String> {
let leaf = name.strip_prefix("tips/").unwrap_or(name);
if leaf.is_empty() || leaf.len() > 128 || leaf.starts_with('.') || !leaf.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.')) {
return Err("invalid preset record name".into());
}
match name {
INDEX_FILE => Ok(MAX_INDEX_BYTES),
ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES),
_ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES),
_ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES),
_ => Err("unknown preset record type".into()),
}
}
impl Bridge {
fn state(&self) -> MutexGuard<'_, State> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
}
fn insert(s: &mut State, name: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() as u64 > record_limit(name)? {
return Err(format!("{name}: preset record too large"));
}
let old = s.files.get(name).map_or(0, |b| b.len() as u64);
let size = s.bytes.saturating_sub(old).saturating_add(bytes.len() as u64);
if size > MAX_STORE_BYTES || (!s.files.contains_key(name) && s.files.len() >= MAX_RECORDS) {
return Err("browser preset store is full".into());
}
s.files.insert(name.to_string(), Arc::from(bytes));
s.bytes = size;
Ok(())
}
pub fn load(&self, name: &str, bytes: Vec<u8>) -> Result<(), String> {
Self::insert(&mut self.state(), name, &bytes)
}
pub fn begin(&self) -> Option<Batch> {
let mut s = self.state();
if s.writing || s.error.is_some() || s.dirty.is_empty() {
return None;
}
s.writing = true;
Some(
std::mem::take(&mut s.dirty)
.into_iter()
.map(|name| {
let bytes = s.files.get(&name).cloned();
(name, bytes)
})
.collect(),
)
}
pub fn finish(&self, batch: Batch, result: Result<(), String>) {
let mut s = self.state();
s.writing = false;
if let Err(e) = result {
// Requeue names, never old bytes: edits/deletes made during the failed write win.
s.dirty.extend(batch.into_keys());
s.error = Some(e);
}
}
pub fn unavailable(&self, error: String) {
self.state().error = Some(error);
}
pub fn retry(&self) {
self.state().error = None;
}
pub fn error(&self) -> Option<String> {
let s = self.state();
s.rejected.values().next().cloned().or_else(|| s.error.clone())
}
pub fn unsaved(&self) -> bool {
let s = self.state();
s.writing || !s.dirty.is_empty() || !s.rejected.is_empty()
}
}
impl PresetBackend for Bridge {
fn list(&self) -> Result<Vec<(String, u64)>, String> {
Ok(self.state().files.iter().map(|(k, b)| (k.clone(), b.len() as u64)).collect())
}
fn read(&self, name: &str, max: u64) -> Result<Vec<u8>, String> {
let s = self.state();
let bytes = s.files.get(name).ok_or_else(|| format!("{name}: not found"))?;
if bytes.len() as u64 > max {
return Err(format!("{name}: preset record too large"));
}
Ok(bytes.to_vec())
}
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
let mut s = self.state();
if s.files.get(name).is_some_and(|old| old.as_ref() == bytes) {
s.rejected.remove(name);
return Ok(());
}
if !s.dirty.contains(name) && s.dirty.len() >= MAX_RECORDS {
let e = "too many pending preset records".to_string();
s.rejected.insert(name.to_string(), e.clone());
return Err(e);
}
if let Err(e) = Self::insert(&mut s, name, bytes) {
s.rejected.insert(name.to_string(), e.clone());
return Err(e);
}
s.dirty.insert(name.to_string());
// This current replacement resolves only its own omitted edit. Pending transactions
// still keep unsaved() true; Retry or an older successful batch cannot clear it.
s.rejected.remove(name);
Ok(())
}
fn remove(&self, name: &str) -> Result<(), String> {
record_limit(name)?;
let mut s = self.state();
if s.preserve_tips && name.starts_with("tips/") {
s.rejected.remove(name);
return Ok(());
}
if !s.dirty.contains(name) && s.dirty.len() >= MAX_RECORDS {
let e = "too many pending preset records".to_string();
s.rejected.insert(name.to_string(), e.clone());
return Err(e);
}
if let Some(bytes) = s.files.remove(name) {
s.bytes = s.bytes.saturating_sub(bytes.len() as u64);
s.dirty.insert(name.to_string());
}
s.rejected.remove(name);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12};
use serde_json::json;
fn import(s: &mut Session) {
let abr = write_v12(
2,
&[LegacyBrush {
name: "Browser seam brush".into(),
spacing: 25,
anti_alias: true,
tip: LegacyTip::Sampled(AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] }),
}],
false,
)
.unwrap();
s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "group": "Browser samples"})).unwrap();
}
#[test]
fn browser_session_reloads_imported_tip_settings_group_and_order() {
let backend = Bridge::default();
let (mut first, _) = session(backend.clone());
import(&mut first);
let (second, warnings) = session(backend);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(first.tools.presets, second.tools.presets);
}
#[test]
fn pending_writes_coalesce_and_only_one_transaction_runs() {
let b = Bridge::default();
b.write("index.json", b"one").unwrap();
b.write("index.json", b"two").unwrap();
let batch = b.begin().unwrap();
assert_eq!(batch.get("index.json").unwrap().as_deref(), Some(&b"two"[..]));
b.remove("index.json").unwrap();
assert!(b.begin().is_none());
assert!(b.unsaved());
b.finish(batch, Ok(()));
let batch = b.begin().unwrap();
assert!(batch.get("index.json").unwrap().is_none());
b.finish(batch, Ok(()));
assert!(!b.unsaved());
}
#[test]
fn failed_transaction_retains_latest_writes_until_explicit_retry() {
let b = Bridge::default();
b.write("index.json", b"old").unwrap();
let batch = b.begin().unwrap();
b.write("index.json", b"new").unwrap();
b.finish(batch, Err("QuotaExceededError".into()));
assert!(b.unsaved());
assert!(b.error().unwrap().contains("QuotaExceeded"));
assert!(b.begin().is_none());
b.retry();
let batch = b.begin().unwrap();
assert_eq!(batch.get("index.json").unwrap().as_deref(), Some(&b"new"[..]));
b.finish(batch, Ok(()));
assert!(!b.unsaved());
assert!(b.error().is_none());
}
#[test]
fn browser_deletion_and_reordering_survive_reload() {
let b = Bridge::default();
let (mut s, _) = session(b.clone());
import(&mut s);
let deleted = s.tools.presets[0].name.clone();
s.execute("brush.presets.delete", json!({"name": deleted})).unwrap();
s.execute("brush.presets.moveGroup", json!({"group":"Browser samples", "index":0})).unwrap();
let persisted = b.begin().unwrap();
let restored = Bridge::default();
for (key, value) in &persisted {
if let Some(bytes) = value {
restored.load(key, bytes.to_vec()).unwrap();
}
}
b.finish(persisted, Ok(()));
let (loaded, warnings) = session(restored);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(s.tools.presets, loaded.tools.presets);
s.execute("brush.presets.delete", json!({"name":"Browser seam brush"})).unwrap();
let (loaded, _) = session(b);
assert_eq!(s.tools.presets, loaded.tools.presets);
}
#[test]
fn corrupt_record_is_skipped_with_warning_and_kept_for_recovery() {
let b = Bridge::default();
b.load("bad.pcbrushes", b"corrupt".to_vec()).unwrap();
let (s, warnings) = session(b.clone());
assert_eq!(s.tools.presets, Session::new().tools.presets);
assert!(warnings.iter().any(|w| w.contains("bad.pcbrushes")));
assert_eq!(b.read("bad.pcbrushes", 100).unwrap(), b"corrupt");
}
#[test]
fn rejects_invalid_and_oversized_records_before_copying() {
assert!(record_limit("../index.json").is_err());
assert!(record_limit("unknown.bin").is_err());
assert_eq!(record_limit("index.json").unwrap(), MAX_INDEX_BYTES);
assert_eq!(record_limit("tips/a.pctip").unwrap(), MAX_TIP_BYTES);
let b = Bridge::default();
b.load("index.json", b"initial".to_vec()).unwrap();
assert!(!b.unsaved(), "hydration is not a user edit");
assert!(b.read("index.json", 2).is_err());
assert!(b.write("index.json", &vec![0; MAX_INDEX_BYTES as usize + 1]).is_err());
assert!(b.error().is_some());
b.retry();
assert!(b.error().is_some(), "staging errors cannot be cleared by retrying older writes");
assert_eq!(b.read("index.json", 100).unwrap(), b"initial");
}
#[test]
fn accepted_replacement_resolves_only_its_own_staging_rejection() {
let b = Bridge::default();
b.load("index.json", b"initial".to_vec()).unwrap();
let oversized = vec![0; MAX_INDEX_BYTES as usize + 1];
assert!(b.write("index.json", &oversized).is_err());
b.retry();
assert!(b.error().is_some(), "retrying older bytes cannot save the rejected edit");
b.write("index.json", b"repaired").unwrap();
assert!(b.unsaved(), "accepted replacement still needs its transaction");
b.finish(b.begin().unwrap(), Ok(()));
assert!(b.error().is_none());
assert!(!b.unsaved());
assert_eq!(b.read("index.json", 100).unwrap(), b"repaired");
assert!(b.write("index.json", &oversized).is_err());
assert!(b.write("other.pcbrushes", &vec![0; MAX_GROUP_BYTES as usize + 1]).is_err());
b.write("index.json", b"repaired").unwrap();
assert!(b.error().is_some(), "an unchanged accepted record cannot clear another record's rejection");
assert!(b.unsaved());
b.remove("other.pcbrushes").unwrap();
assert!(b.error().is_none());
assert!(!b.unsaved(), "discarding the rejected absent record resolves its pending edit");
}
#[test]
fn older_successful_transaction_does_not_resolve_an_omitted_edit() {
let b = Bridge::default();
b.write("index.json", b"older").unwrap();
let older = b.begin().unwrap();
assert!(b.write("index.json", &vec![0; MAX_INDEX_BYTES as usize + 1]).is_err());
b.finish(older, Ok(()));
assert!(b.error().is_some());
assert!(b.unsaved());
b.write("index.json", b"older").unwrap();
assert!(b.error().is_none(), "explicitly restoring the already saved bytes resolves the omitted edit");
assert!(!b.unsaved());
}
#[test]
fn denied_storage_keeps_imports_in_memory_and_marks_them_unsaved() {
let b = Bridge::default();
b.unavailable("SecurityError".into());
let (mut s, _) = session(b.clone());
import(&mut s);
assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
assert!(b.unsaved());
assert!(b.error().unwrap().contains("SecurityError"));
assert!(b.begin().is_none());
}
#[test]
fn corrupt_group_and_shared_tip_survive_unrelated_save_delete_and_reload() {
let b = Bridge::default();
let (mut s, _) = session(b.clone());
import(&mut s);
let tip = b.list().unwrap().into_iter().find(|(n, _)| n.starts_with("tips/")).unwrap().0;
b.load("damaged.pcbrushes", b"recoverable broken group".to_vec()).unwrap();
let (mut s, warnings) = session(b.clone());
assert!(!warnings.is_empty());
s.execute("brush.presets.save", json!({"name":"Unrelated brush"})).unwrap();
s.execute("brush.presets.delete", json!({"name":"Browser seam brush"})).unwrap();
assert_eq!(b.read("damaged.pcbrushes", 100).unwrap(), b"recoverable broken group");
assert!(b.read(&tip, MAX_TIP_BYTES).is_ok());
let (reloaded, warnings) = session(b.clone());
assert!(!warnings.is_empty());
assert!(reloaded.tools.presets.iter().any(|p| p.name == "Unrelated brush"));
assert!(!reloaded.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
assert_eq!(b.read("damaged.pcbrushes", 100).unwrap(), b"recoverable broken group");
assert!(b.read(&tip, MAX_TIP_BYTES).is_ok());
}
#[test]
fn indexed_brush_store_coexists_with_imported_gradient_preferences() {
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write};
let b = Bridge::default();
let (mut s, _) = session(b.clone());
import(&mut s);
let data = write(&[GrdGradient {
name: "Browser gradient".into(),
noise: false,
stops: vec![
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Foreground },
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
],
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.5, 0.5)],
}]);
s.execute("gradient.presets.importGrd", json!({"data":photocraft_paint::tile::b64_encode(&data),"group":"Browser gradients"})).unwrap();
let prefs = s.prefs_to_json();
let (mut loaded, _) = session(b);
loaded.load_prefs_json(&prefs).unwrap();
assert_eq!(loaded.tools.presets, s.tools.presets);
assert_eq!(loaded.presets.gradients, s.presets.gradients);
assert!(loaded.presets.gradients.iter().any(|g| g.name == "Browser gradients"));
}
}
+321 -27
View File
@@ -1,24 +1,46 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
use photocraft_engine::Session;
use photocraft_ui_egui::i18n;
use photocraft_ui_egui::served_fonts;
use photocraft_ui_egui::theme::ThemeKind;
use photocraft_ui_egui::{PhotocraftApp, Services};
use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Services};
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
/// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest
/// format, each file relative to the site root. No manifest (a 404) means no served fonts.
const FONTS_MANIFEST: &str = "fonts/manifest.txt";
/// The served fonts: the manifest once read, and what the network has delivered.
#[derive(Default)]
struct ServedFonts {
/// Font files by family, each with its Subresource Integrity value when the manifest has one.
files: HashMap<String, Vec<(String, Option<String>)>>,
/// Families fetched or being fetched: each one once per page load.
fetched: HashSet<String>,
/// Downloaded files waiting for the next frame: (family, bytes).
arrived: Vec<(String, Vec<u8>)>,
}
type Served = Arc<Mutex<ServedFonts>>;
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -39,6 +61,16 @@ pub fn start() {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
// Hydrate before constructing an interactive session: a late load must never undo a
// user deletion/reorder. Storage denial or timeout still opens a usable session.
let (database, presets, mut preset_warnings) = match crate::indexed_presets::load().await {
Ok((db, bridge, warnings)) => (Some(db), bridge, warnings),
Err(e) => {
let bridge = crate::preset_bridge::Bridge::default();
bridge.unavailable(e);
(None, bridge, Vec::new())
}
};
let pen_target = canvas.clone();
let result = eframe::WebRunner::new()
.start(
@@ -47,7 +79,14 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let served: Served = Arc::default();
load_font_manifest(served.clone(), cc.egui_ctx.clone());
let (session, warnings) = crate::preset_bridge::session(presets.clone());
preset_warnings.extend(warnings);
let mut app = PhotocraftApp::new(session, services(inbox.clone()));
if !preset_warnings.is_empty() {
photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);
}
listen_pen(&pen_target, app.stylus.feed.clone());
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
if let Some(rs) = cc.wgpu_render_state.clone()
@@ -56,7 +95,9 @@ pub fn start() {
log::info!("photocraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
app.set_wgpu(rs);
}
Ok(Box::new(WebShell { app, inbox }))
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
}),
)
.await;
@@ -99,15 +140,146 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
}
}
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
/// refreshed every frame by [`WebShell`].
fn guard_unload(unsaved: Arc<AtomicBool>) {
use wasm_bindgen::closure::Closure;
let Some(window) = web_sys::window() else { return };
let cb = Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
if unsaved.load(Ordering::Relaxed) {
e.prevent_default();
// Older browsers show the prompt only when a return value is set.
e.set_return_value("");
}
});
if window.add_event_listener_with_callback("beforeunload", cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
}
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Reads the served font manifest, if the host has one, and lists its families in the font menus.
fn load_font_manifest(served: Served, ctx: egui::Context) {
wasm_bindgen_futures::spawn_local(async move {
// Most hosts serve no fonts: a missing manifest is the normal case, not an error.
let Ok(bytes) = fetch_bytes(FONTS_MANIFEST, None).await else { return };
let text = String::from_utf8_lossy(&bytes);
// A host that answers unknown paths with its HTML page (single-page app fallback).
if text.trim_start().starts_with('<') {
return;
}
let (fonts, skipped) = served_fonts::parse_manifest(&text);
for s in skipped {
log::warn!("{FONTS_MANIFEST}: skipped {s}");
}
let mut files: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
for f in &fonts {
files.entry(f.family.clone()).or_default().push((f.file.clone(), f.integrity.clone()));
}
log::info!("photocraft-web: {} served font families", files.len());
served.lock().unwrap_or_else(|e| e.into_inner()).files = files;
// The families for the font menus, and the script fallbacks (the manifest's `scripts`).
served_fonts::add_fonts(&fonts);
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
// the Arabic font before typing); the rest is fetched when text or a font menu needs it.
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str));
ctx.request_repaint();
});
}
/// The browser's preferred languages (`navigator.languages`, e.g. `["ar-EG", "en"]`).
fn browser_languages() -> Vec<String> {
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default()
}
/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource
/// Integrity value), the browser rejects a body whose hash differs.
async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, String> {
use wasm_bindgen_futures::JsFuture;
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let request = match integrity {
Some(sri) => {
let init = web_sys::RequestInit::new();
init.set_integrity(sri);
window.fetch_with_str_and_init(url, &init)
}
None => window.fetch_with_str(url),
};
let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
let body = JsFuture::from(resp.array_buffer().map_err(js)?).await.map_err(js)?;
Ok(js_sys::Uint8Array::new(&body).to_vec())
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
/// so the app's own drop path can't handle them) and feed them through the inbox.
struct WebShell {
app: PhotocraftApp,
inbox: Inbox,
/// Read by the `beforeunload` listener ([`guard_unload`]).
unsaved: Arc<AtomicBool>,
served: Served,
database: Option<web_sys::IdbDatabase>,
presets: crate::preset_bridge::Bridge,
}
impl WebShell {
fn save_presets(&mut self, ctx: &egui::Context) {
if let Some(db) = self.database.clone()
&& let Some(batch) = self.presets.begin()
{
let presets = self.presets.clone();
let wake = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let result = crate::indexed_presets::write(&db, &batch).await;
if let Err(e) = &result {
log::error!("Could not save browser presets: {e}");
}
presets.finish(batch, result);
wake.request_repaint();
});
}
self.unsaved.store(self.app.has_unsaved_work() || self.presets.unsaved(), Ordering::Relaxed);
}
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
/// installs the files that arrived.
fn serve_fonts(&mut self, ctx: &egui::Context) {
let requested = served_fonts::take_requests();
let (fetch, arrived) = {
let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner());
let mut fetch = Vec::new();
for family in requested {
if let Some(files) = s.files.get(&family).cloned()
&& s.fetched.insert(family.clone())
{
fetch.extend(files.into_iter().map(|(file, integrity)| (family.clone(), file, integrity)));
}
}
(fetch, std::mem::take(&mut s.arrived))
};
for (family, file, integrity) in fetch {
let served = self.served.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
match fetch_bytes(&file, integrity.as_deref()).await {
Ok(bytes) => {
served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't fetch served font {file}: {e}"),
}
});
}
served_fonts::install(&mut self.app, arrived);
}
}
impl eframe::App for WebShell {
@@ -127,42 +299,94 @@ impl eframe::App for WebShell {
}
});
}
self.serve_fonts(ctx);
self.app.logic(ctx, frame);
self.save_presets(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
if let Some(error) = error {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
} else {
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
});
}
self.app.ui(ui, frame);
self.save_presets(ui.ctx());
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
fn services(inbox: Inbox) -> Services {
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
pick_open: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await else {
return;
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the files' contents, not paths. File › Open
// asks for several files (`multiple`), each opened in its own tab.
FileDialogRequest::Open { multiple, extensions, .. } => wasm_bindgen_futures::spawn_local(async move {
let dialog = if let Some(exts) = extensions {
rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts)
} else {
rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("SVG", SVG_EXTS)
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
None
})),
// No save dialog on the web: the suggested name becomes the download name.
pick_save: Some(Box::new(|suggested: &str| {
let name = std::path::Path::new(suggested).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| suggested.to_string());
Some(name)
let answer = if multiple {
match dialog.pick_files().await {
Some(picked) if !picked.is_empty() => {
let mut files = Vec::with_capacity(picked.len());
for file in picked {
files.push((file.file_name(), file.read().await));
}
Some(FileDialogAnswer::ContentsMany(files))
}
_ => None,
}
} else {
match dialog.pick_file().await {
Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)),
None => None,
}
};
reply.send(answer);
}),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
reply.send(Some(FileDialogAnswer::SaveTo(name)));
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
encode_png: Some(Box::new(|w, h, rgba| {
@@ -218,6 +442,76 @@ fn mime_for(name: &str) -> &'static str {
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
Some("ora") => "image/openraster",
_ => "application/octet-stream",
}
}
/// Rust bindings to the browser's user-activated EyeDropper API; unsupported browsers keep
/// the document picker and its normal canvas zoom. The browser owns its screen magnifier.
fn screen_color_service() -> Option<photocraft_ui_egui::screen_picker::Service> {
use js_sys::{Function, Reflect};
use photocraft_ui_egui::screen_picker::{Capture, Pending};
use wasm_bindgen::{JsValue, closure::Closure};
let window = web_sys::window()?;
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
Some(Box::new(move |ctx| {
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = (|| -> Result<_, String> {
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(|e| format!("Could not start browser eyedropper: {e:?}"))?;
let controller = web_sys::AbortController::new().map_err(|e| format!("Could not create eyedropper cancellation: {e:?}"))?;
let options = js_sys::Object::new();
Reflect::set(&options, &JsValue::from_str("signal"), &controller.signal()).map_err(|e| format!("Could not set eyedropper options: {e:?}"))?;
let open = Reflect::get(&eye, &JsValue::from_str("open"))
.map_err(|e| format!("Browser eyedropper has no open method: {e:?}"))?
.dyn_into::<Function>()
.map_err(|_| "Invalid browser eyedropper method")?;
let promise = open
.call1(&eye, &options)
.map_err(|e| format!("Could not open browser eyedropper: {e:?}"))?
.dyn_into::<js_sys::Promise>()
.map_err(|_| "Browser eyedropper returned no promise")?;
let stop = cancelled.clone();
let abort = controller.clone();
let timer = Closure::<dyn FnMut()>::new(move || {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
abort.abort();
}
});
let interval = window.set_interval_with_callback_and_timeout_and_arguments_0(timer.as_ref().unchecked_ref(), 50).map_err(|e| {
controller.abort();
format!("Could not watch eyedropper cancellation: {e:?}")
})?;
Ok((promise, timer, interval))
})();
match started {
Err(e) => {
let _ = tx.send(Err(e));
}
Ok((promise, timer, interval)) => {
let wake = ctx.clone();
let window = window.clone();
wasm_bindgen_futures::spawn_local(async move {
let result = match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(value) => Reflect::get(&value, &JsValue::from_str("sRGBHex"))
.ok()
.and_then(|v| v.as_string())
.and_then(|h| photocraft_ui_egui::color_picker_ui::parse_hex(&h))
.map(|rgb| Capture::Color(Some(rgb)))
.ok_or_else(|| "Browser eyedropper returned an invalid color".into()),
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|v| v.as_string()).as_deref() == Some("AbortError") => {
Ok(Capture::Color(None))
}
Err(e) => Err(format!("Browser screen color selection failed: {e:?}")),
};
window.clear_interval_with_handle(interval);
drop(timer);
let _ = tx.send(result);
wake.request_repaint();
});
}
}
Pending { receiver: rx, cancelled }
}))
}
+54 -2
View File
@@ -7,6 +7,11 @@ default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
build = "build.rs"
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
@@ -24,21 +29,68 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { version = "3", features = ["wayland-data-control"] }
arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
ron = "0.12"
[target.'cfg(target_os = "macos")'.dependencies]
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
# `event` (CGEventSource) is used for the Caps Lock poll (#1758).
core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link", "event"] }
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
# Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs).
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
# Already used by the portal stack; bounded waits let cancelling close the OS request.
futures-lite = "=2.6.1"
async-io = "=2.6.0"
# Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
# Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`,
# GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles),
# no dependencies of its own. Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "windows")'.dependencies]
# Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal.
xcap = "=0.9.8"
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
[target.'cfg(target_os = "macos")'.dev-dependencies]
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSGraphicsContext", "NSMenu", "NSMenuItem", "NSResponder"] }
[[test]]
name = "mac_menu_appkit"
harness = false
[build-dependencies]
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
+218
View File
@@ -0,0 +1,218 @@
//! Linux desktop colour scheme, including Wayland compositors that do not report a winit theme.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
// The Settings.Read result is a variant; some portals nest another variant inside it.
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
#[cfg(target_os = "linux")]
fn portal_startup<T>(subscribe: impl FnOnce(), read: impl FnOnce() -> T) -> T {
subscribe();
read()
}
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
start(|publisher| {
let proxy = portal_proxy();
// Subscribe before the first read, so a change during start-up isn't lost.
let mut signals = None;
let initial = portal_startup(
|| signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()),
|| proxy.as_ref().and_then(portal_theme).or_else(gtk_theme),
);
publisher.publish(initial);
let Some(signals) = signals else { return };
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
publisher.publish(portal_code(changed).and_then(decode));
}
})
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
/// The worker's handle on the shared value. Every change wakes the registered UI, including the
/// first read when it lands after the start-up wait below.
#[cfg(target_os = "linux")]
struct Publisher {
value: std::sync::Arc<std::sync::atomic::AtomicU8>,
wake: std::sync::Arc<std::sync::OnceLock<egui::Context>>,
ready: std::sync::mpsc::Sender<()>,
}
#[cfg(target_os = "linux")]
impl Publisher {
fn publish(&self, theme: Option<egui::Theme>) {
let code = code_of(theme);
if self.value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
&& let Some(ctx) = self.wake.get()
{
ctx.request_repaint();
}
let _ = self.ready.send(());
}
}
/// Runs `worker` on its own thread and waits up to 250 ms for its first value, so a fast desktop
/// answers before the first frame.
#[cfg(target_os = "linux")]
fn start(worker: impl FnOnce(&Publisher) + Send + 'static) -> Option<photocraft_ui_egui::SystemThemeFn> {
use std::sync::{Arc, OnceLock, atomic::AtomicU8};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (ready, wait) = std::sync::mpsc::channel();
let publisher = Publisher { value: Arc::clone(&value), wake: Arc::clone(&wake), ready };
std::thread::Builder::new().name("appearance-portal".into()).spawn(move || worker(&publisher)).ok()?;
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(std::sync::atomic::Ordering::Relaxed)))
}))
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn startup_keeps_changes_emitted_during_initial_read() {
use std::cell::Cell;
for (initial_value, changed_value) in [(2, 1), (1, 2), (2, 2)] {
let subscribed = Cell::new(false);
let mut portal_value = initial_value;
let mut pending_signal = None;
let initial = super::portal_startup(
|| subscribed.set(true),
|| {
let initial = portal_value;
portal_value = changed_value;
if subscribed.get() && portal_value != initial {
pending_signal = Some(portal_value);
}
initial
},
);
let cached = pending_signal.unwrap_or(initial);
assert_eq!(cached, changed_value, "portal changed during startup but cached {cached}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn portal_scheme_values() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
}
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
#[cfg(target_os = "linux")]
#[test]
fn late_initial_read_requests_repaint() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let (release, gate) = std::sync::mpsc::channel::<()>();
let (done, finished) = std::sync::mpsc::channel::<()>();
let reader = super::start(move |publisher| {
let _ = gate.recv();
publisher.publish(Some(egui::Theme::Light));
let _ = done.send(());
})
.expect("worker thread");
let ctx = egui::Context::default();
let repaints = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&repaints);
ctx.set_request_repaint_callback(move |_| {
counter.fetch_add(1, Ordering::Relaxed);
});
assert_eq!(reader(&ctx), None, "start-up wait elapsed without an answer");
release.send(()).unwrap();
finished.recv().unwrap();
assert_eq!(repaints.load(Ordering::Relaxed), 1);
assert_eq!(reader(&ctx), Some(egui::Theme::Light));
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Caps Lock state, read from the OS once per frame so the canvas can show the precise crosshair
//! for painting tools whatever the cursor preference (Photoshop's ⇪ behaviour, #1758).
//!
//! winit and egui have no Caps Lock key or modifier — `egui::Key` has no variant, `Modifiers` no
//! field, and egui-winit's `key_from_named_key` drops the `NamedKey::CapsLock` key event before it
//! reaches the app. So the state is polled here instead, from the platform:
//!
//! - **X11 (and XWayland)**: XKB's Lock modifier state, via the pure-Rust x11rb client. The app
//! already opens on X11 when the `Preferences › Performance › Linux display server` preference
//! asks for it, and on Wayland through XWayland for pen support, so this is the common path.
//! - **Windows**: `GetKeyState(VK_CAPITAL)` through the safe `winsafe` binding.
//! - **macOS**: `CGEventSourceFlagsState` (`kCGEventFlagAlphaShift`) through `core-graphics2`.
//! - **Native Wayland**: there is no global keyboard-state query (no equivalent of XKB GetState or
//! VK_CAPITAL); the service is `None` there and Caps Lock leaves the cursor preference alone.
//!
//! No `unsafe`: every OS call goes through a safe binding.
pub use platform::service;
#[cfg(target_os = "macos")]
mod platform {
use core_graphics2::event_source::CGEventSource;
use core_graphics2::event_types::{CGEventFlags, CGEventSourceStateID};
use photocraft_ui_egui::CapsLockFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
Some(Box::new(|| {
let flags = CGEventSource::flags_state(CGEventSourceStateID::CombinedSessionState);
flags.contains(CGEventFlags::MaskAlphaShift)
}))
}
}
#[cfg(target_os = "windows")]
mod platform {
use photocraft_ui_egui::CapsLockFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
Some(Box::new(|| {
// The second element of the pair is "key toggled on", which is Caps Lock's state.
let (_, toggled) = winsafe::GetKeyState(winsafe::co::VK::CAPITAL);
toggled
}))
}
}
#[cfg(target_os = "linux")]
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::CapsLockFn;
use x11rb::protocol::xkb::{self, ConnectionExt as _};
use x11rb::protocol::xproto::ModMask;
use x11rb::rust_connection::RustConnection;
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
// XKB keyboard state lives on the X server. The window is our window: whether it is X11
// (or XWayland) is whether there is an X server to ask. Native Wayland has no such
// connection and degrades to the cursor preference (no service).
let is_x11 = matches!(cc.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_));
if !is_x11 {
return None;
}
// Lazily opened on first use and kept: the Lock-modifier mask is stable per keymap, so it
// is queried once and only the toggle state is re-read every frame.
let mut conn: Option<(RustConnection, ModMask)> = None;
// After a failure, wait before reconnecting (and warn once per failure), so a broken X
// connection doesn't reconnect and log on every frame.
let mut retry_at: Option<std::time::Instant> = None;
Some(Box::new(move || {
if retry_at.is_some_and(|t| std::time::Instant::now() < t) {
return false;
}
match caps_lock_on(&mut conn) {
Ok(on) => {
retry_at = None;
on
}
Err(error) => {
log::warn!("couldn't read the Caps Lock state: {error}");
conn = None;
retry_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
false
}
}
}))
}
/// Caps Lock is on when the Lock modifier is both engaged (`locked_mods`) and present in the
/// keyboard's indicator map (so a re-mapped lock key is still recognised).
fn caps_lock_on(conn: &mut Option<(RustConnection, ModMask)>) -> Result<bool, String> {
if conn.is_none() {
let (c, _) = RustConnection::connect(None).map_err(|e| e.to_string())?;
// XKB requests need the extension active first (it is, on every modern server).
let enabled = c.xkb_use_extension(1, 0).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
if !enabled.supported {
return Err("the X server has no XKB extension".into());
}
let device = u16::from(xkb::ID::USE_CORE_KBD);
// Indicator 0 is the Caps Lock indicator; its `.mods` is the Lock-modifier mask.
let map = c.xkb_get_indicator_map(device, 1).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
let lock = map.maps.first().map_or(ModMask::LOCK, |m| m.mods);
*conn = Some((c, lock));
}
let (conn, lock) = match conn.as_ref() {
Some((conn, lock)) => (conn, *lock),
None => return Err("no X connection".into()),
};
let device = u16::from(xkb::ID::USE_CORE_KBD);
let state = conn.xkb_get_state(device).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
Ok(u16::from(state.locked_mods) & u16::from(lock) != 0)
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
mod platform {
use photocraft_ui_egui::CapsLockFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
None
}
}
+52 -4
View File
@@ -5,13 +5,14 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::time::Instant;
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
@@ -83,18 +84,21 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
let deadline = Instant::now() + REPLY_DEADLINE;
let (mut req, rrx) = ControlRequest::new(method, params);
req.deadline = Some(deadline);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
let mut r =
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
};
if write_reply(&mut out, &reply).is_err() {
break;
@@ -109,6 +113,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
mod tests {
use super::*;
use std::io::BufRead;
use std::time::Duration;
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
@@ -124,6 +129,7 @@ mod tests {
let handler = std::thread::spawn(move || {
let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(first.method, "test.large");
assert!(first.deadline.is_some_and(|deadline| deadline > std::time::Instant::now()), "native requests carry their reply deadline");
first.reply.send(json!({"ok": true, "result": "x".repeat(MAX_RESPONSE_BYTES)})).unwrap();
let second = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(second.method, "test.small");
@@ -154,4 +160,46 @@ mod tests {
handler.join().unwrap();
server.join().unwrap();
}
#[test]
fn bad_json_reply_carries_a_null_id_and_the_connection_keeps_serving() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
serve(stream, TOKEN, tx, egui::Context::default());
});
let handler = std::thread::spawn(move || {
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(req.method, "test.after");
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
writeln!(stream, "{}", json!({"id": "auth", "method": "auth", "params": {"token": TOKEN}})).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["ok"], true);
writeln!(stream, "{{not json}}").unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let bad: Value = serde_json::from_str(&line).unwrap();
let bad = bad.as_object().unwrap();
assert_eq!(bad.get("id"), Some(&Value::Null), "bad JSON reply must carry `id: null`: {line}");
assert_eq!(bad.get("ok"), Some(&Value::Bool(false)));
assert!(bad.get("error").and_then(Value::as_str).unwrap().starts_with("bad JSON: "));
writeln!(stream, "{}", json!({"id": 7, "method": "test.after"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let after: Value = serde_json::from_str(&line).unwrap();
assert_eq!(after["id"], 7);
assert_eq!(after["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Where the pointer is, read from the OS, so a file drop can tell the canvas (place as a layer)
//! from the tab strip (open at that tab position) and the panels (open as a document), and the tab
//! strip can show where a dragged file would go. winit 0.30's file drops carry no position, and
//! during an OS drag the window gets no pointer events, so egui's last pointer is stale.
//!
//! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it.
//! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection).
//! - **Windows**: `GetCursorPos`, relative to the window's client area.
//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place.
//!
//! No `unsafe`: every OS call goes through a safe binding.
//!
//! [`service`] builds the pointer-position service for the app's window, `None` where it can't
//! be read.
pub use platform::service;
#[cfg(target_os = "macos")]
mod platform {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEvent, NSWindow};
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
Some(Box::new(|ctx: &egui::Context| in_window_points().map(|(x, y)| egui::pos2(x as f32, y as f32) / ctx.zoom_factor())))
}
/// The pointer in points from the top-left of the content view of our frontmost window under
/// it (`None` when it's over none of ours, or off the main thread). Only our own windows are
/// searched: a drop that reached us landed on one of them.
fn in_window_points() -> Option<(f64, f64)> {
let mtm = MainThreadMarker::new()?;
let at = NSEvent::mouseLocation();
let contains = |w: &NSWindow| {
let f = w.frame();
(f.origin.x..f.origin.x + f.size.width).contains(&at.x) && (f.origin.y..f.origin.y + f.size.height).contains(&at.y)
};
let window = NSApplication::sharedApplication(mtm).orderedWindows().iter().find(|w| w.isVisible() && contains(w))?;
let view = window.contentView()?;
let p = view.convertPoint_fromView(window.convertPointFromScreen(at), None);
let y = if view.isFlipped() { p.y } else { view.bounds().size.height - p.y };
Some((p.x, y))
}
}
#[cfg(target_os = "linux")]
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::CursorPosFn;
use x11rb::protocol::xproto::ConnectionExt;
use x11rb::rust_connection::RustConnection;
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
let window = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok()?,
RawWindowHandle::Xcb(h) => h.window.get(),
_ => return None,
};
// Opened on first use and kept: the pointer is read every frame while files hover.
let mut conn: Option<RustConnection> = None;
Some(Box::new(move |ctx: &egui::Context| match pointer_in(&mut conn, window) {
Ok(p) => p.map(|(x, y)| egui::pos2(f32::from(x), f32::from(y)) / ctx.pixels_per_point()),
Err(e) => {
log::warn!("couldn't read the pointer position: {e}");
conn = None;
None
}
}))
}
/// The pointer in pixels relative to `window` (`None` when it's on another screen).
fn pointer_in(conn: &mut Option<RustConnection>, window: u32) -> Result<Option<(i16, i16)>, String> {
let conn = match conn {
Some(c) => c,
None => conn.insert(RustConnection::connect(None).map_err(|e| e.to_string())?.0),
};
let reply = conn.query_pointer(window).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
Ok(reply.same_screen.then_some((reply.win_x, reply.win_y)))
}
}
#[cfg(target_os = "windows")]
mod platform {
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
Some(Box::new(|ctx: &egui::Context| {
let pt = winsafe::GetCursorPos().map_err(|e| log::warn!("couldn't read the pointer position: {e}")).ok()?;
let inner = ctx.input(|i| i.viewport().inner_rect)?;
Some(super::client_points(egui::pos2(pt.x as f32, pt.y as f32), inner.min, ctx.pixels_per_point()))
}))
}
}
/// A screen position in physical pixels to points in the window whose client area starts at
/// `inner_min` (egui's `inner_rect.min`: the client origin in pixels over `ppp`). On Windows
/// `GetCursorPos` and the client origin are both physical pixels (winit makes the process
/// per-monitor DPI aware) and the window draws at one scale wherever it sits, so this holds across
/// monitors with different DPIs.
#[cfg(any(target_os = "windows", test))]
fn client_points(screen_px: egui::Pos2, inner_min: egui::Pos2, ppp: f32) -> egui::Pos2 {
((screen_px - inner_min * ppp) / ppp).to_pos2()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
mod platform {
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
None
}
}
#[cfg(test)]
mod tests {
use egui::pos2;
#[test]
fn client_points_on_a_second_monitor_at_another_scale() {
// A 150% monitor left of a 100% primary: the window's client area starts at (-2400, 300) px.
let inner_min = pos2(-2400.0, 300.0) / 1.5;
assert_eq!(super::client_points(pos2(-2100.0, 450.0), inner_min, 1.5), pos2(200.0, 100.0));
// The same window on the 100% primary.
assert_eq!(super::client_points(pos2(500.0, 400.0), pos2(300.0, 300.0), 1.0), pos2(200.0, 100.0));
}
}
+129
View File
@@ -0,0 +1,129 @@
//! Why a File › Open / Save dialog may never appear on Linux (#3354).
//!
//! `rfd` talks to the XDG file-chooser portal through `libdbus-1`, which it loads at runtime, and
//! falls back to `zenity`. When neither works (a minimal window-manager session without a portal
//! backend) it logs the failure and answers "nothing picked", which the app took for Cancel: the
//! menu item did nothing and said nothing. [`start`] looks for the pieces once, in the background,
//! and [`known_problem`] returns what to install when a dialog came back empty and none of them
//! is there.
//!
//! The probe only ever explains an empty answer; it never stops a dialog from opening. Anything
//! it can't tell counts as present, so a working system never sees the message.
use std::sync::OnceLock;
/// What the probe found. `None` means it couldn't tell, which counts as present.
// Only Linux probes; the types and helpers stay testable on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Findings {
/// `libdbus-1.so.3` is in the linker cache (`rfd` loads it for the portal).
pub libdbus: Option<bool>,
/// The session bus has the portal's `FileChooser` interface (a portal and a backend for it).
pub file_chooser_portal: Option<bool>,
/// `zenity` is on `PATH` (`rfd`'s fallback).
pub zenity: bool,
}
/// The message for `findings`, or `None` when some file dialog should work.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn problem(findings: Findings) -> Option<String> {
let portal = findings.libdbus != Some(false) && findings.file_chooser_portal != Some(false);
if portal || findings.zenity {
return None;
}
let cause = if findings.libdbus == Some(false) { "libdbus is missing" } else { "no desktop portal offers a file chooser" };
Some(format!(
"No file dialog can open: {cause}. Install xdg-desktop-portal with a backend (xdg-desktop-portal-gtk, -kde or -gnome) and libdbus, or zenity. Meanwhile, drop a file onto the window or paste a copied image."
))
}
static RESULT: OnceLock<Option<String>> = OnceLock::new();
/// The problem found by [`start`], once it has finished.
pub fn known_problem() -> Option<String> {
RESULT.get().cloned().flatten()
}
/// Whether `program` is an executable file in one of the `path` directories.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn on_path(program: &str, path: &std::ffi::OsStr) -> bool {
std::env::split_paths(path).any(|dir| dir.join(program).is_file())
}
/// Look for the file dialog backends on a helper thread (the portal call can take a moment).
#[cfg(target_os = "linux")]
pub fn start() {
let spawned = std::thread::Builder::new().name("file dialog probe".into()).spawn(|| {
let findings = Findings {
libdbus: crate::linux_libs::cached("libdbus-1.so.3"),
file_chooser_portal: file_chooser_portal(),
zenity: std::env::var_os("PATH").is_some_and(|path| on_path("zenity", &path)),
};
log::info!("file dialog backends: {findings:?}");
let _ = RESULT.set(problem(findings));
});
if let Err(e) = spawned {
log::warn!("couldn't start the file dialog probe: {e}");
}
}
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option<bool> {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
.ok()?;
match proxy.get_property::<u32>("version") {
Ok(_) => Some(true),
Err(zbus::Error::MethodError(name, _, _)) => {
let name = name.as_str();
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
absent.then_some(false)
}
Err(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn found(libdbus: Option<bool>, file_chooser_portal: Option<bool>, zenity: bool) -> Findings {
Findings { libdbus, file_chooser_portal, zenity }
}
#[test]
fn a_working_backend_is_not_reported() {
assert_eq!(problem(found(Some(true), Some(true), false)), None, "portal");
assert_eq!(problem(found(Some(false), Some(false), true)), None, "zenity alone");
assert_eq!(problem(found(None, None, false)), None, "unknown counts as present");
assert_eq!(problem(found(Some(true), None, false)), None, "slow portal reply");
}
#[test]
fn nothing_to_show_a_dialog_with_is_explained() {
let no_portal = problem(found(Some(true), Some(false), false)).unwrap();
assert!(no_portal.contains("no desktop portal offers a file chooser") && no_portal.contains("xdg-desktop-portal"), "{no_portal}");
let no_dbus = problem(found(Some(false), Some(true), false)).unwrap();
assert!(no_dbus.contains("libdbus is missing"), "{no_dbus}");
}
#[test]
fn programs_are_found_on_the_given_path() {
let dir = std::env::temp_dir().join(format!("photocraft-probe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("zenity"), b"").unwrap();
let path = std::env::join_paths([std::path::Path::new("/nonexistent"), dir.as_path()]).unwrap();
assert!(on_path("zenity", &path));
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+302 -22
View File
@@ -16,7 +16,9 @@
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere).
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
//! (NVIDIA's EGL, which also hides llvmpipe) falls back to a Vulkan adapter for the window only
//! (#1337); before, CPU mode then couldn't start at all.
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
@@ -24,7 +26,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use eframe::{egui_wgpu, wgpu};
use photocraft_engine::prefs::GpuBackend;
use photocraft_engine::prefs::{GpuBackend, RenderingMode};
use serde_json::{Value, json};
/// The marker's file name in the config directory.
@@ -130,6 +132,14 @@ pub fn next_safer(tried: GpuBackend, os: Os) -> GpuBackend {
}
}
/// Whether a start that returned an error (so it didn't crash in the driver) keeps its marker, so
/// the next start tries a safer backend. Not when there is none: the CPU path is the end of every
/// chain, and a marker left there pinned every later start to the same failing path. Not under
/// `WGPU_BACKEND` either (no plan reads that marker).
pub fn keep_marker_after_error(plan: &Plan, os: Os) -> bool {
plan.env.is_none() && next_safer(plan.backend, os) != plan.backend
}
/// Decide this launch's backend from the preference, a marker left by a start that crashed,
/// `WGPU_BACKEND` and `--safe-gpu`.
pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
@@ -154,6 +164,19 @@ pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_
Plan { backend: pref, env: None, reason: None, remember: false }
}
/// Resolve the rendering policy before the window exists. Explicit GPU/Automatic selections
/// reset a legacy CPU backend to automatic; CPU keeps software-preferred window presentation.
pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
let backend = match mode {
RenderingMode::Cpu => GpuBackend::Cpu,
RenderingMode::Auto | RenderingMode::Gpu if pref == GpuBackend::Cpu => GpuBackend::Auto,
_ => pref,
};
let env = if safe_gpu || mode == RenderingMode::Cpu { None } else { env };
let crashed = if mode == RenderingMode::Cpu { None } else { crashed };
plan(backend, crashed, env, safe_gpu, os)
}
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
if plan.env.is_some() {
@@ -168,11 +191,29 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
GpuBackend::Cpu => match os {
Os::Windows => wgpu::Backends::DX12,
Os::Mac => wgpu::Backends::METAL,
Os::Other => wgpu::Backends::GL,
// Vulkan only for the window when no GL adapter can present (`rank`): with NVIDIA's
// EGL the GL adapter can't, and CPU mode failed every start (#1337).
Os::Other => wgpu::Backends::GL | wgpu::Backends::VULKAN,
},
})
}
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
/// compile. Use a `dxcompiler.dll` shipped beside the executable, by its full path; otherwise FXC
/// (`d3dcompiler_47.dll`, part of Windows). `WGPU_DX12_COMPILER` still picks one explicitly.
pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Compiler {
if let Some(c) = env.and_then(|v| v.trim().parse().ok()) {
return c;
}
let beside_exe = exe_dir.map(|d| d.join("dxcompiler.dll")).filter(|p| p.is_file());
match beside_exe.as_deref().and_then(Path::to_str) {
Some(p) => wgpu::Dx12Compiler::DynamicDxc { dxc_path: p.to_string() },
None => wgpu::Dx12Compiler::Fxc,
}
}
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
@@ -185,8 +226,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows.
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
@@ -206,13 +249,12 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
wgpu::Backend::Gl => 3,
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
};
(ty, be)
(fallback, ty, be)
}
/// The adapter to use among `adapters` (`None` when there is none).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
let any_ok = adapters.iter().any(|a| a.surface_ok);
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok || !any_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
@@ -225,14 +267,30 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
}
/// Configure eframe's wgpu setup for `plan`: instance backends, adapter selection (Intel on
/// Windows, software adapters for `cpu`) and the marker update once the adapter is chosen.
/// `note` receives a remark for System Info (e.g. the Intel default).
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
/// crashes on them on old Intel Macs (Iris Pro, GPUFamily 1 on Monterey), so no mode could start
/// (#3107). Only Intel Macs skip them: PhotoCraft issues no indirect draws or dispatches, but every
/// other GPU keeps wgpu's default. `WGPU_VALIDATION_INDIRECT_CALL=1` still wins.
fn skips_indirect_validation(os: Os, intel: bool) -> bool {
os == Os::Mac && intel
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
}
if skips_indirect_validation(os, cfg!(target_arch = "x86_64")) {
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
if os == Os::Windows {
let exe = std::env::current_exe().ok();
let env = std::env::var("WGPU_DX12_COMPILER").ok();
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
}
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
if select {
let backend = plan.backend;
@@ -250,6 +308,10 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
})
.collect();
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
@@ -304,6 +366,12 @@ pub struct Sentinel {
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
/// Only renderer errors should affect the next launch's graphics backend. Windowing errors
/// (such as a missing Linux display) and app setup errors never indicate a driver failure.
pub fn keep_marker_after_run(result: &eframe::Result) -> bool {
matches!(result, Err(eframe::Error::Wgpu(_)))
}
impl Sentinel {
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
/// unless another instance holds the lock or the directory isn't writable, this start's
@@ -357,14 +425,26 @@ impl Sentinel {
}
}
/// `performance.gpuBackend` and `performance.useGpu` from the preferences file, read before the
/// app (and its full preference load) exists. Unreadable values are the defaults.
pub fn read_prefs(path: Option<&Path>) -> (GpuBackend, bool) {
/// The Linux display-server preference (`performance.linuxDisplayServer`) from the preferences
/// file, read before the window opens; `Auto` when the file or value is missing or unknown.
#[cfg(any(target_os = "linux", test))]
pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::LinuxDisplayServer {
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);
v.get("performance")
.and_then(|p| serde_json::from_value::<photocraft_engine::prefs::Performance>(p.clone()).ok())
.map(|performance| performance.linux_display_server)
.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);
let perf = v.get("performance");
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
(backend, use_gpu)
let explicit = perf.and_then(|p| p.get("renderingMode")).and_then(Value::as_str).and_then(RenderingMode::parse);
let mode = explicit.unwrap_or(if !use_gpu || backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto });
(backend, mode)
}
#[cfg(test)]
@@ -376,6 +456,38 @@ mod tests {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
let dir = std::env::temp_dir().join(format!("photocraft-display-server-{}", 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_display_server(Some(&file))
};
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"x11"}}"#), LinuxDisplayServer::X11);
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"auto","gpuBackend":"vulkan"}}"#), LinuxDisplayServer::Auto);
// Unknown values, other types, broken JSON and a missing file all start as Auto.
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"wayland"}}"#), LinuxDisplayServer::Auto);
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":11}}"#), LinuxDisplayServer::Auto);
assert_eq!(read("{not json"), LinuxDisplayServer::Auto);
assert_eq!(read_display_server(Some(&dir.join("missing.json"))), LinuxDisplayServer::Auto);
assert_eq!(read_display_server(None), LinuxDisplayServer::Auto);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cpu_policy_ignores_hardware_backend_override() {
let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows);
assert_eq!(p.backend, Cpu);
assert!(p.env.is_none());
assert_eq!(plan_with_mode(Vulkan, RenderingMode::Cpu, Some(&crashed("vulkan", "vulkan")), None, false, Os::Windows).backend, Cpu);
let p = plan_with_mode(Auto, RenderingMode::Gpu, None, Some("vulkan"), true, Os::Windows);
assert_eq!(p.backend, Cpu);
assert!(p.env.is_none());
}
#[test]
fn no_marker_uses_the_preference() {
for os in [Os::Windows, Os::Mac, Os::Other] {
@@ -434,7 +546,7 @@ mod tests {
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
assert_eq!((p.backend, p.remember), (Cpu, false));
assert_eq!(backends(&p, Os::Windows), Some(wgpu::Backends::DX12));
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL));
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL | wgpu::Backends::VULKAN));
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
}
@@ -474,6 +586,43 @@ mod tests {
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
}
/// #1337: on Linux with NVIDIA, the only GL adapter (NVIDIA's EGL) can't present, so CPU mode
/// found no adapter and never started. GL still wins whenever it can present.
#[test]
fn cpu_on_linux_falls_back_to_vulkan_only_when_gl_cannot_present() {
use wgpu::{Backend as B, DeviceType as T};
const NVIDIA: u32 = 0x10de;
let llvmpipe = cand(0x10005, T::Cpu, B::Gl);
let nvidia_gl = cand(NVIDIA, T::DiscreteGpu, B::Gl);
let nvidia_vk = cand(NVIDIA, T::DiscreteGpu, B::Vulkan);
let lavapipe = cand(0x10005, T::Cpu, B::Vulkan);
// GL presents: same choice as GL-only, software first, then hardware GL over any Vulkan.
assert_eq!(pick(&[nvidia_vk, lavapipe, llvmpipe], Cpu, Os::Other), Some(2));
assert_eq!(pick(&[nvidia_vk, lavapipe, nvidia_gl], Cpu, Os::Other), Some(2));
// GL can't present: Vulkan for the window, software first.
let mut gl = nvidia_gl;
gl.surface_ok = false;
assert_eq!(pick(&[gl, nvidia_vk, lavapipe], Cpu, Os::Other), Some(2));
assert_eq!(pick(&[gl, nvidia_vk], Cpu, Os::Other), Some(1));
// Other backends rank as before.
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
a.surface_ok = false;
assert_eq!(pick(&[a], Auto, Os::Windows), None);
assert_eq!(pick(&[a], Cpu, Os::Windows), None);
}
#[test]
fn explicit_policy_overrides_legacy_cpu_backend() {
assert_eq!(plan_with_mode(Cpu, RenderingMode::Gpu, None, None, false, Os::Mac).backend, Auto);
assert_eq!(plan_with_mode(Metal, RenderingMode::Cpu, None, None, false, Os::Mac).backend, Cpu);
assert_eq!(plan_with_mode(Auto, RenderingMode::Gpu, None, None, true, Os::Mac).backend, Cpu);
}
#[test]
fn marker_round_trip_and_lenient_parse() {
let m = Marker {
@@ -492,6 +641,43 @@ mod tests {
assert_eq!(Marker::parse(&long).adapter.len(), 256);
}
#[test]
fn a_failed_start_keeps_its_marker_only_when_a_safer_backend_exists() {
// GPU backends move down the chain on the next start.
assert!(keep_marker_after_error(&plan(Auto, None, None, false, Os::Other), Os::Other));
assert!(keep_marker_after_error(&plan(Gl, None, None, false, Os::Other), Os::Other));
assert!(keep_marker_after_error(&plan(Dx12, None, None, false, Os::Windows), Os::Windows));
assert!(keep_marker_after_error(&plan(Auto, None, None, false, Os::Mac), Os::Mac));
// The CPU path is the end of every chain, including the `--safe-gpu` retry.
for os in [Os::Windows, Os::Mac, Os::Other] {
assert!(!keep_marker_after_error(&plan(Cpu, None, None, false, os), os));
assert!(!keep_marker_after_error(&plan(Auto, None, None, true, os), os));
}
// `WGPU_BACKEND` is the user's choice; no plan reads its marker.
assert!(!keep_marker_after_error(&plan(Auto, None, Some("vulkan"), false, Os::Other), Os::Other));
}
/// A failed CPU start (no usable GL adapter under XWayland) left a `cpu` marker, so every
/// later start was planned on CPU and failed the same way until the marker was deleted by hand.
#[test]
fn a_failed_cpu_start_does_not_pin_later_starts() {
let dir = temp_dir("cpu-error");
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
let failed = plan(Auto, None, None, true, Os::Other);
s.write(Marker { backend: failed.backend.name().into(), ..Default::default() }).unwrap();
// eframe returned an error: what `main` does with the marker.
if keep_marker_after_error(&failed, Os::Other) {
drop(s);
} else {
s.finish();
}
let (prev, _s) = Sentinel::begin(&dir);
assert_eq!(prev, Previous::Clean);
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
let _ = std::fs::remove_dir_all(&dir);
}
fn temp_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("photocraft-gpu-startup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
@@ -513,7 +699,16 @@ mod tests {
assert!(none.is_none());
// The process dies in the driver: the lock goes, the marker stays.
drop(s);
let (prev, s) = Sentinel::begin(&dir);
// Another test in this binary spawns a process: between its fork and exec the child briefly
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
let (mut prev, mut s) = Sentinel::begin(&dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(&dir);
}
let m = prev.crashed().cloned().expect("crash detected");
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
// This start succeeds: the next one finds nothing.
@@ -526,6 +721,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
let cases = [
("success", Ok(()), false),
("app-error", Err(eframe::Error::AppCreation(Box::new(std::io::Error::other("app setup failed")))), false),
("gpu-error", Err(eframe::Error::Wgpu(egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no adapter".into()))), true),
];
for (name, result, keep) in cases {
let dir = temp_dir(name);
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
if keep_marker_after_run(&result) {
drop(s);
} else {
s.finish();
}
let (previous, s) = Sentinel::begin(&dir);
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
if !keep {
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
}
s.expect("sentinel").finish();
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn unwritable_config_dir_is_not_fatal() {
let dir = temp_dir("file");
@@ -542,14 +764,72 @@ mod tests {
let dir = temp_dir("prefs");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("preferences.json");
assert_eq!(read_prefs(None), (Auto, true));
assert_eq!(read_prefs(Some(&p)), (Auto, true));
assert_eq!(read_rendering_prefs(None), (Auto, RenderingMode::Auto));
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
assert_eq!(read_prefs(Some(&p)), (Dx12, false));
assert_eq!(read_rendering_prefs(Some(&p)), (Dx12, RenderingMode::Cpu));
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
assert_eq!(read_prefs(Some(&p)), (Auto, true));
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
std::fs::write(&p, r#"{"performance": {"useGpu": false, "renderingMode": "gpu"}}"#).unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Gpu));
std::fs::write(&p, "{not json").unwrap();
assert_eq!(read_prefs(Some(&p)), (Auto, true));
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
let _ = std::fs::remove_dir_all(&dir);
}
/// #712: never a `dxcompiler.dll` found through the DLL search path (current directory, PATH).
#[test]
fn dx12_compiler_is_fxc_or_dxc_beside_the_exe() {
use wgpu::Dx12Compiler as C;
let dir = temp_dir("dxc");
std::fs::create_dir_all(&dir).unwrap();
assert!(matches!(dx12_compiler(None, None), C::Fxc));
assert!(matches!(dx12_compiler(None, Some(&dir)), C::Fxc));
std::fs::write(dir.join("dxcompiler.dll"), b"MZ").unwrap();
let expected = dir.join("dxcompiler.dll");
assert!(matches!(dx12_compiler(None, Some(&dir)), C::DynamicDxc { dxc_path } if Path::new(&dxc_path) == expected));
// An explicit WGPU_DX12_COMPILER wins; an unknown value is ignored.
assert!(matches!(dx12_compiler(Some(" FXC "), Some(&dir)), C::Fxc));
assert!(matches!(dx12_compiler(Some("dxc"), None), C::DynamicDxc { dxc_path } if dxc_path == "dxcompiler.dll"));
assert!(matches!(dx12_compiler(Some("quantum"), None), C::Fxc));
let _ = std::fs::remove_dir_all(&dir);
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let plan = plan(Auto, None, None, false, Os::Windows);
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
C::Fxc => true,
C::DynamicDxc { dxc_path } => Path::new(dxc_path).is_absolute(),
C::StaticDxc | C::Auto => false,
};
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
}
}
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// Metal's compiler crashed on them on Intel Iris Pro, losing the device in every mode (#3107).
assert!(skips_indirect_validation(Os::Mac, true));
// Apple silicon, Windows and Linux keep wgpu's validation.
for (os, intel) in [(Os::Mac, false), (Os::Windows, true), (Os::Other, true), (Os::Other, false)] {
assert!(!skips_indirect_validation(os, intel), "{os:?} intel={intel}");
}
for (os, safe_gpu) in [(Os::Windows, false), (Os::Other, true), (Os::Other, false)] {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
let before = before.instance_descriptor.flags;
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
}
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
}
+73 -39
View File
@@ -9,11 +9,11 @@
//!
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
//! it can't be read (musl, NixOS, some containers) and a library isn't found in the directories,
//! that proves nothing, so we start silently. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
//!
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
//! touches the system, and it is only called on Linux.
//! and [`available`] touch the system, and they are only called on Linux.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::path::{Path, PathBuf};
@@ -111,9 +111,10 @@ pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool
})
}
/// A real `ldconfig -p` listing starts with "N libs found in cache".
/// A real `ldconfig -p` listing starts with "N libs found in cache" (translated on a system whose
/// locale `LC_ALL=C` doesn't reach): a count, then the cache's name.
fn ldconfig_output_is_valid(output: &str) -> bool {
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
output.lines().next().is_some_and(|l| l.split_whitespace().next().is_some_and(|n| n.parse::<u32>().is_ok()))
}
/// Where we look for libraries, and what we found there.
@@ -200,7 +201,7 @@ fn install_command(packages: &[&str], distro: Distro) -> String {
}
/// The message for missing libraries: what's missing, and the command that installs it.
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro) -> String {
let session_name = match session {
DisplaySession::Wayland => "Wayland",
DisplaySession::X11 => "X11",
@@ -214,26 +215,26 @@ pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distr
packages.push(p);
}
}
let mut msg = if conclusive {
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
} else {
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", "))
};
let mut msg = format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "));
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
if distro == Distro::Unknown {
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
}
if conclusive {
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
}
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
msg
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-linux-gnu" } else { "x86_64-linux-gnu" };
let triplet = if cfg!(target_arch = "aarch64") {
"aarch64-linux-gnu"
} else if cfg!(target_arch = "riscv64") {
"riscv64-linux-gnu"
} else {
"x86_64-linux-gnu"
};
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
dirs.push(PathBuf::from(d));
}
@@ -245,7 +246,9 @@ fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
fn read_ldconfig() -> Option<String> {
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
// The listing is parsed, so it must be the untranslated one: a German system prints
// "5522 Bibliotheken im Cache", which `ldconfig_output_is_valid` took for no cache at all.
if let Ok(out) = std::process::Command::new(cmd).arg("-p").env("LC_ALL", "C").output()
&& out.status.success()
{
let text = String::from_utf8_lossy(&out.stdout).into_owned();
@@ -257,29 +260,43 @@ fn read_ldconfig() -> Option<String> {
None
}
/// Run the check against this system. `Err` carries the message to print before exiting with a
/// non-zero status; a warning is printed here and the app starts.
pub fn preflight() -> Result<(), String> {
/// This system's linker cache and library directories.
fn system_probe() -> Probe {
Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) }
}
/// Whether the linker cache lists `soname`; `None` when the cache can't be read, which proves
/// nothing (musl, NixOS, some containers).
pub fn cached(soname: &str) -> Option<bool> {
read_ldconfig().map(|text| ldconfig_has(&text, soname, ldconfig_arch_tag()))
}
/// Are all the libraries `session` needs found on this system? (For choosing a display server:
/// libraries the check can't find count as missing.)
pub fn available(session: DisplaySession) -> bool {
check(session, &system_probe(), |p| p.exists()) == Verdict::Ok
}
/// Run the check for the display server the window opens on against this system. `Err` carries
/// the message to print before exiting with a non-zero status.
pub fn preflight(session: DisplaySession) -> Result<(), String> {
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
return Ok(());
}
let session = session_from_env(|k| std::env::var(k).ok());
if session == DisplaySession::None {
return Ok(());
}
let probe = Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) };
match check(session, &probe, |p| p.exists()) {
Verdict::Ok => Ok(()),
Verdict::Missing { missing, conclusive } => {
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
let msg = missing_message(session, &missing, distro, conclusive);
if conclusive {
Err(msg)
} else {
eprint!("{msg}");
Ok(())
}
}
let verdict = check(session, &system_probe(), |p| p.exists());
decide(session, verdict, || std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown))
}
/// `Err` with the message to print only when the linker cache was read and the libraries are
/// really absent. Without the cache (musl has no `ldconfig -p`; NixOS, AppImages that ship their
/// own libraries) not finding a file proves nothing, so we start without a misleading warning.
fn decide(session: DisplaySession, verdict: Verdict, distro: impl FnOnce() -> Distro) -> Result<(), String> {
match verdict {
Verdict::Missing { missing, conclusive: true } => Err(missing_message(session, &missing, distro())),
Verdict::Missing { conclusive: false, .. } | Verdict::Ok => Ok(()),
}
}
@@ -317,6 +334,10 @@ mod tests {
fn ldconfig_parsing_matches_name_and_architecture() {
assert!(ldconfig_output_is_valid(CACHE));
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
// A translated listing is still a listing (it used to be rejected, so the check was
// silently skipped on non-English systems).
assert!(ldconfig_output_is_valid("5522 Bibliotheken im Cache »/etc/ld.so.cache« gefunden\n\tlibz.so.1 (libc6,x86-64) => /usr/lib/libz.so.1\n"));
assert!(!ldconfig_output_is_valid("Error: kein Cache\n"));
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
@@ -352,7 +373,7 @@ mod tests {
assert!(conclusive);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian);
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
@@ -374,13 +395,13 @@ mod tests {
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
assert_eq!(missing.len(), 1);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora);
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
}
#[test]
fn library_dirs_count_and_unread_cache_only_warns() {
fn library_dirs_count_and_unread_cache_is_inconclusive() {
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
@@ -388,16 +409,29 @@ mod tests {
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
// Not found and no cache to confirm it: inconclusive (the app starts, see #1144).
let v = check(DisplaySession::Wayland, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
assert!(!conclusive);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
assert!(msg.contains("Starting anyway"), "{msg}");
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown);
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
}
#[test]
fn unreadable_cache_never_warns_or_fails() {
// Issue #1144: Alpine (musl) has no `ldconfig -p`, so a library that exists was reported missing.
let probe = Probe { ldconfig: None, dirs: vec![] };
let verdict = check(DisplaySession::X11, &probe, |_| false);
assert_eq!(decide(DisplaySession::X11, verdict, || Distro::Debian), Ok(()));
// A readable cache that lacks a library is still an error.
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
let verdict = check(DisplaySession::X11, &probe, |_| false);
let Err(msg) = decide(DisplaySession::X11, verdict, || Distro::Debian) else { panic!("expected an error") };
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
}
#[test]
fn distro_detection() {
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
+432
View File
@@ -0,0 +1,432 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (GPU startup fallbacks, a
//! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/photocraft.log`
//! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to
//! `.2`), so the log of a run that crashed survives the next launch.
//!
//! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,photocraft_gpu=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`photocraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "photocraft.log";
/// How many previous logs are kept (`photocraft.1.log` … `photocraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,photocraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let thread = std::thread::current();
let line = format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("photocraft"), LevelFilter::Info);
assert_eq!(f.level_for("photocraft::gpu_startup"), LevelFilter::Info);
assert_eq!(f.level_for("photocraft_gpu::health"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,photocraft_gpu=trace,photocraft_gpu::health=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("photocraft_gpu::canvas"), LevelFilter::Trace);
assert_eq!(f.level_for("photocraft_gpu::health"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
for spec in ["", ",,,", "=", "=debug", "photocraft=", "nonsense=loud", "🦀=info", "*", "a=b=c", "warn,,photocraft=DEBUG"] {
let f = Filter::parse(spec);
let _ = f.level_for("photocraft");
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,photocraft=DEBUG").level_for("photocraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
// 2026-10-08T07:59:17.728Z
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(std::fs::read_to_string(dir.join(LOG_FILE)).expect("current"), "run 4");
assert_eq!(std::fs::read_to_string(dir.join("photocraft.1.log")).expect("1"), "run 3");
assert_eq!(std::fs::read_to_string(dir.join("photocraft.2.log")).expect("2"), "run 2");
assert!(!dir.join("photocraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "photocraft::gpu_startup", "GPU startup marker: denied"),
"2026-10-08T07:59:17.728Z WARN [main] photocraft::gpu_startup: GPU startup marker: denied\n"
);
// A multi-line message (a panic with a backtrace) keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "photocraft::app_dirs", "settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Debug, "photocraft", "below info");
let text = read(&path);
assert!(text.contains(" INFO [") && text.contains("photocraft::app_dirs: settings directory\n"), "{text}");
assert!(text.contains("wgpu_hal::vulkan: a real warning\n"), "{text}");
assert!(!text.contains("too chatty") && !text.contains("below info"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("photocraft_gpu").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
let _ = std::fs::remove_file(&dir);
}
}
+396
View File
@@ -0,0 +1,396 @@
//! The macOS menu bar, through muda (a standalone native menu crate, not Tauri: no webview, just
//! AppKit menus behind a safe API, no GTK on macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in
//! `photocraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s.
//!
//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window)
//! gets `id#2` for its second copy, as muda ids must be unique.
//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything
//! else (labels, enabled, checked) is updated in place. Opening or closing a document rebuilds
//! only the Window menu, whose list of documents changed: AppKit adds its own items to that
//! menu (window tiling, the window list), so muda can't insert rows there by position.
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key
//! equivalents go back to egui as the key press that was made (the event's modifiers, not the
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply.
//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H.
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, channel};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
use photocraft_ui_egui::native_menu::{self, Backend, Chord, Event, MenuBar, MenuRole, Node, Standard};
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
/// One native item.
struct Entry {
handle: Handle,
/// The command id (several entries can share one).
command: String,
chord: Option<Chord>,
label: String,
enabled: bool,
}
/// How an item was chosen, read from AppKit's current event while it calls the handler.
struct Chosen {
id: String,
/// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧.
key: bool,
mods: [bool; 4],
}
pub struct MacMenu {
menu: Option<Menu>,
/// By muda id.
entries: HashMap<String, Entry>,
rx: Receiver<Chosen>,
structure: u64,
/// The Window menu, the muda ids of its items, and its [`native_menu::window_key`].
window: Option<(Submenu, Vec<String>, u64)>,
}
impl MacMenu {
pub fn new(ctx: &egui::Context) -> MacMenu {
let (tx, rx) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
// AppKit calls this on the main thread, from the event that chose the item: a key
// equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent());
let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown);
let flags = event.as_ref().map(|ev| ev.modifierFlags()).unwrap_or(NSEventModifierFlags::empty());
let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift]
.map(|m| flags.contains(m));
let _ = tx.send(Chosen { id: e.id.0, key, mods });
// Wake egui, so the item runs now rather than on the next mouse move.
repaint.request_repaint();
}));
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0, window: None }
}
fn build(&mut self, bar: &MenuBar) {
if let Some(old) = self.menu.take() {
old.remove_for_nsapp();
}
self.entries.clear();
self.window = None;
let menu = Menu::new();
let mut special = Vec::new();
for m in &bar.menus {
let (sub, ids) = self.submenu(m);
let _ = menu.append(&sub);
if m.role == MenuRole::Window {
self.window = Some((sub.clone(), ids, native_menu::window_key(bar)));
}
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
special.push((m.role, sub));
}
}
menu.init_for_nsapp();
// Only once the menu is NSApp's: muda looks the submenus up in the installed main menu.
for (role, sub) in special {
match role {
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
_ => sub.set_as_help_menu_for_nsapp(),
}
}
self.menu = Some(menu);
self.structure = native_menu::structure_key(bar);
}
/// A top-level menu, and the muda ids of the items it added.
fn submenu(&mut self, m: &native_menu::Menu) -> (Submenu, Vec<String>) {
let before: HashSet<String> = self.entries.keys().cloned().collect();
let sub = Submenu::new(escape(&m.title), true);
self.append(&sub, &m.children);
let ids = self.entries.keys().filter(|id| !before.contains(*id)).cloned().collect();
(sub, ids)
}
/// Replace the Window menu (a document opened or closed), leaving the other menus as they are.
fn rebuild_window(&mut self, bar: &MenuBar) {
let (Some(menu), Some((old, ids, _))) = (self.menu.clone(), self.window.take()) else { return self.build(bar) };
let Some((at, spec)) = bar.menus.iter().enumerate().find(|(_, m)| m.role == MenuRole::Window) else { return self.build(bar) };
if menu.remove(&old).is_err() {
return self.build(bar);
}
for id in ids {
self.entries.remove(&id);
}
let (sub, ids) = self.submenu(spec);
if menu.insert(&sub, at).is_err() {
return self.build(bar);
}
sub.set_as_windows_menu_for_nsapp();
self.window = Some((sub, ids, native_menu::window_key(bar)));
}
fn append(&mut self, sub: &Submenu, nodes: &[Node]) {
for n in nodes {
match n {
Node::Separator => {
let _ = sub.append(&PredefinedMenuItem::separator());
}
Node::Submenu { label, children, .. } => {
let s = Submenu::new(escape(label), !children.is_empty());
self.append(&s, children);
let _ = sub.append(&s);
}
Node::Standard(item) => {
let p = match item {
Standard::Services => PredefinedMenuItem::services(Some(photocraft_ui_egui::i18n::t("Services"))),
Standard::HideOthers => PredefinedMenuItem::hide_others(Some(photocraft_ui_egui::i18n::t("Hide Others"))),
Standard::ShowAll => PredefinedMenuItem::show_all(Some(photocraft_ui_egui::i18n::t("Show All"))),
Standard::Minimize => PredefinedMenuItem::minimize(None),
Standard::Zoom => PredefinedMenuItem::maximize(Some(photocraft_ui_egui::i18n::t("Zoom"))),
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None),
Standard::CloseWindow => PredefinedMenuItem::close_window(None),
};
let _ = sub.append(&p);
}
Node::Item(it) => {
let id = self.unique_id(&it.id);
let chord = it.shortcut.as_deref().and_then(Chord::parse);
let accel = chord.as_ref().filter(|c| c.native_ok()).and_then(accelerator);
let handle = match it.checked {
Some(c) => {
let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel);
let _ = sub.append(&h);
Handle::Check(h)
}
None => {
let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel);
let _ = sub.append(&h);
Handle::Plain(h)
}
};
self.entries.insert(id, Entry { handle, command: it.id.clone(), chord, label: it.label.clone(), enabled: it.enabled });
}
}
}
}
/// muda ids must be unique: the second `edit.keyboardShortcuts` becomes `…#2`.
fn unique_id(&self, id: &str) -> String {
let mut unique = id.to_string();
let mut n = 2;
while self.entries.contains_key(&unique) {
unique = format!("{id}#{n}");
n += 1;
}
unique
}
}
impl Backend for MacMenu {
fn sync(&mut self, bar: &MenuBar) {
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure {
self.build(bar);
return;
}
if self.window.as_ref().map(|w| w.2) != Some(native_menu::window_key(bar)) {
self.rebuild_window(bar);
}
let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect();
for e in self.entries.values_mut() {
let Some(it) = items.get(e.command.as_str()) else { continue };
if e.label != it.label {
let text = escape(&it.label);
match &e.handle {
Handle::Plain(h) => h.set_text(text),
Handle::Check(h) => h.set_text(text),
}
e.label = it.label.clone();
}
if e.enabled != it.enabled {
match &e.handle {
Handle::Plain(h) => h.set_enabled(it.enabled),
Handle::Check(h) => h.set_enabled(it.enabled),
}
e.enabled = it.enabled;
}
// Compare with AppKit's own state: muda toggles a check item when it's clicked.
if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked)
&& h.is_checked() != c
{
h.set_checked(c);
}
}
}
fn drain(&mut self) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(chosen) = self.rx.try_recv() {
// Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled.
let Some(e) = self.entries.get(&chosen.id) else { continue };
if e.command == native_menu::HIDE {
if let Some(mtm) = MainThreadMarker::new() {
NSApplication::sharedApplication(mtm).hide(None);
}
continue;
}
// A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open
// menu (no ⌘ or ⌃, and the item's shortcut needs one). The key is the item's (AppKit
// matched on it); the modifiers are the ones held.
let [cmd, ctrl, alt, shift] = chosen.mods;
let key =
e.chord.as_ref().filter(|c| chosen.key && (cmd || ctrl || !(c.cmd || c.ctrl))).map(|c| Chord { cmd, ctrl, alt, shift, key: c.key.clone() });
out.push(match key {
Some(chord) if e.command != native_menu::MINIMIZE => Event::Key(chord),
_ => Event::Click(e.command.clone()),
});
}
out
}
}
/// Install the menu bar now (in eframe's creator closure, so winit's default menu doesn't stay).
pub fn install(ctx: &egui::Context, app: &photocraft_ui_egui::PhotocraftApp) -> Option<native_menu::NativeMenu> {
let language = &app.session.prefs().interface.language;
let lang = photocraft_ui_egui::i18n::Lang::from_pref(language);
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(app), lang, language).bar;
// Never crash for a menu: if AppKit refuses, keep the in-window menus.
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut menu = MacMenu::new(ctx);
menu.sync(&bar);
menu
}));
match built {
Ok(menu) => Some(native_menu::NativeMenu::new(Box::new(menu))),
Err(_) => {
log::warn!("couldn't install the macOS menu bar; using the in-window menus");
None
}
}
}
/// `&` marks a mnemonic in muda labels.
fn escape(s: &str) -> String {
s.replace('&', "&&")
}
/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together.
fn accelerator(c: &Chord) -> Option<Accelerator> {
let mut mods = Modifiers::empty();
for (on, m) in [(c.cmd, Modifiers::META), (c.ctrl, Modifiers::CONTROL), (c.alt, Modifiers::ALT), (c.shift, Modifiers::SHIFT)] {
if on {
mods |= m;
}
}
const LETTERS: [Code; 26] = [
Code::KeyA,
Code::KeyB,
Code::KeyC,
Code::KeyD,
Code::KeyE,
Code::KeyF,
Code::KeyG,
Code::KeyH,
Code::KeyI,
Code::KeyJ,
Code::KeyK,
Code::KeyL,
Code::KeyM,
Code::KeyN,
Code::KeyO,
Code::KeyP,
Code::KeyQ,
Code::KeyR,
Code::KeyS,
Code::KeyT,
Code::KeyU,
Code::KeyV,
Code::KeyW,
Code::KeyX,
Code::KeyY,
Code::KeyZ,
];
const DIGITS: [Code; 10] =
[Code::Digit0, Code::Digit1, Code::Digit2, Code::Digit3, Code::Digit4, Code::Digit5, Code::Digit6, Code::Digit7, Code::Digit8, Code::Digit9];
const F: [Code; 24] = [
Code::F1,
Code::F2,
Code::F3,
Code::F4,
Code::F5,
Code::F6,
Code::F7,
Code::F8,
Code::F9,
Code::F10,
Code::F11,
Code::F12,
Code::F13,
Code::F14,
Code::F15,
Code::F16,
Code::F17,
Code::F18,
Code::F19,
Code::F20,
Code::F21,
Code::F22,
Code::F23,
Code::F24,
];
let k = c.key.as_str();
let mut chars = k.chars();
let code = match (chars.next(), chars.next()) {
(Some(ch), None) if ch.is_ascii_alphabetic() => *LETTERS.get((ch.to_ascii_uppercase() as usize).checked_sub('A' as usize)?)?,
(Some(ch), None) if ch.is_ascii_digit() => *DIGITS.get((ch as usize).checked_sub('0' as usize)?)?,
_ if c.is_function_key() => *F.get(k.get(1..)?.parse::<usize>().ok()?.checked_sub(1)?)?,
_ => match k {
"[" => Code::BracketLeft,
"]" => Code::BracketRight,
"\\" => Code::Backslash,
"/" => Code::Slash,
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
";" => Code::Semicolon,
"," => Code::Comma,
"." => Code::Period,
"`" => Code::Backquote,
"Delete" | "Backspace" => Code::Backspace,
"Enter" if !mods.is_empty() => Code::Enter,
"Tab" if !mods.is_empty() => Code::Tab,
"Space" if !mods.is_empty() => Code::Space,
_ => return None,
},
};
Some(Accelerator::new(mods, code))
}
#[cfg(test)]
mod tests {
use super::*;
/// Every shortcut the Mac menu shows becomes a key equivalent (or stays egui's, for bare keys).
#[test]
fn every_menu_shortcut_maps_to_a_key_equivalent() {
let app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(&app), photocraft_ui_egui::i18n::Lang::EN, "auto").bar;
let mut native = 0;
for it in bar.items() {
let Some(c) = it.shortcut.as_deref().and_then(Chord::parse) else { continue };
if c.native_ok() {
assert!(accelerator(&c).is_some(), "{} ({}) has no key equivalent", c.portable(), it.id);
native += 1;
}
}
assert!(native > 70, "{native} key equivalents");
}
#[test]
fn control_and_command_stay_distinct() {
let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap();
let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap();
assert_ne!(a, b);
}
}
+41
View File
@@ -0,0 +1,41 @@
//! macOS window lifecycle: title bar dragging and quitting.
//!
//! Dragging: the window draws under a transparent title bar (`main.rs`), so AppKit would
//! otherwise drag the window from anywhere in that strip, menus included: pressing a menu title
//! and dragging down to an item moved the window instead. Turning AppKit's own dragging off leaves
//! only the app's drag region (the free gap between the menus and the controls,
//! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's
//! `performWindowDragWithEvent:`, which works on an unmovable window).
//!
//! Quitting: see [`terminate_later`]. All safe objc2 calls.
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
/// Stop AppKit dragging the app's windows by their title bar strip.
pub fn disable_native_title_drag() {
let Some(mtm) = MainThreadMarker::new() else {
log::warn!("title bar: not on the main thread; AppKit window dragging left on");
return;
};
for window in NSApplication::sharedApplication(mtm).windows().iter() {
window.setMovable(false);
}
}
/// Quit the way a Mac app does, with `-[NSApplication terminate:]` (`Services::quit`), instead of
/// letting eframe close the window. eframe drops the window while AppKit's run loop is still
/// running, and on Macs with a Touch Bar AppKit's next display cycle then tries to stop observing
/// a view that is gone: an uncaught exception that crashes (macOS 27) or leaves a windowless app
/// that only Force Quit ends (macOS 26) (#1458, #1575). `terminate:` ends the process before
/// another display cycle runs; winit's `applicationWillTerminate:` closes the windows and has
/// eframe save the window layout first, as the old ⌘Q (winit's default menu) did.
///
/// Scheduled on the main queue: winit has to be out of its event handler, or eframe's exit
/// handling (and the save) is dropped.
pub fn terminate_later() {
dispatch2::DispatchQueue::main().exec_async(|| match MainThreadMarker::new() {
Some(mtm) => NSApplication::sharedApplication(mtm).terminate(None),
None => log::warn!("quit: the main queue ran off the main thread; not terminating"),
});
}
+458 -53
View File
@@ -2,12 +2,15 @@
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
//!
//! `--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
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
//!
//! `--in-window-menus` (or `PHOTOCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
//! The first line must authenticate; subsequent request lines get reply lines.
//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods.
@@ -19,19 +22,32 @@
mod app_dirs;
mod app_icon;
mod appearance;
#[cfg(target_os = "macos")]
mod apple_events;
mod caps_lock;
mod control_server;
mod crash_guard;
mod cursor;
mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_window;
// Pure logic is tested on every platform; only Linux runs the check.
#[cfg(any(target_os = "linux", test))]
mod linux_libs;
mod logging;
mod monitor_profile;
mod photoshop_settings;
mod screen_color;
mod services;
// 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;
mod ui_state;
mod window_frame;
use photocraft_engine::Session;
use photocraft_ui_egui::PhotocraftApp;
@@ -39,38 +55,203 @@ use photocraft_ui_egui::PhotocraftApp;
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
const APP_ID: &str = "ai.storyteller.photocraft";
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
/// keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
/// Interface › System Title Bar asks for the system's (#1271, #1316). Read from the saved
/// preferences before the window opens; a missing or unreadable file keeps the default.
fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
let prefs: photocraft_engine::prefs::Preferences =
prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
CUSTOM_TITLEBAR && !prefs.interface.system_title_bar
}
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
/// different offset every launch (#419). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
centered: true,
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
window_builder: Some(Box::new(at_least_min_size)),
..Default::default()
}
}
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
/// as a 160 × 64 strip on every launch.
fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
let (Some(size), Some(min)) = (builder.inner_size, builder.min_inner_size) else { return builder };
let grown = size.max(min);
if grown != size {
builder.position = builder.position.map(|pos| pos - (grown - size) / 2.0);
builder.inner_size = Some(grown);
}
builder
}
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
/// names the value (issue #701): silently running with no control server leaves a launcher or
/// agent unable to tell a typo from a successful grant.
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
/// when there are none, else 2 (a command-line usage error), never 0.
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
#[cfg(test)]
mod control_port_tests {
use super::{control_args_exit_code, parse_control_port};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(control_args_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(control_args_exit_code(&errors), Some(2));
}
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, a following flag eaten as the value, and an empty value all fail,
// naming the offending value in the message.
let err = parse_control_port("78787", "--control").unwrap_err();
assert!(err.contains("78787"), "{err}");
assert!(parse_control_port("--safe-gpu", "--control").is_err());
assert!(parse_control_port("", "--control").is_err());
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut control_port: Option<u16> = None;
let mut control_arg_errors: Vec<String> = Vec::new();
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
}
}
let mut control_token = None;
let mut control_token_file = None;
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
let mut files = Vec::new();
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
// 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 args = std::env::args().skip(1);
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() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--control-token" => control_token = args.next(),
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
"--safe-gpu" => safe_gpu = true,
"--version" => {
match a.to_str() {
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
},
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => control_token = Some(token),
Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
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("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
}
// Old macOS passes a process serial number when launched from Finder.
_ if a.starts_with("-psn_") => {}
_ => files.push(a),
Some(s) if s.starts_with("-psn_") => {}
Some(s) => files.push(s.to_owned()),
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
// A malformed control port must not silently drop the control server (issue #701): name the
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = control_args_exit_code(&control_arg_errors) {
for error in &control_arg_errors {
eprintln!("photocraft: {error}");
}
std::process::exit(code);
}
// The log file lives under the settings directory; opened after the arguments, so `--version`
// and usage errors leave no file behind. Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, services::config_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
// 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")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = tablet::display_session();
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
#[cfg(target_os = "linux")]
let session = if session == linux_libs::DisplaySession::Wayland
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
&& linux_libs::available(linux_libs::DisplaySession::X11)
{
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
linux_libs::DisplaySession::X11
} else {
session
};
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
// one is missing (issue #201). Name the package to install and exit instead.
#[cfg(target_os = "linux")]
if let Err(message) = linux_libs::preflight() {
if let Err(message) = linux_libs::preflight(session) {
eprint!("{message}");
std::process::exit(1);
}
@@ -81,7 +262,7 @@ fn main() -> eframe::Result {
Ok(token) => token,
Err(e) => {
eprintln!("photocraft: cannot configure control authentication: {e}");
return Ok(());
std::process::exit(2);
}
};
if let Some(path) = token_file {
@@ -95,7 +276,7 @@ fn main() -> eframe::Result {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
return Ok(());
std::process::exit(2);
}
};
Some((port, token, workspace))
@@ -109,41 +290,44 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
// Pen tablet samples on macOS (AppKit event monitor, before winit sees each event) and X11
// (started once eframe says which display server it is on). The monitor lives until the event
// loop returns.
let stylus_feed = photocraft_ui_egui::stylus::StylusFeed::default();
// The macOS pen tablet monitor (installed once eframe created the app, see below); it lives
// until the event loop returns.
#[cfg(target_os = "macos")]
let _tablet = tablet::install_macos(&stylus_feed);
let tablet_monitor = std::cell::OnceCell::new();
#[cfg(target_os = "macos")]
let tablet_monitor = &tablet_monitor;
// Read the main display's ICC profile while the window opens (colour-managed canvas).
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
#[cfg(target_os = "linux")]
let monitor = monitor_profile::detect_async_for_session(native_session);
#[cfg(not(target_os = "linux"))]
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
..Default::default()
};
let custom_titlebar = custom_titlebar(services::prefs_file().as_deref());
let mut options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; open on X11 when that was chosen.
#[cfg(target_os = "linux")]
if session == linux_libs::DisplaySession::X11 {
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
}));
}
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
// driver moves to a safer one), and lock this start's marker until the first frames render.
let t_sentinel = std::time::Instant::now();
let os = gpu_startup::Os::current();
let (pref, _) = gpu_startup::read_prefs(services::prefs_file().as_deref());
let (pref, mode) = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref());
let (previous, sentinel) = match services::config_dir() {
Some(dir) => gpu_startup::Sentinel::begin(&dir),
None => (gpu_startup::Previous::Clean, None),
};
let env_backend = std::env::var("WGPU_BACKEND").ok();
let plan = gpu_startup::plan(pref, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
let plan = gpu_startup::plan_with_mode(pref, mode, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
if let Some(m) = previous.crashed() {
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
}
@@ -163,31 +347,79 @@ fn main() -> eframe::Result {
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
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;
let keep_marker = gpu_startup::keep_marker_after_error(&plan, os);
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_callback = app_created.clone();
let started_sentinel = sentinel.clone();
let result = eframe::run_native(
"Photocraft",
"PhotoCraft",
options,
Box::new(move |cc| {
created_in_callback.store(true, std::sync::atomic::Ordering::Relaxed);
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
let mut services = services::native(automation);
services.preset_store = presets;
#[cfg(target_os = "linux")]
let display = tablet::DisplayKind::of(cc);
#[cfg(target_os = "linux")]
{
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
file_dialog_probe::start();
if services.is_wayland {
let var = |name| std::env::var(name).ok();
services.xwayland_command =
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
app.custom_titlebar = custom_titlebar;
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
#[cfg(target_os = "macos")]
mac_window::disable_native_title_drag();
// Long commands and file opens run as background jobs with progress and Cancel (#210).
app.background_jobs = std::env::var_os("PHOTOCRAFT_INLINE_JOBS").is_none();
// Displays and their profiles (#569): wait briefly so the first frames already use
// the right profile; a slower reading is applied when it arrives, and the shell reads
// again when the displays may have changed.
if let Some(rx) = monitor {
#[cfg(target_os = "linux")]
{
app.services.read_displays = Some(std::sync::Arc::new(move || monitor_profile::detect_async_for_session(native_session)));
}
#[cfg(not(target_os = "linux"))]
{
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
}
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
photocraft_ui_egui::monitor_status::apply(&mut app, Err("the display profile reader stopped without an answer".into()));
}
}
}
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
info.fallback = plan.reason.clone().or_else(|| gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone());
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}")),
(reason, note) => reason.or(note),
};
info.canvas = "cpu".into();
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
if plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
&& app.session.prefs().performance.use_gpu
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
{
app.set_wgpu(rs);
} else {
@@ -195,13 +427,33 @@ fn main() -> eframe::Result {
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
(app.perf.gpu_info.canvas == "cpu" && mode != photocraft_engine::prefs::RenderingMode::Cpu)
.then(|| app.perf.gpu_info.fallback.clone())
.flatten()
});
if let Some(reason) = fallback_reason {
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
}
app.perf.span("gpuSentinel", sentinel_ms);
// Once the first frames rendered: clear the marker, and keep a crash fallback.
let remember_cpu =
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
let remember = plan.remember.then_some(plan.backend);
app.on_started(move |app| {
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if remember_cpu
&& let Err(error) = app.run(
"prefs.set",
serde_json::json!({"values": {
"performance.renderingMode": "cpu", "performance.useGpu": false
}}),
)
{
log::warn!("couldn't remember CPU recovery: {error}");
}
if let Some(b) = remember
&& app.session.prefs().performance.gpu_backend != b
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
@@ -216,27 +468,180 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
{
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
// Quit through AppKit, not by closing the window (Touch Bar Macs, #1575, #1458).
app.services.quit = Some(Box::new(mac_window::terminate_later));
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
if !in_window_menus {
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
}
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
// 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
// border offset the content and the pointer on some Windows systems (#2246).
app.services.window_frame = window_frame::service(cc, custom_titlebar);
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
// precise crosshair for painting tools while it is toggled on (#1758).
app.services.caps_lock = caps_lock::service(cc);
app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
// write into the stylus feed. The monitor goes in here, not before the event loop:
// AppKit's shared application only exists once winit created it (#759).
#[cfg(target_os = "macos")]
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
let _ = tablet_monitor.set(monitor);
}
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor installed above
// and the X11 reader write into this feed.
app.stylus.feed = stylus_feed;
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, tablet::DisplayKind::of(cc));
tablet::spawn_x11(&app.stylus.feed, display);
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
use photocraft_ui_egui::i18n;
let line = i18n::t("{path}: the path is not valid Unicode; rename the file and open it again.");
let lines = unreadable_paths.iter().map(|p| i18n::fmt(line, &[("path", p.as_str())])).collect();
photocraft_ui_egui::notices::post(&mut app, i18n::t("Could not open"), lines, true, None);
}
// Portable marker found but its data folder isn't writable (#228): say where settings went.
if let Some(w) = &app_dirs::current().warning {
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false);
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false, None);
}
Ok(Box::new(app))
}),
);
// Closed before the first frames rendered: not a driver crash. (A start that failed to
// create its device keeps the marker, so the next one tries a safer backend.)
if result.is_ok()
&& let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
// 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.
let renderer_error = gpu_startup::keep_marker_after_run(&result);
if !(renderer_error && keep_marker)
&& let Some(mut s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
{
s.finish();
if result.is_err()
&& !renderer_error
&& let Some(marker) = previous.crashed()
{
// This failure supplies no new graphics-crash evidence: retain the previous
// marker, rather than recording this attempt's fallback backend.
if let Err(error) = s.write(marker.clone()) {
log::warn!("couldn't restore previous GPU startup marker: {error}");
}
} else {
s.finish();
}
}
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
// Only renderer initialization failures qualify; never restart after editing has begun.
if retry_cpu && !app_created.load(std::sync::atomic::Ordering::Relaxed) && matches!(&result, Err(eframe::Error::Wgpu(_))) {
let reason = result.as_ref().err().map(ToString::to_string).unwrap_or_default();
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if let Ok(exe) = std::env::current_exe() {
let launched = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.arg("--safe-gpu")
.env_remove("WGPU_BACKEND")
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
.spawn();
match launched {
Ok(_) => return Ok(()),
Err(error) => log::error!("could not start CPU compatibility mode: {error}"),
}
}
}
result
}
#[cfg(test)]
mod tests {
#[test]
fn window_and_panel_geometry_survive_a_restart() {
let options = super::native_options(super::CUSTOM_TITLEBAR);
assert!(options.persist_window);
assert_eq!(options.persistence_path, super::services::config_dir().map(|dir| dir.join("ui.ron")));
#[derive(Default)]
struct Storage(std::collections::BTreeMap<String, String>);
impl eframe::Storage for Storage {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.into(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
let ctx = egui::Context::default();
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
let mut output = ctx.run_ui(input(), |ui| {
egui::Panel::right("dock").exact_size(410.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let mut storage = Storage::default();
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
let restored = egui::Context::default();
restored.memory_mut(|memory| *memory = eframe::get_value(&storage, "egui").unwrap());
let mut output = restored.run_ui(input(), |ui| {
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
assert_eq!(panel.size().x, 410.0);
}
#[test]
fn the_window_opens_centred_at_its_default_size() {
let o = super::native_options(super::CUSTOM_TITLEBAR);
assert!(o.centered, "#419: centred, not cascaded from the top-left corner");
assert_eq!(o.viewport.inner_size, Some(egui::vec2(1440.0, 900.0)));
// eframe shrinks the start size to the monitor, so the centred position is on-screen.
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
}
#[test]
fn a_window_saved_while_minimized_reopens_at_the_minimum_size() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// Saved minimized on Windows, then centred on a 1920 × 1080 monitor by eframe.
let saved = super::native_options(super::CUSTOM_TITLEBAR).viewport.with_inner_size([160.0, 64.0]).with_position([880.0, 508.0]);
let opened = hook(saved);
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 480.0)));
assert_eq!(opened.position, Some(egui::pos2(580.0, 300.0)), "still centred");
// Too narrow only: only the width grows.
let opened = super::at_least_min_size(super::native_options(false).viewport.with_inner_size([500.0, 900.0]));
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 900.0)));
// A window at or above the minimum, or with no size, is left alone.
for size in [[760.0, 480.0], [1100.0, 760.0]] {
let builder = super::native_options(false).viewport.with_inner_size(size).with_position([10.0, 20.0]);
let opened = super::at_least_min_size(builder);
assert_eq!((opened.inner_size, opened.position), (Some(egui::Vec2::from(size)), Some(egui::pos2(10.0, 20.0))));
}
let mut builder = super::native_options(false).viewport;
builder.inner_size = None;
assert_eq!(super::at_least_min_size(builder).inner_size, None);
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
// system decorations on Windows and Linux; macOS always has them.
let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("prefs.json");
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences file: the default");
std::fs::write(&file, "not json").unwrap();
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR, "an unreadable file: the default");
std::fs::write(&file, r#"{"interface":{"systemTitleBar":true}}"#).unwrap();
assert!(!super::custom_titlebar(Some(&file)));
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
std::fs::write(&file, r#"{"interface":{"systemTitleBar":false}}"#).unwrap();
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR);
if super::CUSTOM_TITLEBAR {
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+473 -33
View File
@@ -1,15 +1,48 @@
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
//! Monitor Profile = `auto`).
//! The displays and their ICC profiles, for the colour-managed canvas (Edit › Color Settings ›
//! Monitor Profile = `auto`; #569).
//!
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
//! return `None` (sRGB, or the profile chosen in Color Settings).
//! macOS: every `NSScreen` with its `CGDirectDisplayID`, name, frame and
//! `colorSpace.ICCProfileData`, documented AppKit APIs read through `osascript` (AppKit via
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
//! thread; the shell starts one at launch and again when the displays may have changed
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
//! it is on. Linux X11: RandR output names and CRTC frames are matched to colord display devices
//! by the exact `XRANDR_name` metadata; cloned outputs get a profile only when every output has
//! the same ICC bytes. Native Wayland, including Xwayland windows opened from a Wayland session,
//! reports no display profiles: eframe/winit has no safe way to set the app surface's Wayland
//! image description, so applying the output ICC in PhotoCraft could double-transform it.
//! Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
use std::sync::mpsc::Receiver;
/// Starts reading the main display's profile in the background.
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
use photocraft_engine::display_color::Display;
/// What the platform reader returns: the displays, or why there are none.
pub type Detection = Result<Vec<Display>, String>;
/// Starts reading the displays in the background; `None` when the platform has no reader.
#[cfg(not(target_os = "linux"))]
pub fn detect_async() -> Option<Receiver<Detection>> {
#[cfg(target_os = "macos")]
{
Some(spawn_reader(detect))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// Linux display profile reads are supported only for an X11 session. In particular, an app
/// window opened through Xwayland still belongs to the Wayland desktop and must not apply a
/// compositor-managed output profile a second time.
#[cfg(target_os = "linux")]
pub fn detect_async_for_session(session: crate::linux_libs::DisplaySession) -> Option<Receiver<Detection>> {
(session == crate::linux_libs::DisplaySession::X11).then(|| spawn_reader(detect))
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn spawn_reader(detect: fn() -> Detection) -> Receiver<Detection> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
@@ -17,29 +50,354 @@ pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
rx
}
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
/// points, Cocoa's bottom-left origin), the profile as base64, the profile's name in System
/// Settings and the display's name, tab-separated. Numbers as integers (text conversion of
/// reals follows the user's locale, e.g. "0,0"). The id goes through NSNumber's `stringValue`:
/// a display id above AppleScript's integer range (2^29) is otherwise coerced to a real and
/// printed as "2.077748985E+9" (#1371).
#[cfg(target_os = "macos")]
fn detect() -> Option<Vec<u8>> {
let out = std::process::Command::new("/usr/bin/osascript")
.args([
"-e",
"use framework \"AppKit\"",
"-e",
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
// An ICC profile starts with its size and carries `acsp` at offset 36.
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
const SCRIPT: &str = r#"use framework "AppKit"
set out to ""
repeat with s in (current application's NSScreen's screens() as list)
set f to s's frame()
set n to (((s's deviceDescription()'s objectForKey:"NSScreenNumber")'s stringValue()) as text)
set icc to ""
set cs to s's colorSpace()
set csName to ""
if cs is not missing value then
set csName to ((cs's localizedName()) as text)
set d to cs's ICCProfileData()
if d is not missing value then set icc to ((d's base64EncodedStringWithOptions:0) as text)
end if
set out to out & n & tab & ((item 1 of item 1 of f) as integer) & tab & ((item 2 of item 1 of f) as integer) & tab & ((item 1 of item 2 of f) as integer) & tab & ((item 2 of item 2 of f) as integer) & tab & icc & tab & csName & tab & ((s's localizedName()) as text) & linefeed
end repeat
return out"#;
#[cfg(target_os = "macos")]
fn detect() -> Detection {
let mut cmd = std::process::Command::new("/usr/bin/osascript");
cmd.args(["-e", SCRIPT]);
parse_reply(&run_helper(&mut cmd, HELPER_TIMEOUT)?)
}
#[cfg(not(target_os = "macos"))]
fn detect() -> Option<Vec<u8>> {
None
/// How long a reading may take before the helper is stopped (it normally takes about 0.4 s).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
const HELPER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Run the helper and return its standard output; a helper that fails, or runs longer than
/// `timeout`, is an error (and is killed and reaped), so a hung reading can't block later ones.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) -> Result<String, String> {
use std::io::Read;
use std::process::Stdio;
let mut child =
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run the display profile reader: {e}"))?;
// Drain both pipes while waiting: a reply larger than the pipe buffer would otherwise block
// the helper until the deadline.
let drain = |p: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut p) = p {
let _ = p.read_to_end(&mut buf);
}
buf
})
};
let out = drain(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let err = drain(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let deadline = std::time::Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("reading the display profiles took longer than {} s and was stopped", timeout.as_secs()));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("waiting for the display profile reader failed: {e}"));
}
}
};
let out = out.join().unwrap_or_default();
let err = err.join().unwrap_or_default();
if !status.success() {
let err = String::from_utf8_lossy(&err);
let first = err.lines().next().unwrap_or("").trim();
return Err(format!("reading the display profiles failed ({status}){}", if first.is_empty() { String::new() } else { format!(": {first}") }));
}
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(target_os = "linux")]
fn detect() -> Detection {
linux::detect()
}
#[cfg(target_os = "linux")]
mod linux {
use std::collections::{BTreeMap, HashMap};
use std::io::Read as _;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use photocraft_engine::display_color::Display;
use x11rb::connection::Connection as _;
use x11rb::protocol::randr;
use zbus::blocking::{connection::Builder, proxy::Proxy};
use zbus::zvariant::OwnedObjectPath;
use super::Detection;
const MAX_ICC_BYTES: usize = 64 * 1024 * 1024;
const COLORD: &str = "org.freedesktop.ColorManager";
const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
struct IccProfile {
name: String,
bytes: Arc<Vec<u8>>,
}
pub(super) fn detect() -> Detection {
let (connection, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
let root = connection.setup().roots.get(screen).ok_or_else(|| "the X11 server didn't provide a screen".to_string())?.root;
let resources = randr::get_screen_resources_current(&connection, root)
.map_err(|e| format!("couldn't request XRandR outputs: {e}"))?
.reply()
.map_err(|e| format!("couldn't read XRandR outputs: {e}"))?;
let mut output_names = HashMap::new();
let mut active_crtcs = BTreeMap::new();
for output in resources.outputs {
let info = match randr::get_output_info(&connection, output, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) => info,
Err(e) => {
log::debug!("couldn't read one XRandR output: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request one XRandR output: {e}");
continue;
}
};
if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
continue;
}
let Ok(name) = String::from_utf8(info.name) else {
log::debug!("skipping an XRandR output with a non-UTF-8 name");
continue;
};
if !name.is_empty() {
output_names.insert(output, name);
active_crtcs.insert(info.crtc, ());
}
}
let profiles = match colord_profiles() {
Ok(profiles) => profiles,
Err(e) => {
log::warn!("couldn't read X11 display profiles from colord: {e}");
HashMap::new()
}
};
let mut displays = Vec::with_capacity(active_crtcs.len());
for crtc in active_crtcs.into_keys() {
let crtc_info = match randr::get_crtc_info(&connection, crtc, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) if info.status == randr::SetConfig::SUCCESS && info.width > 0 && info.height > 0 => info,
Ok(_) => continue,
Err(e) => {
log::debug!("couldn't read XRandR CRTC {crtc}: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request XRandR CRTC {crtc}: {e}");
continue;
}
};
let Some(output_names) = crtc_info.outputs.iter().map(|output| output_names.get(output).cloned()).collect::<Option<Vec<_>>>() else { continue };
let Some(primary_name) = output_names.first() else { continue };
let profile = unambiguous_clone_profile(&output_names, &profiles);
displays.push(Display {
id: crtc,
name: primary_name.clone(),
frame: [f64::from(crtc_info.x), f64::from(crtc_info.y), f64::from(crtc_info.width), f64::from(crtc_info.height)],
profile_name: profile.as_ref().map(|p| p.name.clone()),
icc: profile.map(|p| p.bytes),
});
}
if displays.is_empty() {
return Err("X11 reported no active RandR outputs".into());
}
Ok(displays)
}
fn colord_profiles() -> Result<HashMap<String, Vec<IccProfile>>, String> {
let connection = Builder::system()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?
.method_timeout(Duration::from_secs(2))
.build()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't access colord: {e}"))?;
let devices: Vec<OwnedObjectPath> =
manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list display devices: {e}"))?;
let mut profiles: HashMap<String, Vec<IccProfile>> = HashMap::new();
for device_path in devices {
let device = Proxy::new(&connection, COLORD, &device_path, COLORD_DEVICE).map_err(|e| format!("couldn't access a colord display device: {e}"))?;
let metadata: HashMap<String, String> = device.get_property("Metadata").map_err(|e| format!("couldn't read colord display metadata: {e}"))?;
let Some(output_name) = metadata.get("XRANDR_name").filter(|name| !name.is_empty()) else { continue };
// `*` is colord's default profile qualifier. A display without one is kept, with no
// ICC, so the engine reports the normal sRGB fallback.
let profile_path: OwnedObjectPath = match device.call("GetProfileForQualifiers", &(vec!["*"],)) {
Ok(path) => path,
Err(_) => continue,
};
let profile = Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't access a colord ICC profile: {e}"))?;
let filename: String = match profile.get_property::<String>("Filename") {
Ok(filename) if !filename.is_empty() => filename,
_ => continue,
};
let Some(profile) = read_icc(Path::new(&filename)) else {
log::warn!("couldn't read a valid ICC profile assigned to X11 output {output_name}");
continue;
};
profiles.entry(output_name.clone()).or_default().push(profile);
}
Ok(profiles)
}
fn read_icc(path: &Path) -> Option<IccProfile> {
let mut bytes = Vec::new();
std::fs::File::open(path).ok()?.take((MAX_ICC_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
let bytes = checked_icc_bytes(bytes)?;
let name = path.file_name()?.to_str()?.to_owned();
Some(IccProfile { name, bytes: Arc::new(bytes) })
}
fn checked_icc_bytes(mut bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.len() > MAX_ICC_BYTES || bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
return None;
}
let declared = u32::from_be_bytes(bytes.get(0..4)?.try_into().ok()?) as usize;
if declared < 132 || declared > bytes.len() {
return None;
}
bytes.truncate(declared);
let profile = photocraft_cms::Profile::parse(&bytes).ok()?;
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
fn unambiguous_clone_profile(output_names: &[String], profiles: &HashMap<String, Vec<IccProfile>>) -> Option<IccProfile> {
let mut selected: Option<&IccProfile> = None;
for output in output_names {
let candidates = profiles.get(output)?;
let first = candidates.first()?;
if candidates.iter().any(|candidate| candidate.bytes != first.bytes) {
return None;
}
if selected.is_some_and(|profile| profile.bytes != first.bytes) {
return None;
}
selected = Some(first);
}
selected.map(|profile| IccProfile { name: profile.name.clone(), bytes: profile.bytes.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checked_icc_bytes_require_a_bounded_parseable_rgb_profile() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(checked_icc_bytes(bytes.clone()), Some(bytes));
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(checked_icc_bytes(cmyk.as_ref().clone()).is_none());
assert!(checked_icc_bytes(vec![0; 132]).is_none());
assert!(checked_icc_bytes(vec![0; MAX_ICC_BYTES + 1]).is_none());
}
#[test]
fn cloned_outputs_use_only_one_unambiguous_profile() {
let srgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes();
let profile = |name: &str, bytes: std::sync::Arc<Vec<u8>>| IccProfile { name: name.into(), bytes };
let outputs = ["DP-1".to_owned(), "HDMI-1".to_owned()];
let matching =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert_eq!(unambiguous_clone_profile(&outputs, &matching).map(|p| p.name), Some("sRGB".into()));
let differing =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("Display P3", p3.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &differing).is_none());
let missing = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &missing).is_none());
let conflicting = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb), profile("Display P3", p3)])]);
assert!(unambiguous_clone_profile(&["DP-1".to_owned()], &conflicting).is_none());
}
}
}
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
/// (the coordinates window positions use). Lines that don't parse are skipped.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn parse_reply(out: &str) -> Detection {
let mut displays = Vec::new();
let mut primary_height = None;
for line in out.lines().filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.splitn(8, '\t').collect();
let [id, x, y, w, h, b64, profile_name, name] = f.as_slice() else { continue };
let num = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
let (Some(id), Some(x), Some(y), Some(w), Some(h)) = (display_id(id), num(x), num(y), num(w), num(h)) else { continue };
// Cocoa frames are relative to the primary screen's bottom-left corner, y up.
let top = *primary_height.get_or_insert(h) - (y + h);
let name = name.trim();
let name = if name.is_empty() { format!("Display {id}") } else { name.to_string() };
let b64 = b64.trim();
// An ICC profile starts with its size and carries `acsp` at offset 36; anything else is
// treated as no profile (the engine then says the display has none).
let icc = (!b64.is_empty()).then(|| base64_decode(b64)).flatten().filter(|b| b.len() >= 132 && b.get(36..40) == Some(b"acsp"));
let profile_name = Some(profile_name.trim().to_string()).filter(|n| !n.is_empty());
displays.push(Display { id, name, frame: [x, top, w, h], profile_name, icc: icc.map(std::sync::Arc::new) });
}
if displays.is_empty() {
// Only the start of the first field: a display line carries a whole base64 ICC profile,
// which must not end up in a notice (#1371).
let first = out.lines().next().unwrap_or("").split('\t').next().unwrap_or("").trim();
let mut shown: String = first.chars().take(80).collect();
if shown.len() < first.len() {
shown.push('…');
}
return Err(if shown.is_empty() { "the display reader returned nothing".into() } else { format!("couldn't read the display list: {shown}") });
}
Ok(displays)
}
/// A display id: a plain integer, or the scientific notation AppleScript printed for ids above
/// its integer range before the helper asked for `stringValue` (#1371), when it is exact.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn display_id(s: &str) -> Option<u32> {
let s = s.trim();
s.parse::<u32>().ok().or_else(|| {
let v = s.parse::<f64>().ok().filter(|v| v.is_finite() && *v >= 0.0 && v.fract() == 0.0 && *v <= f64::from(u32::MAX))?;
Some(v as u32)
})
}
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
@@ -87,12 +445,94 @@ mod tests {
assert!(base64_decode("a$==").is_none());
}
/// The detected profile (when there is one) parses as an RGB profile.
#[test]
fn detected_profile_parses() {
if let Some(bytes) = detect() {
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
fn helper_replies() {
// Only the ICC signature is checked here; parsing is the engine's job.
let mut icc = vec![0u8; 132];
icc[36..40].copy_from_slice(b"acsp");
let b64 = base64_encode(&icc);
// The #569 setup: built-in display primary, the external one right of it and higher up.
let reply = format!(
"1\t0\t0\t1728\t1117\t{b64}\tColor LCD\tBuilt-in Retina Display\n4\t1728\t92\t3008\t1692\t{b64}\tApple_Display_26-04-13.icc\tROG PG32UQX\n"
);
let d = parse_reply(&reply).unwrap();
assert_eq!(d.len(), 2);
assert_eq!((d[0].id, d[0].name.as_str(), d[0].frame), (1, "Built-in Retina Display", [0.0, 0.0, 1728.0, 1117.0]));
assert_eq!((d[1].id, d[1].frame), (4, [1728.0, -667.0, 3008.0, 1692.0]));
assert_eq!(d[1].profile_name.as_deref(), Some("Apple_Display_26-04-13.icc"));
assert_eq!(d[1].icc.as_deref(), Some(&icc));
// No profile, a bad profile, no name: kept, without a profile or with a fallback name.
let d = parse_reply("2\t0\t0\t800\t600\t\t\t\n3\t800\t0\t800\t600\taGVsbG8=\tX\tY\n").unwrap();
assert_eq!((d[0].name.as_str(), d[0].icc.is_none(), d[0].profile_name.is_none()), ("Display 2", true, true));
assert!(d[1].icc.is_none());
// Garbage lines are skipped; nothing usable is an error.
assert_eq!(parse_reply("junk\n5\t0\t0\t10\t10\t\t\tZ").unwrap().len(), 1);
assert!(parse_reply("").is_err());
assert!(parse_reply("execution error: -1728").unwrap_err().contains("-1728"));
}
#[test]
fn large_display_ids_and_short_errors() {
// #1371: a CGDirectDisplayID above AppleScript's integer range came back as a real.
assert_eq!(display_id("2077748985"), Some(2_077_748_985));
assert_eq!(display_id(" 2.077748985E+9 "), Some(2_077_748_985));
assert_eq!(display_id("4.294967295E+9"), Some(u32::MAX));
for bad in ["4.3E+9", "-1", "1.5", "NaN", "inf", "", "x"] {
assert_eq!(display_id(bad), None, "{bad}");
}
let d = parse_reply("2.077748985E+9\t0\t0\t1512\t982\t\tColor LCD\tBuilt-in Retina Display\n").unwrap();
assert_eq!(d[0].id, 2_077_748_985);
// A line that can't be read never puts its profile into the error.
let profile = "A".repeat(4000);
let e = parse_reply(&format!("bad id\t0\t0\t1\t1\t{profile}\tColor LCD\tX\n")).unwrap_err();
assert!(e.contains("bad id") && !e.contains("AAAA") && e.len() < 120, "{e}");
let e = parse_reply(&"Z".repeat(500)).unwrap_err();
assert!(e.chars().count() < 120 && e.ends_with('…'), "{e}");
}
// Drives `sleep` (found on PATH: NixOS and the Nix build sandbox have no /bin/sleep) and
// /bin/sh; the helper itself only runs on macOS.
#[cfg(unix)]
#[test]
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
let t0 = std::time::Instant::now();
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
assert!(e.contains("took longer than"), "{e}");
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "killed at the deadline");
// 1 MB on stdout (far beyond a pipe buffer) arrives whole.
let big = run_helper(std::process::Command::new("/bin/sh").args(["-c", "head -c 1048576 /dev/zero | tr '\\0' a"]), std::time::Duration::from_secs(20))
.unwrap();
assert_eq!(big.len(), 1 << 20);
let e = run_helper(std::process::Command::new("/bin/sh").args(["-c", "echo boom >&2; exit 3"]), std::time::Duration::from_secs(5)).unwrap_err();
assert!(e.contains("boom"), "{e}");
assert!(run_helper(&mut std::process::Command::new("/nonexistent/helper"), std::time::Duration::from_secs(1)).is_err());
}
fn base64_encode(b: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in b.chunks(3) {
let n = c.iter().enumerate().fold(0u32, |n, (i, v)| n | u32::from(*v) << (16 - 8 * i));
for i in 0..=c.len() {
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
}
}
while !s.len().is_multiple_of(4) {
s.push('=');
}
s
}
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn detected_displays_parse() {
if let Ok(displays) = detect() {
for d in displays {
let Some(icc) = d.icc else { continue };
let p = photocraft_engine::color_cmds::profile_from_bytes(&icc).expect("parses");
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}: {}", d.name, p.description);
}
}
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Photoshop's live keyboard shortcut set on this machine, for the one-time offer to import it
//! at first launch (`photocraft_ui_egui::kys_import::offer_import`).
//!
//! Photoshop keeps the set in use as `Keyboard Shortcuts.psp` in its per-version settings
//! folder; it is the same XML a saved `.kys` holds, named after the set the user chose.
//! macOS: `~/Library/Preferences/Adobe Photoshop <version> Settings/`. Windows:
//! `%APPDATA%\Adobe\Adobe Photoshop <version>\Adobe Photoshop <version> Settings\`. Every
//! installed version has one; the newest version wins (the number in the folder name, then
//! the file's modification time), since that is the Photoshop the user runs. Linux has no
//! Photoshop; a copied set is imported by hand (Edit › Keyboard Shortcuts).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// The file is a few tens of kilobytes; anything far larger is not a shortcut set.
const MAX_BYTES: u64 = 4 << 20;
/// The platform whose folder layout applies.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Platform {
Mac,
Windows,
Other,
}
const fn this_platform() -> Platform {
if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Other
}
}
/// Every `Adobe Photoshop <version> Settings` folder that could hold a set, newest first.
pub fn settings_dirs(os: Platform, env: &impl Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
let var = |k: &str| env(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let mut dirs = match os {
Platform::Mac => var("HOME").map(|h| h.join("Library/Preferences")).into_iter().flat_map(|prefs| settings_in(&prefs)).collect::<Vec<_>>(),
// `%APPDATA%\Adobe\Adobe Photoshop 2026\Adobe Photoshop 2026 Settings`.
Platform::Windows => var("APPDATA")
.map(|a| a.join("Adobe"))
.into_iter()
.flat_map(|adobe| std::fs::read_dir(adobe).into_iter().flatten().flatten().map(|e| e.path()))
.filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("Adobe Photoshop ")))
.flat_map(|p| settings_in(&p))
.collect(),
Platform::Other => Vec::new(),
};
dirs.sort_by_key(|d| std::cmp::Reverse(version_key(d)));
dirs
}
/// `Adobe Photoshop <version> Settings` folders directly inside `parent`.
fn settings_in(parent: &Path) -> Vec<PathBuf> {
std::fs::read_dir(parent)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
name.starts_with("Adobe Photoshop ") && name.ends_with(" Settings") && p.is_dir()
})
.collect()
}
/// Newest first: the version number in the folder name (2026; CC 2019 → 2019; CS6 → 6), then
/// the set's modification time.
fn version_key(dir: &Path) -> (u64, std::time::SystemTime) {
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let number = name.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).filter_map(|s| s.parse::<u64>().ok()).max().unwrap_or(0);
let modified = std::fs::metadata(dir.join(FILE)).and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH);
(number, modified)
}
/// The live set's file name inside a settings folder.
pub const FILE: &str = "Keyboard Shortcuts.psp";
/// The newest install's live set as (path, XML text), `None` without a readable one. A file that
/// is not a shortcut set (another version's binary `.psp`, a truncated file) is skipped.
pub fn live_keyboard_shortcuts_on(os: Platform, env: &impl Fn(&str) -> Option<OsString>) -> Option<(String, String)> {
settings_dirs(os, env).into_iter().map(|d| d.join(FILE)).find_map(|path| {
let len = std::fs::metadata(&path).ok()?.len();
if len > MAX_BYTES {
return None;
}
let text = std::fs::read_to_string(&path).ok()?;
text.contains("<photoshop-keyboard-shortcuts").then(|| (path.to_string_lossy().into_owned(), text))
})
}
/// [`live_keyboard_shortcuts_on`] for this platform.
pub fn live_keyboard_shortcuts(env: &impl Fn(&str) -> Option<OsString>) -> Option<(String, String)> {
live_keyboard_shortcuts_on(this_platform(), env)
}
#[cfg(test)]
mod tests {
use super::*;
const SET: &str = "\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<photoshop-keyboard-shortcuts version=\"4\" filename=\"texcuts\" modified=\"1\" multi-undo=\"1\">\n</photoshop-keyboard-shortcuts>\n";
/// A fresh scratch directory per test (removed first, so a rerun starts clean).
fn temp_root(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("photocraft-photoshop-settings-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn write(dir: &Path, text: &[u8]) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(FILE), text).unwrap();
}
fn env_for(root: &Path) -> impl Fn(&str) -> Option<OsString> + '_ {
move |k| match k {
"HOME" => Some(root.join("home").into_os_string()),
"APPDATA" => Some(root.join("appdata").into_os_string()),
_ => None,
}
}
#[test]
fn newest_version_wins_on_macos() {
let root = temp_root("mac");
let prefs = root.join("home/Library/Preferences");
write(&prefs.join("Adobe Photoshop 2024 Settings"), SET.as_bytes());
write(&prefs.join("Adobe Photoshop 2026 Settings"), SET.replace("texcuts", "newest").as_bytes());
write(&prefs.join("Adobe Photoshop CC 2019 Settings"), SET.as_bytes());
write(&prefs.join("Adobe Photoshop 2025 Paths"), SET.as_bytes()); // not a Settings folder
let env = env_for(&root);
let dirs: Vec<String> = settings_dirs(Platform::Mac, &env).iter().map(|d| d.file_name().unwrap().to_string_lossy().into_owned()).collect();
assert_eq!(dirs, ["Adobe Photoshop 2026 Settings", "Adobe Photoshop 2024 Settings", "Adobe Photoshop CC 2019 Settings"]);
let (path, text) = live_keyboard_shortcuts_on(Platform::Mac, &env).unwrap();
assert!(path.replace('\\', "/").ends_with("Adobe Photoshop 2026 Settings/Keyboard Shortcuts.psp"), "{path}");
assert!(text.contains("filename=\"newest\""));
}
#[test]
fn windows_layout_and_unreadable_sets_are_skipped() {
let root = temp_root("win");
let adobe = root.join("appdata/Adobe");
// The newest install's file is not a shortcut set (binary); the older one is used.
write(&adobe.join("Adobe Photoshop 2026/Adobe Photoshop 2026 Settings"), &[0x80, 0x00, 0xff, 0xfe]);
write(&adobe.join("Adobe Photoshop 2025/Adobe Photoshop 2025 Settings"), SET.as_bytes());
std::fs::create_dir_all(adobe.join("Adobe Illustrator 30/Adobe Illustrator 30 Settings")).unwrap();
let env = env_for(&root);
let (path, _) = live_keyboard_shortcuts_on(Platform::Windows, &env).unwrap();
assert!(path.replace('\\', "/").ends_with("Adobe Photoshop 2025/Adobe Photoshop 2025 Settings/Keyboard Shortcuts.psp"), "{path}");
assert!(live_keyboard_shortcuts_on(Platform::Other, &env).is_none());
assert!(live_keyboard_shortcuts_on(Platform::Mac, &|_| None).is_none(), "no HOME, no set");
}
}
+485
View File
@@ -0,0 +1,485 @@
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
return None;
}
let window = cc.winit_window()?.clone();
Some(Box::new(move |ctx| {
let (tx, rx) = std::sync::mpsc::channel();
let fallback = tx.clone();
let placements: Vec<Placement> = window
.available_monitors()
.enumerate()
.map(|(index, m)| {
let p = m.position();
let size = m.size();
Placement { index, x: p.x, y: p.y, width: size.width, height: size.height }
})
.collect();
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop = cancelled.clone();
let scale = ctx.native_pixels_per_point().unwrap_or(1.0);
let wake = ctx.clone();
if let Err(e) = std::thread::Builder::new().name("screen-color-picker".into()).spawn(move || {
let result = capture(wayland, scale, &stop, &placements);
let _ = tx.send(result);
wake.request_repaint();
}) {
let _ = fallback.send(Err(format!("Could not start screen color picker: {e}")));
}
Pending { receiver: rx, cancelled }
}))
}
#[derive(Clone, Copy)]
struct Placement {
index: usize,
x: i32,
y: i32,
width: u32,
height: u32,
}
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
}
fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
#[cfg(target_os = "linux")]
const MAX_ICC_PROFILE_BYTES: usize = 1024 * 1024;
#[cfg(target_os = "linux")]
const ICC_PROFILE_PROPERTY_LONG_LENGTH: u32 = 256 * 1024;
#[cfg(target_os = "linux")]
fn icc_profile(bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.is_empty() || bytes.len() > MAX_ICC_PROFILE_BYTES {
return None;
}
let profile = match photocraft_cms::Profile::parse(&bytes) {
Ok(profile) => profile,
Err(_) => return None,
};
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
#[cfg(target_os = "linux")]
fn icc_profile_atom<C: x11rb::connection::RequestConnection>(conn: &C, name: &str) -> Option<u32> {
use x11rb::protocol::xproto::ConnectionExt as _;
let cookie = match conn.intern_atom(true, name.as_bytes()) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let atom = match cookie.reply() {
Ok(reply) => reply.atom,
Err(_) => return None,
};
(atom != 0).then_some(atom)
}
#[cfg(target_os = "linux")]
fn output_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, output: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::{randr::ConnectionExt as _, xproto::AtomEnum};
let cookie = match conn.randr_get_output_property(output, atom, AtomEnum::CARDINAL, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH, false, false) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.data)
}
#[cfg(target_os = "linux")]
fn root_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, root: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _};
let cookie = match conn.get_property(false, root, atom, AtomEnum::ANY, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.value)
}
#[cfg(target_os = "linux")]
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
if wayland {
return portal(stop);
}
use x11rb::{
connection::Connection,
image::{Image, PixelLayout},
protocol::{randr::ConnectionExt as _, xproto::VisualClass},
};
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
count(usize::from(w), usize::from(h))?;
let mut monitors = conn.randr_get_monitors(screen.root, true).ok().and_then(|c| c.reply().ok()).map(|r| r.monitors).unwrap_or_default();
monitors.sort_by_key(|m| !m.primary);
if !monitors.iter().any(|m| m.primary)
&& let Some(first) = monitors.first_mut()
{
first.primary = true;
}
let regions: Vec<(i16, i16, u16, u16, bool, Vec<x11rb::protocol::randr::Output>)> = if monitors.is_empty() {
vec![(0, 0, w, h, true, Vec::new())]
} else {
monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary, m.outputs.clone())).collect()
};
if regions.len() > 16 {
return Err("Too many screens for the color picker".into());
}
let mut total = 0usize;
for &(x, y, mw, mh, _, _) in &regions {
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
return Err("Invalid X11 display geometry".into());
}
total = total
.checked_add(count(usize::from(mw), usize::from(mh))?)
.filter(|&n| n <= MAX_PIXELS)
.ok_or("Combined displays exceed the screen picker memory budget")?;
}
let (image, visual_id) = Image::get(&conn, screen.root, 0, 0, w, h).map_err(|e| format!("Screen capture failed: {e}"))?;
let visual = screen.allowed_depths.iter().flat_map(|d| &d.visuals).find(|v| v.visual_id == visual_id).ok_or("Unsupported X11 screen visual")?;
if visual.class != VisualClass::TRUE_COLOR {
return Err("Screen picking requires an X11 TrueColor visual".into());
}
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
let output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE");
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
let mut images = Vec::new();
for (x, y, mw, mh, primary, outputs) in regions {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
let profile = output_profile_atom.and_then(|atom| outputs.iter().find_map(|output| output_icc_profile(&conn, *output, atom))).or_else(|| {
if !primary {
return None;
}
icc_profile_atom(&conn, "_ICC_PROFILE").and_then(|atom| root_icc_profile(&conn, screen.root, atom))
});
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
for row in 0..mh {
for col in 0..mw {
let (r, g, b) = layout.decode(image.get_pixel((x as u16) + col, (y as u16) + row));
rgba.extend_from_slice(&[byte(r), byte(g), byte(b), 255]);
}
}
images.push(ScreenImage {
monitor: find_monitor(placements, i32::from(x), i32::from(y), u32::from(mw), u32::from(mh)),
position: egui::pos2(f32::from(x), f32::from(y)) / scale,
size: egui::vec2(f32::from(mw), f32::from(mh)) / scale,
width: usize::from(mw),
height: usize::from(mh),
rgba,
profile,
});
}
Ok(Capture::Images(images))
}
#[cfg(target_os = "linux")]
fn portal(stop: &std::sync::atomic::AtomicBool) -> Result<Capture, String> {
use std::collections::HashMap;
use zbus::{
blocking::Proxy,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
let conn = zbus::blocking::connection::Builder::session()
.map_err(|e| e.to_string())?
.method_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
let token = format!("photocraft_{}", std::process::id());
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
let request = Proxy::new(&conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
use futures_lite::StreamExt;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
let screenshot = Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
.map_err(|e| e.to_string())?;
let options: HashMap<&str, Value<'_>> = HashMap::from([("handle_token", Value::from(token.as_str()))]);
let _handle: OwnedObjectPath =
screenshot.call("PickColor", &("", options)).map_err(|e| format!("The desktop portal does not support color picking: {e}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let reply = loop {
if stop.load(std::sync::atomic::Ordering::Relaxed) || std::time::Instant::now() >= deadline {
let _: Result<(), _> = request.call("Close", &());
return Ok(Capture::Color(None));
}
let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
async_io::Timer::after(std::time::Duration::from_millis(50)).await;
None
}));
if let Some(message) = next {
break message.ok_or("The desktop color picker closed without a result")?;
}
};
let (code, values): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
if code == 1 {
return Ok(Capture::Color(None));
}
if code != 0 {
return Err("The desktop color picker could not sample this screen".into());
}
let value = values.get("color").ok_or("Desktop picker returned no color")?;
let (r, g, b): (f64, f64, f64) = value.try_clone().map_err(|e| e.to_string())?.try_into().map_err(|e: zbus::zvariant::Error| e.to_string())?;
if [r, g, b].iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
return Err("Desktop picker returned an invalid color".into());
}
Ok(Capture::Color(Some([r as f32, g as f32, b as f32])))
}
#[cfg(target_os = "windows")]
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
let monitors = xcap::Monitor::all().map_err(|e| e.to_string())?;
if monitors.is_empty() || monitors.len() > 16 {
return Err("No supported screen is available".into());
}
let mut budget = 0usize;
for m in &monitors {
budget = budget
.checked_add(count(m.width().map_err(|e| e.to_string())? as usize, m.height().map_err(|e| e.to_string())? as usize)?)
.filter(|&n| n <= MAX_PIXELS)
.ok_or("Combined displays exceed the screen picker memory budget")?;
}
let mut total = 0usize;
let mut images = Vec::new();
for m in monitors {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
let (w, h) = (m.width().map_err(|e| e.to_string())?, m.height().map_err(|e| e.to_string())?);
let scale = m.scale_factor().map_err(|e| e.to_string())?;
if !scale.is_finite() || scale <= 0.0 || scale > 16.0 {
return Err("Invalid display scale".into());
}
let captured = m.capture_image().map_err(|e| format!("Screen capture failed: {e}"))?;
let (width, height) = (captured.width() as usize, captured.height() as usize);
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
let (x, y) = (m.x().map_err(|e| e.to_string())?, m.y().map_err(|e| e.to_string())?);
images.push(ScreenImage {
monitor: find_monitor(placements, x, y, w, h),
position: egui::pos2(x as f32, y as f32) / scale,
size: egui::vec2(w as f32, h as f32) / scale,
width,
height,
rgba: captured.into_raw(),
profile: None,
});
}
Ok(Capture::Images(images))
}
#[cfg(target_os = "macos")]
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
use core_graphics2::{display::get_active_display_list, image::CGImageAlphaInfo as Alpha, window};
if !window::preflight_screen_capture_access() && !window::request_screen_capture_access() {
return Err("Allow PhotoCraft in System Settings > Privacy & Security > Screen Recording, then reopen PhotoCraft".into());
}
let displays = get_active_display_list(17).ok_or("Could not enumerate displays")?;
if displays.is_empty() || displays.len() > 16 {
return Err("No supported screen is available".into());
}
let mut budget = 0usize;
for display in &displays {
let mode = display.copy_display_mode().ok_or("Display mode is unavailable")?;
budget = budget
.checked_add(count(mode.pixel_width(), mode.pixel_height())?)
.filter(|&n| n <= MAX_PIXELS)
.ok_or("Combined displays exceed the screen picker memory budget")?;
}
let mut images = Vec::new();
let mut total = 0usize;
for display in displays {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
// Capture a composed monitor at native resolution through safe bindings. Keeping the
// CoreGraphics seam preserves PhotoCraft's existing macOS 11 deployment target.
let bounds = display.bounds();
let image = window::new_image(
bounds,
window::CGWindowListOption::OnScreenOnly,
window::kCGNullWindowID,
window::CGWindowImageOption::BestResolution | window::CGWindowImageOption::ShouldBeOpaque,
)
.ok_or("Screen capture failed; check Screen Recording permission")?;
let (width, height) = (image.width(), image.height());
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
let profile = image.color_space().and_then(|space| space.copy_icc_data());
if profile.as_ref().is_some_and(|p| p.len() > 1024 * 1024) {
return Err("Screen profile exceeds the memory budget".into());
}
let info = image.bitmap_info().bits();
if image.bits_per_component() != 8 || image.bits_per_pixel() != 32 || info & (1 << 8) != 0 {
return Err("Unsupported screen capture pixel format".into());
}
let (first, premultiplied, opaque) = match image.alpha_info() {
Alpha::AlphaPremultipliedFirst => (true, true, false),
Alpha::AlphaPremultipliedLast => (false, true, false),
Alpha::AlphaFirst => (true, false, false),
Alpha::AlphaLast => (false, false, false),
Alpha::AlphaNoneSkipFirst => (true, false, true),
Alpha::AlphaNoneSkipLast => (false, false, true),
_ => return Err("Unsupported screen capture alpha layout".into()),
};
let little = match info & 0x7000 {
0 => cfg!(target_endian = "little"),
0x2000 => true,
0x4000 => false,
_ => return Err("Unsupported screen capture byte order".into()),
};
let order = match (first, little) {
(true, true) => [2, 1, 0, 3],
(true, false) => [1, 2, 3, 0],
(false, true) => [3, 2, 1, 0],
(false, false) => [0, 1, 2, 3],
};
let stride = image.bytes_per_row();
checked_stride(width, height, stride)?;
let bytes = image.data_provider().and_then(|p| p.copy_data()).ok_or("Screen image has no pixels")?;
let rgba = unpack_rgba(bytes.bytes(), width, height, stride, order, premultiplied, opaque)?;
let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
let (x, y) = (bounds.origin.x as f32, bounds.origin.y as f32);
if ![w, h, x, y].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 {
return Err("Invalid display geometry".into());
}
let scale = width as f32 / w;
images.push(ScreenImage {
monitor: find_monitor(placements, (x * scale).round() as i32, (y * scale).round() as i32, width as u32, height as u32),
position: egui::pos2(x, y),
size: egui::vec2(w, h),
width,
height,
rgba,
profile: profile.map(|p| p.bytes().to_vec()),
});
}
Ok(Capture::Images(images))
}
// CoreGraphics rows can be padded and their byte order/alpha vary. Kept platform independent
// so Linux CI also tests the macOS decoding path against synthetic pixels.
#[cfg(any(target_os = "macos", test))]
fn checked_stride(width: usize, height: usize, stride: usize) -> Result<usize, String> {
count(width, height)?;
let row = width.checked_mul(4).ok_or("Invalid screen row")?;
if stride < row || stride > row.saturating_add(4096) {
return Err("Invalid screen row stride".into());
}
stride.checked_mul(height).filter(|&n| n <= MAX_PIXELS.saturating_mul(4)).ok_or_else(|| "Screen buffer exceeds the memory budget".into())
}
#[cfg(any(target_os = "macos", test))]
fn unpack_rgba(bytes: &[u8], width: usize, height: usize, stride: usize, order: [usize; 4], premultiplied: bool, opaque: bool) -> Result<Vec<u8>, String> {
let length = checked_stride(width, height, stride)?;
if bytes.len() < length || order.iter().any(|&i| i > 3) {
return Err("Invalid screen pixel buffer".into());
}
let mut out = Vec::with_capacity(count(width, height)?.saturating_mul(4));
for row in bytes.chunks_exact(stride).take(height) {
for p in row[..width * 4].as_chunks::<4>().0 {
let a = if opaque { 255 } else { p[order[3]] };
for &i in &order[..3] {
let v = u32::from(p[i]);
out.push(if premultiplied && a != 255 {
if a == 0 { 0 } else { ((v * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }
} else {
v as u8
});
}
out.push(255);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn screen_capture_decodes_padded_bgra_and_premultiplied_argb() {
assert_eq!(
unpack_rgba(&[10, 20, 30, 0, 99, 99, 99, 99, 40, 50, 60, 0, 99, 99, 99, 99], 1, 2, 8, [2, 1, 0, 3], false, true).unwrap(),
[30, 20, 10, 255, 60, 50, 40, 255]
);
assert_eq!(unpack_rgba(&[128, 64, 32, 16], 1, 1, 4, [1, 2, 3, 0], true, false).unwrap(), [128, 64, 32, 255]);
assert_eq!(unpack_rgba(&[0, 0, 0, 0], 1, 1, 4, [0, 1, 2, 3], true, false).unwrap(), [0, 0, 0, 255]);
}
#[test]
fn screen_capture_rejects_unbounded_or_truncated_buffers() {
assert!(count(usize::MAX, 2).is_err());
assert!(checked_stride(1, 1, usize::MAX).is_err());
assert!(checked_stride(1, MAX_PIXELS, 4096).is_err());
assert!(unpack_rgba(&[0; 3], 1, 1, 4, [0, 1, 2, 3], false, true).is_err());
assert!(unpack_rgba(&[0; 4], 1, 1, 4, [4, 1, 2, 3], false, true).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn x11_screen_capture_accepts_only_bounded_rgb_icc_profiles() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(icc_profile(bytes.clone()), Some(bytes));
assert!(icc_profile(Vec::new()).is_none());
assert!(icc_profile(vec![0; MAX_ICC_PROFILE_BYTES + 1]).is_none());
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(icc_profile(cmyk.as_ref().clone()).is_none());
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
fn screen_capture_reads_a_known_x11_fixture_pixel() {
use x11rb::{
COPY_DEPTH_FROM_PARENT,
connection::Connection,
protocol::xproto::{ConfigureWindowAux, ConnectionExt, CreateWindowAux, StackMode, WindowClass},
};
let (conn, index) = x11rb::connect(None).unwrap();
let screen = &conn.setup().roots[index];
let window = conn.generate_id().unwrap();
conn.create_window(
COPY_DEPTH_FROM_PARENT,
window,
screen.root,
200,
200,
8,
8,
0,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new().override_redirect(1).background_pixel(0xff8040),
)
.unwrap()
.check()
.unwrap();
conn.map_window(window).unwrap().check().unwrap();
conn.configure_window(window, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE)).unwrap().check().unwrap();
conn.flush().unwrap();
// X11 request completion precedes the compositor presenting the mapped fixture.
std::thread::sleep(std::time::Duration::from_millis(150));
let images = capture(false, 1.0, &std::sync::atomic::AtomicBool::new(false), &[]);
conn.destroy_window(window).unwrap().check().unwrap();
conn.flush().unwrap();
let Capture::Images(images) = images.unwrap() else { panic!("not an image capture") };
let first = images.iter().find(|m| m.position.x <= 200.0 && m.position.y <= 200.0).unwrap();
let x = (200.0 - first.position.x) as usize;
let y = (200.0 - first.position.y) as usize;
let offset = (y * first.width + x) * 4;
assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]);
}
#[test]
fn screen_capture_matches_negative_and_hidpi_monitor_geometry() {
let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }];
assert_eq!(find_monitor(&m, -3840, 0, 3840, 2160), Some(2));
assert_eq!(find_monitor(&m, 0, 0, 1920, 1080), None);
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn capture(_wayland: bool, _scale: f32, _stop: &std::sync::atomic::AtomicBool, _placements: &[Placement]) -> Result<Capture, String> {
Err("Screen color picking is unavailable on this platform".into())
}
+710 -65
View File
@@ -3,39 +3,166 @@
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_format::Autosaver;
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::Services;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::RefCell;
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
#[cfg(test)]
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
/// Open dialog extensions that also match uppercase and mixed-case names (`IMG_0001.JPG`).
/// On Linux and the BSDs rfd turns each extension into a case-sensitive `*.ext` glob for the XDG
/// portal or zenity, so every letter becomes a character class, as the portal spec suggests
/// (`*.[iI][cC][oO]`). Windows and macOS take literal extensions and ignore case already.
fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
let case_sensitive_globs = cfg!(all(unix, not(target_os = "macos")));
let class = |c: char| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() };
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
}
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
vec![
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
/// keeps defaulting to Photoshop.
const SAVE_FILTERS: &[(&str, &[&str])] =
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
/// keeps defaulting to PSD.
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("PSD Document", &["psd", "psb"]),
("PhotoCraft", &["pcraft"]),
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg"]),
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
];
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
/// never swaps their extension for a document format's.
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let mut v = SAVE_FILTERS.to_vec();
if let Some(i) = v.iter().position(|(_, exts)| exts.contains(&ext.as_str())) {
let f = v.remove(i);
v.insert(0, f);
if let Some((name, exts)) = OTHER_SAVE_FILTERS.iter().find(|(_, exts)| exts.contains(&ext.as_str())) {
return vec![(name.to_string(), exts.iter().map(|e| e.to_string()).collect())];
}
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
Some(i) => {
let f = v.remove(i);
v.insert(0, f);
}
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
None => {}
}
v
}
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, reply: FileDialogReply) {
let mut dialog = rfd::AsyncFileDialog::new();
if let Some(parent) = parent {
dialog = dialog.set_parent(parent);
}
let answer: Pin<Box<dyn Future<Output = Option<FileDialogAnswer>> + Send>> = match request {
FileDialogRequest::Open { multiple, initial_dir, extensions } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
// A command-specific filter must not inherit the image formats used by File › Open.
let dialog = if let Some(exts) = extensions {
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
} else {
open_filters()
.into_iter()
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
};
if multiple {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
} else {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
}
if let Some(name) = Path::new(&suggested).file_name() {
dialog = dialog.set_file_name(name.to_string_lossy());
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
dialog = dialog.set_directory(dir);
}
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.
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
// An empty answer from a system with no dialog backend isn't a Cancel (#3354).
let answer = block_on(answer).or_else(|| crate::file_dialog_probe::known_problem().map(FileDialogAnswer::Unavailable));
reply.send(answer)
}) {
log::error!("couldn't wait for the file dialog: {e}");
}
}
fn path_of(file: &rfd::FileHandle) -> String {
file.path().to_string_lossy().into_owned()
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));
let mut cx = std::task::Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut cx) {
return value;
}
// Spurious wake-ups just poll again.
std::thread::park();
}
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it; see
/// [`crate::app_dirs`].
@@ -64,9 +191,151 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts
}
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
/// 24 MP, 5-layer PSD (572 MB) takes about 3 s to encode and 0.5 s or more to write and flush.
const ENCODE_SHARE: f32 = 0.85;
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
fn save_through(
doc: &Document,
path: &str,
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
) -> Result<Vec<String>, String> {
ctl.progress(0.0, "Encoding");
let opts = export_options(settings);
let cancelled = || ctl.check().is_err();
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
ctl.commit()?;
ctl.progress(ENCODE_SHARE, "Writing");
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
ctl.progress(1.0, "");
Ok(r.warnings)
}
fn save_file(
doc: &Document,
path: &str,
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
) -> Result<Vec<String>, String> {
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
}
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
/// Run `f` on the recovery store (`Err` without a config directory). The service closures never
/// call each other, so the store is never borrowed twice.
fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R) -> Result<R, String> {
let mut slot = store.try_borrow_mut().map_err(|_| "crash recovery is busy".to_string())?;
Ok(f(slot.as_mut().ok_or("no config directory")?))
}
/// Crash recovery: background incremental .pcraft autosaves into `dir` (`None`: no config
/// directory, so autosaves fail and nothing is recovered). Recovered documents keep their entries
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
fn recovery_services(dir: Option<PathBuf>) -> Services {
let recover_dir = dir.clone();
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
})),
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
recover: Some(Box::new(move || {
let Some(dir) = recover_dir.as_ref() else { return Vec::new() };
photocraft_format::list_recovery(dir)
.into_iter()
.map(|e| Recoverable {
key: e.info.key.clone(),
name: e.info.document_name.clone(),
path: e.info.original_path.clone(),
load: Box::new(move || photocraft_format::recover(&e).map_err(|e| e.to_string())),
})
.collect()
})),
adopt_autosave: Some(Box::new(move |id: u64, key: &str| {
let _ = with_store(&store, |s| s.adopt(id, key));
})),
..Default::default()
}
}
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
paths.iter().find_map(|path| {
// text/uri-list lines end in CRLF (RFC 2483, and GTK writes them so), but arboard splits
// on LF only, so a path copied in GNOME Files arrives with a trailing '\r'.
let path = path.to_str().map_or_else(|| path.clone(), |s| PathBuf::from(s.trim_end_matches('\r')));
let mut head = Vec::with_capacity(256);
std::fs::File::open(&path).ok()?.take(256).read_to_end(&mut head).ok()?;
// TGA has no magic number and is only guessed from the header, so trust the extension
// too before reading what may be a large non-image file.
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match photocraft_codecs::detect(&head)? {
photocraft_codecs::Format::Tga if photocraft_codecs::from_extension(ext) != Some(photocraft_codecs::Format::Tga) => return None,
_ => {}
}
let img = photocraft_codecs::decode(&std::fs::read(&path).ok()?).ok()?;
Some((img.width(), img.height(), img.to_rgba8()))
})
}
/// The shell command that starts this install under XWayland, where winit delivers file drops
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
/// `None` outside Flatpak when there is no X server (`DISPLAY` unset) to run on.
#[cfg(any(target_os = "linux", test))]
pub fn xwayland_command(flatpak_id: Option<&str>, appimage: Option<&str>, x_display: bool) -> Option<String> {
if let Some(id) = flatpak_id.filter(|id| !id.is_empty()) {
return Some(format!("flatpak run --nosocket=wayland --socket=x11 {}", shell_word(id)));
}
let exe = appimage.filter(|path| !path.is_empty()).map_or_else(|| "photocraft".to_string(), shell_word);
x_display.then(|| format!("WAYLAND_DISPLAY= {exe}"))
}
/// `s` as one POSIX shell word: bare when every character is safe there, else single-quoted.
#[cfg(any(target_os = "linux", test))]
fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
let savers2 = savers.clone();
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
@@ -78,48 +347,51 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let automation_write = automation.clone().map(|workspace| {
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
});
let automation_open_pcraft = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
let opened = crate::crash_guard::guard("Open", || workspace.open_pcraft(path).map_err(|e| e.to_string()))?;
Ok(opened.map(|doc| (name, doc)))
}) as photocraft_ui_egui::AutomationOpenPcraftFn
});
let automation_save = automation.clone().map(|workspace| {
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
}) as photocraft_ui_egui::SaveFileFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
let automation_authorize = automation.is_some().then_some(step);
let automation_command = automation.map(|_| {
Box::new(|id: &str, params: &serde_json::Value| {
photocraft_automation::workspace::authorize_desktop_engine_command(id, params).map_err(|error| error.to_string())
}) as photocraft_ui_egui::AutomationCommandFn
});
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| {
crate::crash_guard::guard("Open", || photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
crate::crash_guard::guard("Open", || {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
let opts = export_options(settings);
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
})),
pick_open: Some(Box::new(|| {
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
let bytes = std::fs::read(&path).ok()?;
Some((path.to_string_lossy().to_string(), bytes))
})),
pick_save: Some(Box::new(|suggested: &str| {
let p = std::path::Path::new(suggested);
let mut d = rfd::FileDialog::new();
for (name, exts) in save_filters(suggested) {
d = d.add_filter(name, exts);
}
if let Some(name) = p.file_name() {
d = d.set_file_name(name.to_string_lossy());
}
Some(d.save_file()?.to_string_lossy().to_string())
})),
save_file: Some(Arc::new(save_file)),
file_dialog: Some(Box::new(show_file_dialog)),
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
automation_read,
automation_write,
automation_open_pcraft,
automation_save,
automation_command,
automation_authorize,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
clipboard_set_image: Some({
let clip = clip.clone();
Box::new(move |w: u32, h: u32, px: &[u8]| {
@@ -139,37 +411,19 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
// Crash recovery: background incremental .pcraft saves into the recovery directory.
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
let dir = recovery_dir().ok_or("no config directory")?;
let mut map = savers.borrow_mut();
let saver = map.entry(doc.id.0).or_insert_with(|| Autosaver::new(&dir, &format!("doc-{}", doc.id.0)));
saver.request(doc.clone(), revision, path.map(str::to_string), Default::default());
Ok(())
})),
discard_autosave: Some(Box::new(move |id: u64| {
if let Some(s) = savers2.borrow_mut().remove(&id) {
let _ = s.discard();
}
})),
recover: Some(Box::new(|| {
let Some(dir) = recovery_dir() else { return Vec::new() };
let mut out = Vec::new();
for entry in photocraft_format::list_recovery(&dir) {
if let Ok(doc) = photocraft_format::recover(&entry) {
out.push((entry.info.original_path.clone(), doc));
}
// Recovered documents autosave again under their new ids.
let _ = photocraft_format::discard_recovery(&dir, &entry);
}
out
})),
system_theme: crate::appearance::service(),
append_text: Some(Box::new(|path: &str, text: &str| {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
@@ -179,6 +433,23 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
os_events: None,
// Set by main, which starts loading the store before the window opens.
preset_store: None,
is_wayland: false,
..recovery_services(recovery_dir())
}
}
/// Preferences ▸ General ▸ Beep When Done: the platform's default information beep.
fn platform_beep() {
#[cfg(target_os = "windows")]
{
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
}
#[cfg(not(target_os = "windows"))]
{
// No beep API among this app's other platforms' dependencies: write the terminal bell
// (audible when launched from a terminal, which is the same cue macOS terminals use).
use std::io::Write as _;
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
}
}
@@ -243,3 +514,377 @@ pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
};
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_engine::Session;
use photocraft_format::list_recovery;
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
// Matches `text` against a glob of literals and `[..]` classes, as the portal would after `*.`.
fn matches(glob: &str, text: &str) -> bool {
let mut text = text.chars();
let mut glob = glob.chars();
while let Some(g) = glob.next() {
let class: String = if g == '[' { glob.by_ref().take_while(|&c| c != ']').collect() } else { g.to_string() };
if !text.next().is_some_and(|t| class.contains(t)) {
return false;
}
}
text.next().is_none()
}
assert_eq!(open_filter_extensions(&["cr2", "pcraft"]), ["[cC][rR]2", "[pP][cC][rR][aA][fF][tT]"]);
let globs = open_filter_extensions(OPEN_EXTS);
assert_eq!(globs.len(), OPEN_EXTS.len());
for (ext, glob) in OPEN_EXTS.iter().zip(&globs) {
// Every casing: lower, UPPER, and alternating both ways (JpG, jPg).
let alternating = |upper_first: bool| -> String {
ext.chars().enumerate().map(|(i, c)| if (i % 2 == 0) == upper_first { c.to_ascii_uppercase() } else { c }).collect()
};
for name in [ext.to_string(), ext.to_ascii_uppercase(), alternating(true), alternating(false)] {
assert!(matches(glob, &name), "{glob} should match .{name}");
}
assert!(!matches(glob, &format!("{ext}x")) && !matches(glob, &ext[..ext.len() - 1]), "{glob} matches only .{ext}");
}
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
#[test]
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
}
#[test]
fn xwayland_command_matches_how_photocraft_was_installed() {
// Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do.
let flatpak = xwayland_command(Some("ai.storyteller.photocraft"), None, false);
assert_eq!(flatpak.as_deref(), Some("flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft"));
// AppImage: its own path, quoted for the shell (spaces, quotes).
let appimage = xwayland_command(None, Some("/home/me/My Apps/it's.AppImage"), true);
assert_eq!(appimage.as_deref(), Some(r"WAYLAND_DISPLAY= '/home/me/My Apps/it'\''s.AppImage'"));
let bare = xwayland_command(None, Some("/opt/photocraft-0.3.0-linux-x86_64.AppImage"), true);
assert_eq!(bare.as_deref(), Some("WAYLAND_DISPLAY= /opt/photocraft-0.3.0-linux-x86_64.AppImage"));
// deb, rpm, AUR, tarball: the binary on the PATH.
assert_eq!(xwayland_command(None, None, true).as_deref(), Some("WAYLAND_DISPLAY= photocraft"));
assert_eq!(xwayland_command(Some(""), Some(""), true).as_deref(), Some("WAYLAND_DISPLAY= photocraft"));
// No X server (XWayland disabled): nothing to suggest.
assert_eq!(xwayland_command(None, Some("/opt/p.AppImage"), false), None);
}
/// Tests every native save dialog and records the suggested file name. Each name's file type must come first
/// in the save panel, or the panel appends the first type's extension (`photo.webp.psd`, `photo.gif.psd`).
#[test]
fn every_save_dialog_leads_with_its_own_extension() {
let asked: Rc<RefCell<Vec<String>>> = Rc::default();
let log = asked.clone();
// Record each save dialog's suggested name and cancel it, as the user would.
let services = Services {
file_dialog: Some(Box::new(move |request, _parent, reply| {
if let FileDialogRequest::Save { suggested } = request {
log.borrow_mut().push(suggested);
}
reply.send(None);
})),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
let ctx = egui::Context::default();
// Dialogs are shown on the next frame: poll once so each is answered before the next asks.
let invoke = |app: &mut PhotocraftApp, id: &str| {
let r = photocraft_ui_egui::menus::invoke(app, &ctx, id, json!({}));
app.poll_file_dialog(&ctx, None);
r
};
let dialog = |app: &mut PhotocraftApp, id: &str, fields: Value| {
let d = invoke(app, id).unwrap()["dialog"].as_u64().unwrap();
for (k, v) in fields.as_object().unwrap() {
app.ui.dialog_mut(d).unwrap().fields.insert(k.clone(), v.clone());
}
let _ = photocraft_ui_egui::dialogs::confirm(app, d);
app.poll_file_dialog(&ctx, None);
};
new_doc(&mut app, "#ff0000");
// Save As, Save a Copy.
// Files in a format Save As can't write, like .dng, should suggest .psd instead.
let _ = invoke(&mut app, "file.saveAs");
for name in ["cat.pcraft", "cat.jpeg", "cat.gif", "cat.bmp", "cat.dng"] {
app.session.active_mut().unwrap().path = Some(name.into());
let _ = invoke(&mut app, "file.saveAs");
}
let _ = invoke(&mut app, "file.saveACopy");
// Export As, Quick Export, Save for Web (with and without slices).
for format in ["png", "jpg", "webp", "tif", "tga"] {
dialog(&mut app, "file.export.exportAs", json!({"format": format}));
}
for format in ["png", "jpg", "gif", "webp"] {
app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap();
let _ = invoke(&mut app, "file.export.quickExportAsPng");
}
for format in ["gif", "png8", "png24", "jpeg", "wbmp"] {
dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format}));
}
app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
dialog(&mut app, "file.export.saveForWebLegacy", json!({"html": true}));
// Measurement Log, Export/Import Presets.
app.run("image.analysis.recordMeasurements", json!({})).unwrap();
let _ = invoke(&mut app, "measurementLog.export");
dialog(&mut app, "edit.presets.exportImportPresets", json!({"action": "export"}));
let asked = asked.borrow();
let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect();
// The extension each save above should suggest, in order.
let want = [
"psd", // Save As untitled
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
"psd", // Save a Copy
"png jpg webp tif tga", // Export As
"png jpg gif webp", // Quick Export
"gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices
"csv pcpresets", // Measurement Log, Export Presets
]
.join(" ");
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
for (name, ext) in asked.iter().zip(exts) {
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
}
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
let mut woken = false;
let ready = std::future::poll_fn(move |cx| {
if woken {
return std::task::Poll::Ready(7);
}
woken = true;
let _ = tx.send(cx.waker().clone());
std::task::Poll::Pending
});
let waker = std::thread::spawn(move || rx.recv().unwrap().wake());
assert_eq!(block_on(ready), 7);
waker.join().unwrap();
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
/// One app launch with crash recovery in `dir` (dropping it is the crash: background writes
/// already queued finish, nothing else runs).
fn launch(dir: &Path) -> PhotocraftApp {
let mut app = PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())));
let ctx = egui::Context::default();
prefs_ui::tick(&mut app, &ctx);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while app.session.has_jobs() && std::time::Instant::now() < deadline {
photocraft_ui_egui::jobs_ui::tick(&mut app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!app.session.has_jobs(), "recovery finished");
app
}
/// The first pixel of each open document, and whether it's unsaved.
fn open_docs(app: &PhotocraftApp) -> Vec<([f32; 4], bool)> {
app.session.documents().iter().map(|d| (photocraft_compose::flatten(&d.doc).px.first().copied().unwrap_or_default(), d.is_dirty())).collect()
}
fn index_of(app: &PhotocraftApp, color: [f32; 4]) -> usize {
open_docs(app).iter().position(|(c, _)| *c == color).unwrap()
}
fn new_doc(app: &mut PhotocraftApp, color: &str) {
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
app.run("edit.fill", json!({"color": color})).unwrap();
}
fn autosave(app: &mut PhotocraftApp, ctx: &egui::Context) {
prefs_ui::autosave_now(app);
prefs_ui::tick(app, ctx);
}
#[test]
fn recovered_documents_survive_a_second_crash_until_saved_or_closed() {
let dir = std::env::temp_dir().join(format!("photocraft-recovery-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let ctx = egui::Context::default();
// Launch 1: two unsaved documents are autosaved, then the app crashes.
{
let mut app = launch(&dir);
new_doc(&mut app, "#ff0000");
new_doc(&mut app, "#0000ff");
autosave(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 2);
// Launch 2 recovers both and crashes again before its first autosave (#444).
{
let mut app = launch(&dir);
let mut docs = open_docs(&app);
docs.sort_by(|a, b| a.0[0].total_cmp(&b.0[0]));
assert_eq!(docs, [(BLUE, true), (RED, true)], "recovered and still unsaved");
prefs_ui::tick(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 2, "recovering keeps the entries on disk");
// Launch 3 has both again; autosaving unchanged documents adds nothing, a new document
// (whose id may repeat one from an earlier launch) gets an entry of its own.
{
let mut app = launch(&dir);
assert_eq!(app.session.documents().len(), 2);
autosave(&mut app, &ctx);
new_doc(&mut app, "#00ff00");
autosave(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 3);
// Launch 4: closing and saving recovered documents removes their entries, no duplicates.
let mut app = launch(&dir);
assert_eq!(app.session.documents().len(), 3);
let red = index_of(&app, RED);
app.run("file.close", json!({"document": red})).unwrap();
// Saving marks the revision saved (the file write itself is the save service's job).
assert!(app.session.set_active(index_of(&app, BLUE)));
let st = app.session.active_mut().unwrap();
st.saved_revision = st.revision;
prefs_ui::tick(&mut app, &ctx);
let left = list_recovery(&dir);
assert_eq!(left.len(), 1);
assert_eq!(photocraft_compose::flatten(&photocraft_format::recover(&left[0]).unwrap()).px.first().copied(), Some(GREEN));
let green = index_of(&app, GREEN);
app.run("file.close", json!({"document": green})).unwrap();
prefs_ui::tick(&mut app, &ctx);
assert!(list_recovery(&dir).is_empty());
drop(app);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_background_autosave_is_reported_and_retried_without_another_edit() {
let dir = temp("autosave-retry");
let recovery = dir.join("Recovery");
// A regular file prevents creation of the recovery bundle directory.
std::fs::write(&recovery, b"blocked").unwrap();
let ctx = egui::Context::default();
let mut app = launch(&recovery);
new_doc(&mut app, "#ff0000");
let revision = app.session.active().unwrap().revision;
autosave(&mut app, &ctx);
let mut failed = false;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
if app.ui.status.starts_with("Autosave failed:") {
failed = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(failed, "background write failures must reach the UI");
assert!(app.ui.status_error);
assert!(list_recovery(&recovery).is_empty());
// No edit occurs: retrying this very same revision must still work.
std::fs::remove_file(&recovery).unwrap();
std::fs::create_dir(&recovery).unwrap();
autosave(&mut app, &ctx);
let mut recovered = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
recovered = list_recovery(&recovery);
if !recovered.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(recovered.len(), 1, "unchanged revision should retry after failure");
assert_eq!(recovered[0].info.revision, revision);
let restored = photocraft_format::recover(&recovered[0]).unwrap();
assert_eq!(photocraft_compose::flatten(&restored).px.first().copied(), Some(RED));
drop(app);
std::fs::remove_dir_all(dir).unwrap();
}
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-services-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn red_png(w: u32, h: u32) -> Vec<u8> {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).unwrap()
}
/// A 1×1 uncompressed 32-bit TGA (TGA has no magic number).
fn tga_1x1() -> Vec<u8> {
let mut b = vec![0u8, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8];
b.extend_from_slice(&[0, 0, 255, 255]); // BGRA red
b
}
#[test]
fn copied_image_file_pastes_its_pixels() {
let dir = temp("png");
let png = dir.join("red.png");
std::fs::write(&png, red_png(3, 2)).unwrap();
let (w, h, px) = image_from_files(&[png]).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(px, [255u8, 0, 0, 255].repeat(6));
}
#[test]
fn crlf_from_a_uri_list_is_ignored() {
// arboard splits text/uri-list on LF, so GNOME Files' CRLF list leaves a '\r' behind.
let dir = temp("crlf");
let png = dir.join("red image.png");
std::fs::write(&png, red_png(2, 2)).unwrap();
let with_cr = PathBuf::from(format!("{}\r", png.display()));
assert_eq!(image_from_files(&[with_cr]).map(|(w, h, _)| (w, h)), Some((2, 2)));
}
#[test]
fn non_images_are_skipped_for_the_first_image() {
let dir = temp("mixed");
let (txt, png) = (dir.join("notes.txt"), dir.join("red.png"));
std::fs::write(&txt, b"not an image").unwrap();
std::fs::write(&png, red_png(4, 1)).unwrap();
let missing = dir.join("gone.png");
assert_eq!(image_from_files(&[missing.clone(), txt.clone(), dir.clone(), png]).map(|(w, h, _)| (w, h)), Some((4, 1)));
assert!(image_from_files(&[missing, txt, dir]).is_none());
assert!(image_from_files(&[]).is_none());
}
#[test]
fn a_damaged_image_is_skipped_not_a_panic() {
let dir = temp("damaged");
let bad = dir.join("cut.png");
std::fs::write(&bad, &red_png(8, 8)[..20]).unwrap();
assert!(image_from_files(&[bad]).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
let (tga, bin) = (dir.join("red.tga"), dir.join("red.bin"));
std::fs::write(&tga, tga_1x1()).unwrap();
std::fs::write(&bin, tga_1x1()).unwrap();
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_files(&[bin]).is_none());
}
}
@@ -0,0 +1,68 @@
use super::*;
use photocraft_color::Color;
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
use std::sync::atomic::{AtomicUsize, Ordering};
fn snapshot(tag: &str) -> (PathBuf, Document, RecoveryEntry) {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!("photocraft-deferred-recovery-{tag}-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&dir);
let mut doc = Document::with_background("Recovered drawing", Size::new(4, 3), ColorMode::Rgb, SampleType::U16, Color::WHITE);
doc.layers[0].surface_mut().unwrap().write_pixel(1, 2, &[1.0, 0.0, 0.0, 1.0]);
let saver = Autosaver::new(&dir, "previous-session");
saver.request_checked(Arc::new(doc.clone()), 7, Some(dir.join("original.pcraft").to_string_lossy().into_owned()), SaveOptions::default()).unwrap();
assert!(saver.flush().unwrap().unwrap().tiles_written > 0);
let mut entries = list_recovery(&dir);
assert_eq!(entries.len(), 1);
(dir, doc, entries.pop().unwrap())
}
#[test]
fn recovery_discovers_metadata_before_decoding_and_keeps_a_failed_snapshot() {
let (dir, doc, entry) = snapshot("corrupt");
let tile = std::fs::read_dir(entry.bundle.join("tiles")).unwrap().next().unwrap().unwrap().path();
std::fs::write(&tile, b"corrupt compressed tile").unwrap();
let sidecar = dir.join(format!("{}.json", entry.info.key));
let metadata = std::fs::read(&sidecar).unwrap();
let mut services = recovery_services(Some(dir.clone()));
let mut found = services.recover.as_mut().unwrap()();
assert_eq!(found.len(), 1, "discovery lists metadata without decoding the corrupt pixels");
let pending = found.pop().unwrap();
assert_eq!(pending.key, entry.info.key);
assert_eq!(pending.name, doc.name);
assert_eq!(pending.path, entry.info.original_path);
assert!((pending.load)().is_err(), "decoding is deferred until the loader runs");
assert_eq!(std::fs::read(&tile).unwrap(), b"corrupt compressed tile");
assert_eq!(std::fs::read(&sidecar).unwrap(), metadata);
assert_eq!(list_recovery(&dir), vec![entry], "a failed recovery must leave the snapshot available");
drop(services);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn deferred_recovery_preserves_pixels_and_metadata_until_the_adopted_entry_is_discarded() {
let (dir, doc, entry) = snapshot("valid");
let mut services = recovery_services(Some(dir.clone()));
let mut found = services.recover.as_mut().unwrap()();
assert_eq!(found.len(), 1);
let pending = found.pop().unwrap();
assert_eq!(pending.key, entry.info.key);
assert_eq!(pending.name, doc.name);
assert_eq!(pending.path, entry.info.original_path);
// The loader can move to a worker without taking the main thread's recovery store with it.
let restored = std::thread::spawn(pending.load).join().unwrap().unwrap();
assert_eq!(restored, doc);
assert_eq!(restored.layers[0].surface().unwrap().pixel(1, 2), vec![1.0, 0.0, 0.0, 1.0]);
services.adopt_autosave.as_mut().unwrap()(restored.id.0, &pending.key);
assert_eq!(list_recovery(&dir), vec![entry.clone()], "loading and adopting retain the recovery files");
services.discard_autosave.as_mut().unwrap()(restored.id.0);
assert!(list_recovery(&dir).is_empty());
assert!(!entry.bundle.exists());
assert!(!dir.join(format!("{}.json", pending.key)).exists());
drop(services);
std::fs::remove_dir_all(dir).unwrap();
}
+107 -7
View File
@@ -4,8 +4,17 @@
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
//! `WM_POINTER` pressure as touch force. Wayland has no tablet input yet (see the crate docs).
//! `WM_POINTER` pressure as touch force.
//!
//! Wayland compositors give a pen only to clients that bind the tablet protocol
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
//! `unsafe`, see the crate docs): there the pen does nothing in the window, not even move the
//! pointer (#639). Xwayland binds it and serves the pen as an XInput2 device, so with a pen
//! attached the app opens its window through Xwayland ([`display_session`]), where the X11 reader
//! gets pressure, tilt and the eraser end.
#[cfg(any(target_os = "linux", test))]
use crate::linux_libs::DisplaySession;
use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
@@ -19,20 +28,60 @@ pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the AppKit tablet monitor (main thread, before the event loop). Keep the result until
/// the event loop returns. On failure this logs why: pens then paint like a mouse.
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
/// returns. On failure this logs why: pens then paint like a mouse.
#[cfg(target_os = "macos")]
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
}
/// Start the XInput2 reader when eframe runs on X11. On Wayland, `$DISPLAY` is Xwayland, which
/// sees none of this window's input (and a pen sample from another X app would outlive a mouse
/// stroke here), so nothing starts.
/// The display server to open the window on: the session's own, except Xwayland on Wayland when
/// a pen is attached (see the module docs) and Xwayland can run the window: `$DISPLAY` is set
/// and the X11 libraries are installed. `PHOTOCRAFT_NATIVE_WAYLAND=1` keeps native Wayland.
#[cfg(target_os = "linux")]
pub fn display_session() -> DisplaySession {
let var = |k: &str| std::env::var(k).ok();
let session = crate::linux_libs::session_from_env(var);
let pen = || std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|d| has_pen(&d));
let x11_libs = || crate::linux_libs::available(DisplaySession::X11);
if pen_needs_xwayland(session, var, pen, x11_libs) {
eprintln!("photocraft: pen found; opening the window through Xwayland, where the pen works (PHOTOCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
return DisplaySession::X11;
}
session
}
/// [`display_session`]'s decision; the system probes run only when the session asks for them.
#[cfg(any(target_os = "linux", test))]
pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<String>, pen: impl FnOnce() -> bool, x11_libs: impl FnOnce() -> bool) -> bool {
let set = |k: &str| var(k).is_some_and(|v| !v.is_empty() && v != "0");
session == DisplaySession::Wayland && !set("PHOTOCRAFT_NATIVE_WAYLAND") && set("DISPLAY") && pen() && x11_libs()
}
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key a pen tablet's stylus, a pen display's or
/// a pen-enabled touchscreen's reports (libinput treats such a device as a tablet tool).
#[cfg(any(target_os = "linux", test))]
const BTN_TOOL_PEN: u32 = 0x140;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
#[cfg(any(target_os = "linux", test))]
pub fn has_pen(devices: &str) -> bool {
let bits = usize::BITS;
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
})
}
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
/// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another
/// X app would outlive a mouse stroke here), so nothing starts.
#[cfg(target_os = "linux")]
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
if display != Some(DisplayKind::X11) {
log::info!("tablet pressure: not available on this display server ({display:?}); Wayland is a follow-up to #79");
log::info!("tablet pressure: not available on this display server ({display:?})");
return;
}
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
@@ -161,6 +210,57 @@ mod tests {
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
}
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, BTN_TOOL_PEN) and of the same laptop without it.
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
N: Name=\"AT Translated Set 2 keyboard\"\n\
H: Handlers=sysrq kbd event3 leds\n\
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
\n\
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
H: Handlers=mouse1 event7\n\
B: KEY=e520 10000 0 0 0 0\n\
B: ABS=2e0800000000003\n\
\n\
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
N: Name=\"Wacom Intuos Pro M Pen\"\n\
H: Handlers=mouse2 event10\n\
B: KEY=1c03 0 0 0 0 0\n\
B: ABS=1000f000003\n";
#[test]
fn a_pen_is_found_by_its_btn_tool_pen_key() {
assert!(has_pen(WITH_PEN));
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
// Short or malformed bitmaps are no pen, never a panic.
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(keys), "{keys:?}");
}
}
#[test]
fn a_pen_on_wayland_opens_the_window_through_xwayland() {
use std::collections::HashMap;
let env = |pairs: &[(&str, &str)]| {
let m: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k: &str| m.get(k).cloned()
};
let wayland = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")];
assert!(pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || true, || true));
// No pen, no Xwayland, no X11 libraries, or the user keeps Wayland: native Wayland.
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || false, || true));
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&[("WAYLAND_DISPLAY", "wayland-0")]), || true, || true));
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || true, || false));
let native = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("PHOTOCRAFT_NATIVE_WAYLAND", "1")];
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&native), || true, || true));
let zero = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("PHOTOCRAFT_NATIVE_WAYLAND", "0")];
assert!(pen_needs_xwayland(DisplaySession::Wayland, env(&zero), || true, || true));
// X11 already gets the pen; the probes don't even run off Wayland.
assert!(!pen_needs_xwayland(DisplaySession::X11, env(&[("DISPLAY", ":0")]), || panic!("probed"), || panic!("probed")));
}
#[test]
fn use_tablet_pressure_off_paints_like_a_mouse() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
+133
View File
@@ -0,0 +1,133 @@
//! The saved window geometry and egui layout (`ui.ron`, written by eframe's persistence).
//!
//! eframe restores this state while it creates the window, before PhotoCraft's code runs, and
//! some values it does not check abort startup: a zoom factor of zero, NaN or 1e30 fails a
//! `dpi` assertion, and a NaN or huge window size fails surface creation. A truncated or
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
//! holding such values (the window and panels open at their defaults; nothing else is lost).
//! A file that isn't RON at all is left alone: eframe already ignores it.
use std::path::Path;
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
const MAX_COORD: f64 = 1.0e6;
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
/// 300% on a 1× display is 3).
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
pub fn sanitize(path: Option<&Path>) {
let Some(path) = path else { return };
let Ok(text) = std::fs::read_to_string(path) else { return };
if let Err(why) = check(&text) {
log::warn!("ignoring saved window layout {}: {why}", path.display());
if let Err(e) = std::fs::remove_file(path) {
log::warn!("could not remove {}: {e}", path.display());
}
}
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
for (key, value) in &kv {
// eframe also skips an entry that doesn't parse.
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
floats_sane(&v, 0).map_err(|e| format!("{key}: {e}"))?;
let zoom = (key == "egui").then(|| field(&v, "options").and_then(|o| field(o, "zoom_factor")).and_then(as_f64)).flatten();
if let Some(z) = zoom.filter(|z| !ZOOM.contains(z)) {
return Err(format!("egui: zoom factor {z}"));
}
}
Ok(())
}
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
if depth > 64 {
return Err("nested too deeply".into());
}
match v {
ron::Value::Number(_) => match as_f64(v) {
Some(f) if !f.is_finite() || f.abs() > MAX_COORD => Err(format!("value {f}")),
_ => Ok(()),
},
ron::Value::Option(Some(inner)) => floats_sane(inner, depth + 1),
ron::Value::Seq(items) => items.iter().try_for_each(|i| floats_sane(i, depth + 1)),
ron::Value::Map(m) => m.iter().try_for_each(|(k, v)| floats_sane(k, depth + 1).and_then(|()| floats_sane(v, depth + 1))),
_ => Ok(()),
}
}
/// A float (integers such as egui's hashed ids are not coordinates).
fn as_f64(v: &ron::Value) -> Option<f64> {
match v {
ron::Value::Number(ron::value::Number::F32(f)) => Some(f64::from(f.get())),
ron::Value::Number(ron::value::Number::F64(f)) => Some(f.get()),
_ => None,
}
}
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
match v {
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::check;
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
format!("{{\"window\":{window:?},\"egui\":{egui:?}}}")
}
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
#[test]
fn a_saved_layout_is_kept() {
assert_eq!(check(&file(WINDOW, "1.0")), Ok(()));
assert_eq!(check(&file(WINDOW, "0.375")), Ok(()));
}
#[test]
fn values_that_abort_startup_are_rejected() {
// Each of these aborted the app at launch before the check (dpi / wgpu assertions).
for zoom in ["0.0", "NaN", "1e30", "-1.0"] {
assert!(check(&file(WINDOW, zoom)).is_err(), "zoom {zoom}");
}
for size in ["(x:NaN,y:760.0)", "(x:1100.0,y:1e30)", "(x:inf,y:1.0)"] {
let w = WINDOW.replace("(x:1100.0,y:760.0)", size);
assert!(check(&file(&w, "1.0")).is_err(), "size {size}");
}
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
for text in ["", "not ron {{{", "{\"window\":\"(((\"}", "[1,2,3]"] {
assert_eq!(check(text), Ok(()), "{text:?}");
}
let deep = format!("{{\"egui\":{:?}}}", "[".repeat(500) + &"]".repeat(500));
let _ = check(&deep);
}
#[test]
fn sanitize_removes_only_a_bad_file() {
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("good.ron");
let bad = dir.join("bad.ron");
std::fs::write(&good, file(WINDOW, "1.0")).unwrap();
std::fs::write(&bad, file(WINDOW, "0.0")).unwrap();
super::sanitize(Some(&good));
super::sanitize(Some(&bad));
super::sanitize(Some(&dir.join("missing.ron")));
super::sanitize(None);
assert!(good.exists() && !bad.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Keeps the custom (undecorated) title bar window truly borderless (#2246).
//!
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems
//! (reported: Windows 10 LTSB, Intel HD Graphics 4600, 1366 × 768) the desktop compositor still
//! reserves that frame for the window surface: the content is drawn offset by the caption and
//! border, leaving black bars at the window's top-left, while the pointer keeps the un-offset
//! client coordinates, so it no longer points at what is drawn under it.
//!
//! winit 0.27 — before rust-windowing/winit#2419 — simply omitted those styles for undecorated
//! windows, and there was no offset. [`service`] restores that: it strips `WS_CAPTION` and
//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it
//! changes the window flags (maximize, restore, …), so the returned guard checks every frame.
//!
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included.
//! The shell reads a caption-less window covering its monitor as a full-screen app and drops the
//! taskbar's backdrop behind it — a black band on stock Windows 11, a glitched unresponsive one
//! under ExplorerPatcher (#3144). The guard therefore also trims a parked maximize's rect back to
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
//!
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
pub use platform::service;
#[cfg(target_os = "windows")]
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::WindowFrameFn;
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
/// system title bar (the window is decorated) or its handle can't be found.
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<WindowFrameFn> {
if !custom_titlebar {
return None;
}
let target = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(h) => h.hwnd.get(),
_ => return None,
};
let hwnd = find_window(target)?;
reapply(&hwnd);
Some(Box::new(move || reapply(&hwnd)))
}
/// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so
/// this matches winit's own window without wrapping a raw handle (which needs `unsafe`).
fn find_window(target: isize) -> Option<HWND> {
let mut found = None;
let _ = winsafe::EnumWindows(|hwnd| {
if hwnd.ptr() as isize == target {
found = Some(hwnd);
false
} else {
true
}
});
found
}
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
/// maximize stays clear of the taskbar.
fn reapply(hwnd: &HWND) {
strip(hwnd);
keep_clear_of_the_taskbar(hwnd);
}
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they
/// are gone, so it is cheap to call every frame.
fn strip(hwnd: &HWND) {
let style = hwnd.style();
let without = borderless(style);
if without.raw() == style.raw() {
return;
}
hwnd.set_style(without);
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
/// parked but being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
}
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
return;
};
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
let (l, t, r, b) = rect;
let (ml, mt, mr, _) = monitor;
let (_, _, _, wb) = work;
let side = ml - l;
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
return None;
}
if b <= wb + side {
return None;
}
Some(wb + side - t)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
/// keep working, as they did with winit 0.27's undecorated windows.
fn borderless(style: co::WS) -> co::WS {
style & !(co::WS::CAPTION | co::WS::BORDER)
}
#[cfg(test)]
mod tests {
use super::{borderless, clamped_height};
use winsafe::co;
#[test]
fn the_borderless_style_drops_the_caption_and_border_only() {
// `WS_CAPTION` is 0x00c0_0000 and already contains `WS_BORDER` (0x0080_0000).
let decorated = co::WS::CAPTION | co::WS::BORDER | co::WS::SYSMENU | co::WS::THICKFRAME | co::WS::MAXIMIZEBOX | co::WS::MINIMIZEBOX;
let bare = borderless(decorated);
assert!(!bare.has(co::WS::CAPTION), "caption gone");
assert!(!bare.has(co::WS::BORDER), "border gone");
assert!(bare.has(co::WS::THICKFRAME), "edge resizing kept");
assert!(bare.has(co::WS::MAXIMIZEBOX) && bare.has(co::WS::SYSMENU), "window buttons kept");
// Already borderless: unchanged, so the per-frame call does nothing.
assert_eq!(bare.raw(), borderless(bare).raw());
}
#[test]
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
// plus the frame the window was parked with.
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
}
#[test]
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
// What Windows sizes a framed maximize to: the work area plus the frame.
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
}
#[test]
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
// Taskbar on the left: the work area starts further right, but the monitor's bottom
// is still clear, so the rect reaches nothing.
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
}
#[test]
fn a_maximize_being_dragged_away_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
}
}
}
#[cfg(not(target_os = "windows"))]
mod platform {
use photocraft_ui_egui::WindowFrameFn;
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<WindowFrameFn> {
None
}
}
@@ -0,0 +1,73 @@
//! `--control` setup failures must exit non-zero (#1816): a launcher that sees
//! status 0 believes the control server is running when it is not.
//!
//! These tests run the real binary as a subprocess and assert the exit code,
//! covering both failure paths in `main.rs`: control authentication and the
//! automation workspace.
/// A syntactically valid 64-hex-char token, so the workspace test reaches the
/// workspace check instead of failing on token validation first.
const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// Run `photocraft` with the given arguments in an isolated config dir and
/// return the exit status and stderr. Each call gets its own config dir (tests run in
/// parallel), and no display variables, so a regression fails fast instead of opening a window.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
// Isolate from the host environment so the test is reproducible.
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.env_remove("PHOTOCRAFT_CONTROL_TOKEN")
.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE")
.env_remove("PHOTOCRAFT_AUTOMATION_READ_ROOT")
.env_remove("PHOTOCRAFT_AUTOMATION_WRITE_ROOT")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stderr).into_owned())
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
}
#[test]
fn both_token_and_token_file_exits_with_status_2() {
// `server_token` rejects the combination of a supplied token and a token file.
let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let token_file = dir.join("token.txt");
std::fs::write(&token_file, VALID_TOKEN).unwrap();
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
}
#[test]
fn missing_automation_read_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the read root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
#[test]
fn missing_automation_write_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the write root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
+197
View File
@@ -0,0 +1,197 @@
//! The macOS menu bar end to end, on the main thread (`harness = false`): install it through the
//! real muda backend (`src/mac_menu.rs`), read AppKit's menu bar back, open a document and see the
//! items update in place, then choose items the two ways AppKit can: a key equivalent (a real ⌘R
//! key-down through `-[NSMenu performKeyEquivalent:]`) must reach egui as the key press it was, and
//! a click (`-[NSMenu performActionForItemAtIndex:]`) must run as a click, also when the click is
//! ↩ inside the open menu.
// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its
// unit tests compile here too without a harness to run them).
#[cfg(target_os = "macos")]
#[path = "../src/mac_menu.rs"]
#[allow(unused_imports, dead_code)]
mod mac_menu;
#[cfg(target_os = "macos")]
fn main() {
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags, NSEventSubtype, NSEventType, NSMenu};
use objc2_foundation::{NSPoint, NSString};
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::native_menu;
use serde_json::json;
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let nsapp = NSApplication::sharedApplication(mtm);
nsapp.finishLaunching();
let titles = |menu: &NSMenu| -> Vec<String> {
(0..menu.numberOfItems()).filter_map(|i| menu.itemAtIndex(i)).filter(|it| !it.isSeparatorItem()).map(|it| it.title().to_string()).collect()
};
let submenu = |menu: &NSMenu, title: &str| -> Retained<NSMenu> {
(0..menu.numberOfItems())
.filter_map(|i| menu.itemAtIndex(i))
.find(|it| it.title().to_string() == title)
.and_then(|it| it.submenu())
.unwrap_or_else(|| panic!("no {title} submenu in {:?}", titles(menu)))
};
let index = |menu: &NSMenu, title: &str| -> isize {
(0..menu.numberOfItems())
.find(|&i| menu.itemAtIndex(i).is_some_and(|it| it.title().to_string() == title))
.unwrap_or_else(|| panic!("no {title} in {:?}", titles(menu)))
};
let main_menu = || nsapp.mainMenu().expect("a main menu");
// Make `event` AppKit's current event, as the run loop does before dispatching it.
let dequeue = |event: &NSEvent| {
nsapp.postEvent_atStart(event, true);
let mode = NSString::from_str("kCFRunLoopDefaultMode");
let got = nsapp.nextEventMatchingMask_untilDate_inMode_dequeue(NSEventMask::Any, None, &mode, true).expect("the posted event");
assert_eq!(got.r#type(), event.r#type());
got
};
let key_down = |chars: &str, code: u16, flags: NSEventModifierFlags| {
let s = NSString::from_str(chars);
NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode(
NSEventType::KeyDown,
NSPoint::new(0.0, 0.0),
flags,
0.0,
0,
None,
&s,
&s,
false,
code,
)
.expect("a key event")
};
let other = || {
NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
NSEventType::ApplicationDefined,
NSPoint::new(0.0, 0.0),
NSEventModifierFlags::empty(),
0.0,
0,
None,
NSEventSubtype::ApplicationActivated.0,
0,
0,
)
.expect("an application-defined event")
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
let ctx = egui::Context::default();
let frame = |app: &mut PhotocraftApp, raw: egui::RawInput| {
let mut out = ctx.run_ui(raw, |ui| {
native_menu::run(app, ui.ctx());
photocraft_ui_egui::shortcuts::handle(app, ui.ctx());
native_menu::sync(app, ui.ctx());
});
out.textures_delta.clear();
};
app.services.native_menu = mac_menu::install(&ctx, &app);
assert!(app.services.native_menu.is_some(), "the menu bar installed");
// The Mac layout, as AppKit has it.
let bar = main_menu();
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]);
let app_menu = submenu(&bar, "PhotoCraft");
let names = titles(&app_menu);
for want in ["About PhotoCraft", "Settings", "Language", "Appearance", "Hide PhotoCraft", "Quit PhotoCraft"] {
assert!(names.iter().any(|n| n == want), "{want} missing from the app menu: {names:?}");
}
let quit = app_menu.itemAtIndex(index(&app_menu, "Quit PhotoCraft")).expect("Quit");
assert_eq!(quit.keyEquivalent().to_string(), "q");
assert_eq!(quit.keyEquivalentModifierMask(), NSEventModifierFlags::Command);
assert!(!titles(&submenu(&bar, "File")).iter().any(|n| n == "Exit"), "Exit moved to the app menu as Quit");
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
assert_eq!(nsapp.helpMenu().map(|m| m.title().to_string()).as_deref(), Some("Help"), "AppKit knows the Help menu");
// Opening a document enables Layer › New › Layer… in place: same NSMenu, no rebuild.
let new_layer = |bar: &NSMenu| {
let new = submenu(&submenu(bar, "Layer"), "New");
new.itemAtIndex(index(&new, "Layer…")).expect("Layer…").isEnabled()
};
assert!(!new_layer(&bar), "no document: New Layer is disabled");
app.run("file.new", json!({"width": 64, "height": 48})).expect("a document");
frame(&mut app, egui::RawInput::default());
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "opening a document updated the menu, not rebuilt it");
assert!(new_layer(&main_menu()), "with a document New Layer is enabled");
// A key equivalent: ⌘R reaches egui as ⌘R and toggles View › Rulers once.
let rulers = app.ui.extras.rulers;
let press = dequeue(&key_down("r", 15, NSEventModifierFlags::Command));
assert!(main_menu().performKeyEquivalent(&press), "⌘R is View › Rulers' key equivalent");
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
let keys: Vec<_> = raw
.events
.iter()
.filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None })
.collect();
// AppKit took only the key-down; winit delivers the key-up itself (#1638).
assert_eq!(keys, [(egui::Key::R, true, true)], "the key press");
frame(&mut app, raw);
let ran: Vec<String> = photocraft_ui_egui::shortcut_dispatch::take_log(&ctx).into_iter().map(|(id, _)| id).collect();
assert_eq!(ran, ["view.rulers"], "⌘R ran Rulers, whichever ⌘R item AppKit matched");
assert_eq!(app.ui.extras.rulers, !rulers, "⌘R toggled Rulers once");
// A click: View › Rulers chosen with the mouse runs the command.
let view = submenu(&main_menu(), "View");
let at = index(&view, "Rulers");
dequeue(&other());
view.performActionForItemAtIndex(at);
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
assert!(raw.events.is_empty(), "a click is no key press: {:?}", raw.events);
frame(&mut app, raw);
assert_eq!(app.ui.extras.rulers, rulers, "the click toggled Rulers back");
// ↩ inside the open menu is a key-down without ⌘: still a click.
dequeue(&key_down("\r", 36, NSEventModifierFlags::empty()));
view.performActionForItemAtIndex(at);
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
assert!(raw.events.is_empty(), "↩ in the menu is a click: {:?}", raw.events);
frame(&mut app, raw);
assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it");
// Window ends with the open documents (#2991). Opening one rebuilds only the Window menu,
// which AppKit still knows as its Window menu; choosing a row brings that document forward.
app.run("file.new", json!({"width": 32, "height": 32})).expect("a second document");
frame(&mut app, egui::RawInput::default());
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "a new document rebuilt only the Window menu");
let window = submenu(&main_menu(), "Window");
assert!(nsapp.windowsMenu().is_some_and(|w| Retained::as_ptr(&w) == Retained::as_ptr(&window)), "AppKit knows the new Window menu");
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
// The last rows (both documents are "Untitled").
let rows = [window.numberOfItems() - 2, window.numberOfItems() - 1];
let shown: Vec<String> = rows.iter().filter_map(|&i| window.itemAtIndex(i)).map(|it| it.title().to_string()).collect();
assert_eq!(shown, names, "in tab order, after Bring All to Front: {:?}", titles(&window));
let checked = |menu: &NSMenu, at: isize| menu.itemAtIndex(at).is_some_and(|it| it.state() == 1);
assert!(!checked(&window, rows[0]) && checked(&window, rows[1]), "the active document is checked");
dequeue(&other());
window.performActionForItemAtIndex(rows[0]);
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
frame(&mut app, raw);
assert_eq!(app.session.active_index(), Some(0), "choosing the first row brought it forward");
let window = submenu(&main_menu(), "Window");
assert!(checked(&window, rows[0]) && !checked(&window, rows[1]), "the check moved in place");
println!("mac_menu_appkit: ok");
}
#[cfg(not(target_os = "macos"))]
fn main() {}
@@ -0,0 +1,47 @@
//! A failed Linux desktop launch must not poison the next launch's graphics policy.
#![cfg(target_os = "linux")]
#[test]
fn missing_display_does_not_leave_a_gpu_crash_marker() {
let dir = std::env::temp_dir().join(format!("photocraft-no-display-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let prefs = br#"{"performance":{"gpuBackend":"vulkan","useGpu":true}}"#;
std::fs::write(dir.join("preferences.json"), prefs).unwrap();
let error = launch_without_display(&dir);
assert!(!dir.join("gpu-starting.json").exists(), "a display error is not a GPU crash: {error}");
assert_eq!(std::fs::read(dir.join("preferences.json")).unwrap(), prefs);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn missing_display_preserves_existing_gpu_crash_evidence() {
let dir = std::env::temp_dir().join(format!("photocraft-no-display-prior-crash-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let marker = serde_json::json!({
"backend": "vulkan", "adapter": "Test GPU", "adapterBackend": "vulkan",
"driver": "test", "version": "test"
});
std::fs::write(dir.join("gpu-starting.json"), serde_json::to_vec(&marker).unwrap()).unwrap();
launch_without_display(&dir);
let restored: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("gpu-starting.json")).unwrap()).unwrap();
assert_eq!(restored, marker, "a windowing error must not erase or advance prior GPU crash evidence");
std::fs::remove_dir_all(&dir).unwrap();
}
fn launch_without_display(dir: &std::path::Path) -> String {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.env("PHOTOCRAFT_CONFIG_DIR", dir)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WGPU_BACKEND")
.env_remove("PHOTOCRAFT_GPU_STARTUP_FAILURE")
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.output()
.unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "a desktop cannot open without a display");
assert!(error.contains("WAYLAND_DISPLAY") && error.contains("DISPLAY"), "expected a display error, got: {error}");
error.into_owned()
}
@@ -0,0 +1,334 @@
assets/icons/lasso-magnetic.svg
Adapted from "Magnetic Lasso Tool" by Lil' Seal (failureiscool) from Noun Project,
https://thenounproject.com/icon/magnetic-lasso-tool-177513/
licensed under Creative Commons Attribution 3.0 Unported (CC BY 3.0),
https://creativecommons.org/licenses/by/3.0/
Changes by PhotoCraft contributors: redrawn as a 24 x 24 SVG on PhotoCraft's toolbar grid, with
round-capped strokes in place of the original line weights, so it can be tinted and rasterized
like the other toolbar icons.
The full text of the licence follows.
--------------------------------------------------------------------------------
Creative Commons Legal Code
Attribution 3.0 Unported
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
DAMAGES RESULTING FROM ITS USE.
License
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
CONDITIONS.
1. Definitions
a. "Adaptation" means a work based upon the Work, or upon the Work and
other pre-existing works, such as a translation, adaptation,
derivative work, arrangement of music or other alterations of a
literary or artistic work, or phonogram or performance and includes
cinematographic adaptations or any other form in which the Work may be
recast, transformed, or adapted including in any form recognizably
derived from the original, except that a work that constitutes a
Collection will not be considered an Adaptation for the purpose of
this License. For the avoidance of doubt, where the Work is a musical
work, performance or phonogram, the synchronization of the Work in
timed-relation with a moving image ("synching") will be considered an
Adaptation for the purpose of this License.
b. "Collection" means a collection of literary or artistic works, such as
encyclopedias and anthologies, or performances, phonograms or
broadcasts, or other works or subject matter other than works listed
in Section 1(f) below, which, by reason of the selection and
arrangement of their contents, constitute intellectual creations, in
which the Work is included in its entirety in unmodified form along
with one or more other contributions, each constituting separate and
independent works in themselves, which together are assembled into a
collective whole. A work that constitutes a Collection will not be
considered an Adaptation (as defined above) for the purposes of this
License.
c. "Distribute" means to make available to the public the original and
copies of the Work or Adaptation, as appropriate, through sale or
other transfer of ownership.
d. "Licensor" means the individual, individuals, entity or entities that
offer(s) the Work under the terms of this License.
e. "Original Author" means, in the case of a literary or artistic work,
the individual, individuals, entity or entities who created the Work
or if no individual or entity can be identified, the publisher; and in
addition (i) in the case of a performance the actors, singers,
musicians, dancers, and other persons who act, sing, deliver, declaim,
play in, interpret or otherwise perform literary or artistic works or
expressions of folklore; (ii) in the case of a phonogram the producer
being the person or legal entity who first fixes the sounds of a
performance or other sounds; and, (iii) in the case of broadcasts, the
organization that transmits the broadcast.
f. "Work" means the literary and/or artistic work offered under the terms
of this License including without limitation any production in the
literary, scientific and artistic domain, whatever may be the mode or
form of its expression including digital form, such as a book,
pamphlet and other writing; a lecture, address, sermon or other work
of the same nature; a dramatic or dramatico-musical work; a
choreographic work or entertainment in dumb show; a musical
composition with or without words; a cinematographic work to which are
assimilated works expressed by a process analogous to cinematography;
a work of drawing, painting, architecture, sculpture, engraving or
lithography; a photographic work to which are assimilated works
expressed by a process analogous to photography; a work of applied
art; an illustration, map, plan, sketch or three-dimensional work
relative to geography, topography, architecture or science; a
performance; a broadcast; a phonogram; a compilation of data to the
extent it is protected as a copyrightable work; or a work performed by
a variety or circus performer to the extent it is not otherwise
considered a literary or artistic work.
g. "You" means an individual or entity exercising rights under this
License who has not previously violated the terms of this License with
respect to the Work, or who has received express permission from the
Licensor to exercise rights under this License despite a previous
violation.
h. "Publicly Perform" means to perform public recitations of the Work and
to communicate to the public those public recitations, by any means or
process, including by wire or wireless means or public digital
performances; to make available to the public Works in such a way that
members of the public may access these Works from a place and at a
place individually chosen by them; to perform the Work to the public
by any means or process and the communication to the public of the
performances of the Work, including by public digital performance; to
broadcast and rebroadcast the Work by any means including signs,
sounds or images.
i. "Reproduce" means to make copies of the Work by any means including
without limitation by sound or visual recordings and the right of
fixation and reproducing fixations of the Work, including storage of a
protected performance or phonogram in digital form or other electronic
medium.
2. Fair Dealing Rights. Nothing in this License is intended to reduce,
limit, or restrict any uses free from copyright or rights arising from
limitations or exceptions that are provided for in connection with the
copyright protection under copyright law or other applicable laws.
3. License Grant. Subject to the terms and conditions of this License,
Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
perpetual (for the duration of the applicable copyright) license to
exercise the rights in the Work as stated below:
a. to Reproduce the Work, to incorporate the Work into one or more
Collections, and to Reproduce the Work as incorporated in the
Collections;
b. to create and Reproduce Adaptations provided that any such Adaptation,
including any translation in any medium, takes reasonable steps to
clearly label, demarcate or otherwise identify that changes were made
to the original Work. For example, a translation could be marked "The
original work was translated from English to Spanish," or a
modification could indicate "The original work has been modified.";
c. to Distribute and Publicly Perform the Work including as incorporated
in Collections; and,
d. to Distribute and Publicly Perform Adaptations.
e. For the avoidance of doubt:
i. Non-waivable Compulsory License Schemes. In those jurisdictions in
which the right to collect royalties through any statutory or
compulsory licensing scheme cannot be waived, the Licensor
reserves the exclusive right to collect such royalties for any
exercise by You of the rights granted under this License;
ii. Waivable Compulsory License Schemes. In those jurisdictions in
which the right to collect royalties through any statutory or
compulsory licensing scheme can be waived, the Licensor waives the
exclusive right to collect such royalties for any exercise by You
of the rights granted under this License; and,
iii. Voluntary License Schemes. The Licensor waives the right to
collect royalties, whether individually or, in the event that the
Licensor is a member of a collecting society that administers
voluntary licensing schemes, via that society, from any exercise
by You of the rights granted under this License.
The above rights may be exercised in all media and formats whether now
known or hereafter devised. The above rights include the right to make
such modifications as are technically necessary to exercise the rights in
other media and formats. Subject to Section 8(f), all rights not expressly
granted by Licensor are hereby reserved.
4. Restrictions. The license granted in Section 3 above is expressly made
subject to and limited by the following restrictions:
a. You may Distribute or Publicly Perform the Work only under the terms
of this License. You must include a copy of, or the Uniform Resource
Identifier (URI) for, this License with every copy of the Work You
Distribute or Publicly Perform. You may not offer or impose any terms
on the Work that restrict the terms of this License or the ability of
the recipient of the Work to exercise the rights granted to that
recipient under the terms of the License. You may not sublicense the
Work. You must keep intact all notices that refer to this License and
to the disclaimer of warranties with every copy of the Work You
Distribute or Publicly Perform. When You Distribute or Publicly
Perform the Work, You may not impose any effective technological
measures on the Work that restrict the ability of a recipient of the
Work from You to exercise the rights granted to that recipient under
the terms of the License. This Section 4(a) applies to the Work as
incorporated in a Collection, but this does not require the Collection
apart from the Work itself to be made subject to the terms of this
License. If You create a Collection, upon notice from any Licensor You
must, to the extent practicable, remove from the Collection any credit
as required by Section 4(b), as requested. If You create an
Adaptation, upon notice from any Licensor You must, to the extent
practicable, remove from the Adaptation any credit as required by
Section 4(b), as requested.
b. If You Distribute, or Publicly Perform the Work or any Adaptations or
Collections, You must, unless a request has been made pursuant to
Section 4(a), keep intact all copyright notices for the Work and
provide, reasonable to the medium or means You are utilizing: (i) the
name of the Original Author (or pseudonym, if applicable) if supplied,
and/or if the Original Author and/or Licensor designate another party
or parties (e.g., a sponsor institute, publishing entity, journal) for
attribution ("Attribution Parties") in Licensor's copyright notice,
terms of service or by other reasonable means, the name of such party
or parties; (ii) the title of the Work if supplied; (iii) to the
extent reasonably practicable, the URI, if any, that Licensor
specifies to be associated with the Work, unless such URI does not
refer to the copyright notice or licensing information for the Work;
and (iv) , consistent with Section 3(b), in the case of an Adaptation,
a credit identifying the use of the Work in the Adaptation (e.g.,
"French translation of the Work by Original Author," or "Screenplay
based on original Work by Original Author"). The credit required by
this Section 4 (b) may be implemented in any reasonable manner;
provided, however, that in the case of a Adaptation or Collection, at
a minimum such credit will appear, if a credit for all contributing
authors of the Adaptation or Collection appears, then as part of these
credits and in a manner at least as prominent as the credits for the
other contributing authors. For the avoidance of doubt, You may only
use the credit required by this Section for the purpose of attribution
in the manner set out above and, by exercising Your rights under this
License, You may not implicitly or explicitly assert or imply any
connection with, sponsorship or endorsement by the Original Author,
Licensor and/or Attribution Parties, as appropriate, of You or Your
use of the Work, without the separate, express prior written
permission of the Original Author, Licensor and/or Attribution
Parties.
c. Except as otherwise agreed in writing by the Licensor or as may be
otherwise permitted by applicable law, if You Reproduce, Distribute or
Publicly Perform the Work either by itself or as part of any
Adaptations or Collections, You must not distort, mutilate, modify or
take other derogatory action in relation to the Work which would be
prejudicial to the Original Author's honor or reputation. Licensor
agrees that in those jurisdictions (e.g. Japan), in which any exercise
of the right granted in Section 3(b) of this License (the right to
make Adaptations) would be deemed to be a distortion, mutilation,
modification or other derogatory action prejudicial to the Original
Author's honor and reputation, the Licensor will waive or not assert,
as appropriate, this Section, to the fullest extent permitted by the
applicable national law, to enable You to reasonably exercise Your
right under Section 3(b) of this License (right to make Adaptations)
but not otherwise.
5. Representations, Warranties and Disclaimer
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
7. Termination
a. This License and the rights granted hereunder will terminate
automatically upon any breach by You of the terms of this License.
Individuals or entities who have received Adaptations or Collections
from You under this License, however, will not have their licenses
terminated provided such individuals or entities remain in full
compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will
survive any termination of this License.
b. Subject to the above terms and conditions, the license granted here is
perpetual (for the duration of the applicable copyright in the Work).
Notwithstanding the above, Licensor reserves the right to release the
Work under different license terms or to stop distributing the Work at
any time; provided, however that any such election will not serve to
withdraw this License (or any other license that has been, or is
required to be, granted under the terms of this License), and this
License will continue in full force and effect unless terminated as
stated above.
8. Miscellaneous
a. Each time You Distribute or Publicly Perform the Work or a Collection,
the Licensor offers to the recipient a license to the Work on the same
terms and conditions as the license granted to You under this License.
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor
offers to the recipient a license to the original Work on the same
terms and conditions as the license granted to You under this License.
c. If any provision of this License is invalid or unenforceable under
applicable law, it shall not affect the validity or enforceability of
the remainder of the terms of this License, and without further action
by the parties to this agreement, such provision shall be reformed to
the minimum extent necessary to make such provision valid and
enforceable.
d. No term or provision of this License shall be deemed waived and no
breach consented to unless such waiver or consent shall be in writing
and signed by the party to be charged with such waiver or consent.
e. This License constitutes the entire agreement between the parties with
respect to the Work licensed here. There are no understandings,
agreements or representations with respect to the Work not specified
here. Licensor shall not be bound by any additional provisions that
may appear in any communication from You. This License may not be
modified without the mutual written agreement of the Licensor and You.
f. The rights granted under, and the subject matter referenced, in this
License were drafted utilizing the terminology of the Berne Convention
for the Protection of Literary and Artistic Works (as amended on
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
and the Universal Copyright Convention (as revised on July 24, 1971).
These rights and subject matter take effect in the relevant
jurisdiction in which the License terms are sought to be enforced
according to the corresponding provisions of the implementation of
those treaty provisions in the applicable national law. If the
standard suite of rights granted under applicable copyright law
includes additional rights not granted under this License, such
additional rights are deemed to be included in the License; this
License is not intended to restrict the license of any rights under
applicable law.
Creative Commons Notice
Creative Commons is not a party to this License, and makes no warranty
whatsoever in connection with the Work. Creative Commons will not be
liable to You or any party on any legal theory for any damages
whatsoever, including without limitation any general, special,
incidental or consequential damages arising in connection to this
license. Notwithstanding the foregoing two (2) sentences, if Creative
Commons has expressly identified itself as the Licensor hereunder, it
shall have all rights and obligations of Licensor.
Except for the limited purpose of indicating to the public that the
Work is licensed under the CCPL, Creative Commons does not authorize
the use by either party of the trademark "Creative Commons" or any
related trademark or logo of Creative Commons without the prior
written consent of Creative Commons. Any permitted use will be in
compliance with Creative Commons' then-current trademark usage
guidelines, as may be published on its website or otherwise made
available upon request from time to time. For the avoidance of doubt,
this trademark restriction does not form part of this License.
Creative Commons may be contacted at https://creativecommons.org/.
+207
View File
@@ -0,0 +1,207 @@
Original painting symmetry axis icons by @shorbank (2026).
Files: symmetry-vertical.svg, symmetry-horizontal.svg, symmetry-dual.svg, symmetry-diagonal.svg.
SPDX-License-Identifier: MIT OR Apache-2.0
These original icons are available under either licence below, at your option.
MIT License
Copyright (c) 2026 shorbank
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Artboard tool glyph (not from an icon set): a board with a tick at each end of its edges. -->
<rect x="7" y="7" width="10" height="10" rx="1" />
<path d="M7 2v2.5M17 2v2.5M7 19.5V22M17 19.5V22" />
<path d="M2 7h2.5M2 17h2.5M19.5 7H22M19.5 17H22" />
</svg>

After

Width:  |  Height:  |  Size: 487 B

+21
View File
@@ -0,0 +1,21 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 12.01h.01" />
<path d="M10 16.01h.01" />
<path d="M14 12.01h.01" />
<path d="M14 16.01h.01" />
<path d="M18 8v12" />
<path d="M6 8v12" />
<rect x="2" y="8" width="20" height="12" rx="2" />
<path d="M20 1v4" />
<path d="M22 3h-4" />
</svg>

After

Width:  |  Height:  |  Size: 471 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M2.5 9A6 6 0 0 1 8 3" />
<path d="m6 1.5 2 1.5-2 1.5" />
<path d="M21.5 15a6 6 0 0 1-5.5 6" />
<path d="m18 19.5-2 1.5 2 1.5" />
</svg>

After

Width:  |  Height:  |  Size: 544 B

@@ -9,6 +9,5 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="12" cy="12" r="1" />
<path d="m15 18-6-6 6-6" />
</svg>
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own clipping-mask cursor (not from an icon set): a hooked arrow down onto the layer below. -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

+18
View File
@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own release-clipping-mask cursor: clip-below.svg unchanged, plus a small "no" badge (thinner strokes keep it legible at cursor size). -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
<circle cx="20" cy="4" r="3.25" stroke-width="1.5" />
<path d="m17.7 1.7 4.6 4.6" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Convert Point glyph: a corner point, an angle with a square anchor on its apex. -->
<rect x="9.75" y="4.75" width="4.5" height="4.5" />
<path d="M10.5 9.25 4 19" />
<path d="M13.5 9.25 20 19" />
</svg>

After

Width:  |  Height:  |  Size: 433 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
<rect x="16.5" y="16.5" width="4.5" height="4.5" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Frame tool glyph (not from an icon set): an empty placeholder frame, crossed corner to corner. -->
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M6.5 7.5l11 9M17.5 7.5l-11 9" />
</svg>

After

Width:  |  Height:  |  Size: 428 B

+17
View File
@@ -0,0 +1,17 @@
<!-- Adapted from "Magnetic Lasso Tool" by Lil' Seal from Noun Project (CC BY 3.0): see LICENSE-noun-magnetic-lasso.txt -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 1.9H16.4A4.6 4.6 0 0 1 21 6.5V7.7A4.6 4.6 0 0 1 16.4 12.3H9V8.95H16.55A0.6 0.6 0 0 0 17.15 8.35V5.85A0.6 0.6 0 0 0 16.55 5.25H9ZM9.75 2.65H12.5V4.5H9.75ZM9.75 9.7H12.5V11.55H9.75Z" fill="currentColor" fill-rule="evenodd" stroke="none" />
<path d="M12.4 7.5 1.8 3.6l7.7 15.3-1.8 2.8" />
<ellipse cx="9.25" cy="16.65" rx="2.85" ry="2.2" />
<path d="m12.1 16.2 10.1-3-1.6-1" />
</svg>

After

Width:  |  Height:  |  Size: 729 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="12" cy="12" r="4" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
<line x1="8" x2="16" y1="12" y2="12" />
</svg>

After

Width:  |  Height:  |  Size: 330 B

+18
View File
@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Single Column Marquee glyph: a dashed column from edge to edge. -->
<path d="M8 3h8" />
<path d="M8 21h8" />
<path d="M12 6.5v1" />
<path d="M12 11.5v1" />
<path d="M12 16.5v1" />
</svg>

After

Width:  |  Height:  |  Size: 422 B

+18
View File
@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Single Row Marquee glyph: a dashed row from edge to edge. -->
<path d="M3 8v8" />
<path d="M21 8v8" />
<path d="M6.5 12h1" />
<path d="M11.5 12h1" />
<path d="M16.5 12h1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 29.33 30.81"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke-width="2.48" d="M1.33,11.42c0-4.05,3.28-7.33,7.33-7.33,2.08,0,4.06.88,5.45,2.43.28.3.75.32,1.06.04.01-.01.02-.02.04-.04,2.69-3.02,7.31-3.29,10.33-.6,1.57,1.4,2.46,3.4,2.45,5.5,0,3.05-2,5.33-4,7.33l-7.32,7.08c-.97,1.11-2.65,1.23-3.76.26-.08-.07-.16-.15-.24-.23l-7.34-7.11c-2-2-4-4.27-4-7.33" />
<line stroke-width="2.41" x1="14.57" y1="1.3" x2="14.76" y2="29.51" />
</svg>

After

Width:  |  Height:  |  Size: 577 B

+18
View File
@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M18 3.5h5" />
<path d="M20.5 1v5" />
</svg>

After

Width:  |  Height:  |  Size: 594 B

+21
View File
@@ -0,0 +1,21 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
</g>
<path d="M3 12C3 7 7 3 12 3" />
<circle cx="3" cy="12" r="1" fill="currentColor" />
<circle cx="12" cy="3" r="1" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 765 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M18 3.5h5" />
</svg>

After

Width:  |  Height:  |  Size: 569 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
</g>
<path d="M8.5 8.5C6 6 7.5 3 5 3S2 6 4 7.5 5.5 11 3 12" />
</svg>

After

Width:  |  Height:  |  Size: 683 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Perspective Crop tool glyph (not from an icon set): a crop frame seen in
perspective, with its grid and two crop-mark ticks. -->
<path d="M8 6h8l4 12H4z" />
<path d="M12 6v12" />
<path d="M6 12h12" />
<path d="M8 2v2" />
<path d="M20 22v-2" />
</svg>

After

Width:  |  Height:  |  Size: 493 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="1.4" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>

After

Width:  |  Height:  |  Size: 326 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

After

Width:  |  Height:  |  Size: 588 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="25.445" y1="25.32" x2="5.365" y2="5.5" />
</svg>

After

Width:  |  Height:  |  Size: 271 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="29.51" y1="15.31" x2="1.3" y2="15.5" />
<line x1="15.5" y1="29.51" x2="15.31" y2="1.3" />
</svg>

After

Width:  |  Height:  |  Size: 321 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="29.51" y1="15.315" x2="1.3" y2="15.505" />
</svg>

After

Width:  |  Height:  |  Size: 272 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="15.315" y1="1.3" x2="15.505" y2="29.51" />
</svg>

After

Width:  |  Height:  |  Size: 272 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Vertical Type Mask glyph (not from an icon set): a T drawn as marching
ants beside a downward arrow (the text runs top to bottom). -->
<path d="M2.5 7V4h12v3" stroke-dasharray="2.5 2.5" />
<path d="M8.5 4v16" stroke-dasharray="2.5 2.5" />
<path d="M6 20h5" stroke-dasharray="2.5 2.5" />
<path d="M20 8v12" />
<path d="M17.5 17.5 20 20l2.5-2.5" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Horizontal Type Mask glyph (not from an icon set): a T drawn as marching ants. -->
<path d="M4 7V4h16v3" stroke-dasharray="2.5 2.5" />
<path d="M12 4v16" stroke-dasharray="2.5 2.5" />
<path d="M8.5 20h7" stroke-dasharray="2.5 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 472 B

+2
View File
@@ -21,6 +21,8 @@
- [Overview](formats/overview.md)
- [PSD and PSB](formats/psd-psb.md)
- [Raster formats](formats/raster-formats.md)
- [SVG](formats/svg.md)
- [Affinity documents](formats/affinity.md)
- [The `.pcraft` format](formats/pcraft.md)
# Automation
+1 -1
View File
@@ -5,7 +5,7 @@ The layer numbers below describe allowed dependency direction, not runtime privi
| Layer | Crates | Responsibility |
|---|---|---|
| L0 | `geom`, `cms`, `color`, `raster` | Geometry, ICC processing, color/blend math, pixel formats, and copy-on-write tiles |
| L0 standalone | `psd`, `codecs` | PSD/PSB and flat raster format parsing/encoding |
| L0 standalone | `psd`, `codecs`, `affinity` | PSD/PSB and flat raster format parsing/encoding; bounded Affinity preview extraction |
| L1 | `doc` | Pure-data documents, layers, masks, effects, adjustments, text, vectors, and smart objects |
| L2 | `ops`, `paint`, `algo`, `text`, `vector` | History operations, painting, imaging algorithms, type, paths, and shapes |
| L3 | `compose`, `gpu`, `format` | CPU composition, wgpu composition, and `.pcraft` persistence |
+43 -3
View File
@@ -13,7 +13,7 @@
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
Use `photocraft-cli --help` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
```sh
photocraft-cli run input.psd \
@@ -21,6 +21,46 @@ photocraft-cli run input.psd \
--out output.png
```
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
### Missing font warnings
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.
If a type layer uses a font family that is not installed, `convert` and `run`
write a warning to standard error before exporting; they do not silently present
the fallback rendering as an exact match. `info --compact` includes the same
warning in its `warnings` JSON array, without writing it to stderr. `run`
also adds `warnings` to the JSON result for the command that first exposes
the missing family. A multi-command `run` reports each missing family once,
even if later commands still use it.
Warnings do not change the saved document's font declarations and do not stop
the operation. To list or replace the missing families intentionally, use
`type.resolveMissingFonts` on the document.
### Export selected adjustment layers as a .cube LUT
The engine command `file.export.colorLookupTables` creates a 3D LUT from an
identity RGB lattice. It can export all visible top-level adjustment layers
(the default), the current Layers panel selection, or a supplied list of
adjustment layer IDs. The `scope` and `layers` parameters are mutually
exclusive when `scope` is `selected`:
```json
{"size": 33, "scope": "selected", "path": "chosen-look.cube"}
```
For automation that does not use the Layers panel, supply `"layers": [10, 14]`
instead. Input ID order does not change the final color look: adjustments are
always baked in **document stacking order**, bottom to top. When `path` is
omitted, the command returns the .cube text in `cube`; both forms return
`size` and `layerCount`.
Valid LUT dimensions are 2–129 (same maximum as PhotoCraft's LUT reader).
Selected IDs must exist, be visible top-level adjustment layers, and contain
no duplicates; invalid selections report an error rather than silently
exporting a different look. Masks, vector masks and clipping are spatial
operations and cannot be represented faithfully by a color-only LUT, so the
baker omits them. Group-contained adjustments are not exported as independent
layers because that would lose their group's blending semantics.
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
+23
View File
@@ -19,6 +19,23 @@ photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/co
Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries.
## From an installed release
The release packages ship `photocraft-cli` alongside the desktop app, so no build is needed:
| Install | CLI |
|---|---|
| Windows (MSI) | `C:\Program Files\PhotoCraft\photocraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
| Linux (deb, rpm) | `/usr/bin/photocraft-cli` |
| macOS | the separate `photocraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
```sh
# Windows, default install folder
claude mcp add photocraft -- "C:\Program Files\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root <dir> --automation-write-root <dir>
# Linux, or macOS with the CLI unzipped onto PATH
claude mcp add photocraft -- photocraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>
```
## Security notes
Stdio MCP does not open a network listener. Its client receives only the read and write roots
@@ -30,3 +47,9 @@ The current server does not issue scoped sessions or distinguish trusted tools f
Filesystem capabilities are implemented. General tool and method capabilities remain
[proposed](../security/automation-security.md).
The five shared core tools are `command_list`, `command_run`, `command_batch`, `doc_inspect`
and `render_preview`. Existing documented tools, including `doc_render_preview`, remain
listed. `photocraft://document` and `photocraft://commands` expose live JSON. Direct headless
Render Video supports progress and cancellation using the granted write root; see
[`docs/mcp.md`](../../../docs/mcp.md) for arguments, cleanup and transport behavior.
+1 -1
View File
@@ -11,7 +11,7 @@ Automation requests are untrusted input with potential effects beyond the active
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; no explicit JSON-depth policy |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
| Headless preview budgets | **Implemented:** 2048-pixel requested edge, 67,108,864 source pixels, 5 MiB encoded PNG |
+36
View File
@@ -0,0 +1,36 @@
# Affinity documents
PhotoCraft opens native Affinity documents: `.af` files from Affinity 3 and `.afdesign`,
`.afphoto` and `.afpub` files from Affinity 1 and 2. Layers come in as editable PhotoCraft layers:
- pages and artboards become artboards (Publisher spreads sit side by side);
- layers and groups become groups;
- curves and shapes (rectangles, ellipses, polygons, stars and the other geometric shapes) become
shape layers with their fill, gradient and stroke;
- artistic and frame text becomes type layers, drawn with the fonts installed on your computer;
- placed JPEG and PNG images become embedded smart objects, and pixel layers become pixel layers;
- vector and pixel masks, opacity, visibility, locks, names and blend modes are kept.
Artboards are recognized from both the legacy flag and current Affinity 3 artboard properties.
They keep their names, bounds and editable children when saved as `.pcraft` or PSD; moving an
artboard moves its children together. Nested boards become masked groups, and rotated or curved
boards currently use their bounding rectangle.
The document is 8-bit RGB at the Affinity document's resolution. What PhotoCraft can't reproduce is
listed in a warning when the file opens, for example layer effects, adjustment layers and live
filters, brush strokes (drawn as plain strokes), special shapes such as clouds and hearts (drawn as
ellipses), master pages, and CMYK or Lab colour (converted to RGB without the document's profile).
Nothing is dropped without a warning.
PhotoCraft can't write Affinity files. An opened Affinity document has no save path: Save asks for
a new file (`.pcraft`, PSD, PNG…) and never writes over the Affinity file.
If the native data can't be read (a damaged file, or a layout PhotoCraft doesn't know), PhotoCraft
opens the small PNG preview Affinity stores in the file instead, as a single pixel layer named
“Affinity preview”, and the warning says why. That preview can't be placed or used by File ▸ Place,
stacks, batch processing or other imports that can't show the warning. Export PSD or PNG from
Affinity in that case.
See [the crate's notes](https://github.com/storytold/photocraft/blob/main/crates/affinity/README.md)
for the full list of what is imported, how the reader was written and validated, and its safety
limits.
+3 -2
View File
@@ -1,13 +1,14 @@
# Format overview
External bytes enter PhotoCraft through three main paths:
External bytes enter PhotoCraft through four main paths:
- `photocraft-psd` parses and writes PSD and PSB structures.
- `photocraft-codecs` decodes and encodes flat raster formats.
- `photocraft-format` loads and saves the native `.pcraft` bundle.
- `photocraft-affinity` reads native Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): layers, artboards, vectors, text, images and masks; it does not write them.
`photocraft-io` maps PSD and flat images into the document model and exports documents back to those formats. It is the integration layer; parsing limits remain the responsibility of the underlying format crates.
Formats are a primary security boundary. File extension is not a trust signal, metadata can be attacker-controlled, and small compressed inputs can declare very large decoded outputs. Callers should use the bounded default APIs and preserve errors rather than retrying with unlimited limits.
See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), [SVG](svg.md), [Affinity documents](affinity.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
+2
View File
@@ -22,6 +22,8 @@ Default `LoadOptions` apply these maximums:
| One decompressed blob | 1 GiB |
| Total decompressed tiles and blobs | 16 GiB |
Layer groups may nest at most 100 levels (`MAX_GROUP_DEPTH`). Saving a deeper document fails with an error instead of writing a bundle that can't be opened, and a manifest whose JSON nesting goes beyond what that depth needs is rejected before it is parsed, so a hostile file can't exhaust the stack.
The ZIP reader bounds decompression by the caller-supplied maximum, rejects encrypted entries and unsupported compression methods, validates offsets with checked arithmetic, and rejects CRC or declared-size mismatches. Corruption and property tests cover truncation, byte mutation, missing content, invalid manifests, excessive sizes, and random bytes.
These defaults are intentionally finite but can still be expensive on constrained systems. Future hardening should add context-sensitive budgets and explicit limits for entry count, document complexity, and cumulative work, then test directory bundles against symlink and replacement races.
+20 -4
View File
@@ -1,9 +1,25 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## WebP
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
## Exporting documents
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
## Implemented decode limits
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
@@ -12,11 +28,11 @@ Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP,
|---|---:|
| Maximum width | 262,144 pixels |
| Maximum height | 262,144 pixels |
| Maximum pixel count | 268,435,456 pixels |
| Maximum decoded allocation | 2 GiB |
| Maximum pixel count | 1,073,741,824 pixels |
| Maximum decoded allocation | 8 GiB on 64-bit targets; 2 GiB on 32-bit targets (including wasm32) |
Format adapters pass compatible allocation limits into PNG, TIFF, WebP, and `image`-based decoders, while other implementations perform explicit checked dimension/buffer validation. Tests cover bomb-like headers, truncation, bit flips, header overwrites, and random data.
`Limits::none()` exists for callers with a deliberate reason to relax policy. It remains bounded by representable memory sizes, but it removes the default resource policy and should not be used for untrusted input.
Animated containers are represented as a single decoded frame by the current codec API. Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
Animated containers and multi-page TIFFs are represented as a single decoded frame or page by the current codec API; opening one adds an import warning such as "only the first of 3 frames was imported". A JPEG cut off inside its image data opens with a warning that its data ends early (an error when it holds no image data at all). Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
+23
View File
@@ -0,0 +1,23 @@
# SVG
An SVG opens as a document of shape layers, and places (File › Place Embedded / Place Linked) as a vector smart object. Both go through `photocraft-io`'s `svg` module; there is no SVG export.
## Opening
`usvg` parses the file (it is the same crate the UI's icons go through): CSS, `use`, units, nested transforms and `viewBox` are resolved, and text is shaped into outlines with the bundled craft fonts plus, on the desktop, the system's fonts. The resulting tree becomes layers in paint order:
| SVG | Layer |
|---|---|
| `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon` | A shape layer: cubic Bézier knots with the element's transform baked in, fill (colour and opacity, fill rule), stroke (width, colour, caps, joins, miter limit, dashes). |
| `g` with an `id`, opacity or blend mode | A layer group with that opacity and blend mode. Groups that only carry a transform are not layers. |
| `text` | A group of outline shapes named after the text. |
| Linear and radial gradient paints | The layer's own gradient fill with the stops and direction; the exact gradient geometry is not kept (a warning says so). |
| `clip-path`, `mask`, `filter`, pattern paints, `image` | Rasterised by `resvg` into a pixel layer over the element's bounding box, so the picture looks right while those parts are not editable as shapes (a warning names each kind). |
The canvas is the drawing's size in CSS px (from `width`/`height`, else the `viewBox`), RGB 8-bit, transparent where nothing is drawn. A drawing larger than 16 384 px on a side is scaled down to fit. Past 2 000 elements the drawing opens as a single pixel layer rather than thousands of shapes. Malformed SVG is an error, never a panic; `.svgz` is accepted by its extension and plain SVG is also recognised by content when the extension is missing.
## Placing
A placed SVG keeps its bytes as the smart object's source. Whenever the smart object is rendered (at placement, after Free Transform, when a smart filter changes) the drawing is rasterised at the placement's scale and then positioned, so it stays sharp at 1 000 % where a resampled raster would blur. Edit Contents opens the source as the shape-layer document above; saving it back stores the edited document, as for any smart object.
Limits: a single render is capped at 24 million pixels; warped smart objects and stacks render from the drawing's own size.
+4
View File
@@ -2,6 +2,10 @@
PhotoCraft uses the Rust workspace at the repository root. Install the stable Rust toolchain, clone the repository, and run commands from that root.
On Linux, install the windowing, GL and GTK development packages first. The development guide's
[Prerequisites](https://github.com/storytold/photocraft/blob/main/docs/development.md#prerequisites)
list them for Debian/Ubuntu, Fedora, Arch and openSUSE.
```sh
cargo build --workspace
cargo run --release -p photocraft -- image.psd
+1 -1
View File
@@ -1,6 +1,6 @@
# Roadmap
The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity.md`](https://github.com/storytold/photocraft/blob/main/docs/parity.md) by `cargo xtask parity`.
The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity-checklist.md`](https://github.com/storytold/photocraft/blob/main/docs/parity-checklist.md) by `cargo xtask parity`.
Security hardening is cross-cutting rather than a replacement for those product milestones. The current security sequence is:
+28 -5
View File
@@ -7,7 +7,15 @@ Automation is a privilege boundary because requests can cause filesystem access,
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
- Headless TCP refuses a successfully bound non-loopback address.
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
- Encoded request lines are limited to 1 MiB, active TCP connections to 16, and headless/MCP batches to 256 steps.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP, the headless
JSON-lines stdio server (`photocraft-cli serve`), and MCP stdio. The MCP line limit is
enforced before rmcp's line buffer; an oversized MCP line closes that stdio session because
its request ID cannot safely be parsed. Active TCP connections are limited to 16, and
headless/MCP batches to 256 steps.
- Capability-scoped automation file opens read in chunks without reserving from the file's
metadata length. A read rejects data beyond the length observed on the opened file, so growth
after the metadata check cannot expand the read. Large PSB files have no fixed automation file
size ceiling; their materialized bytes and decoded document still require available memory.
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
transport enforces the same request/reply ceilings as TCP.
@@ -16,24 +24,39 @@ Automation is a privilege boundary because requests can cause filesystem access,
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
retains its existing behavior.
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
reply. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
reply. Edits are never automatically resent after a transport failure (#1007); the server's
pure-read tools opt into a single retry on a fresh connection, which applies nothing.
Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
downscaling; the 5 MiB PNG limit applies to the returned image.
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
- The bridge and UI request paths use timeouts.
- Engine commands are expected to reject invalid parameters without panicking.
- Remote filesystem methods use separately granted read and write directory capabilities.
- A save without a `path` (MCP `doc_save`, `serve` `doc.save`, the control channel's `app.save`)
writes back only to the document's own PSD, PSB or `.pcraft` file in its own format; any other
save needs an explicit `path`, so automation never flattens or converts over the file it opened.
- Automation paths must be relative beneath the applicable root. Absolute paths, parent
traversal, alternate separators, Windows device names, malformed components, and link escapes
are rejected before file effects.
- Engine commands that still use ambient paths fail closed at the automation boundary. Synthetic
UI input reaches the same command policy, and automation-triggered file hooks do not run
user-configured script-event paths.
user-configured script-event paths. The policy also covers every command a command runs on its
own behalf (an action's steps, `file.automate.conditionalModeChange` running `image.mode.*`), so
an allowed command can't reach a denied one.
- `prefs.set` applies the engine's dotted-path normalization before checking for filesystem-bearing
preference sections, including paths with leading or repeated dots. It refuses whole-preferences
updates and those sections; individual safe preference keys remain available.
- Applying the Preferences dialog with `ui.dialog.apply` is deliberately refused over automation.
## Known limitations
- no per-client or per-tool capabilities;
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
`prefs.set`;
- no application-specific JSON-depth policy (serde_json's parser has its default finite recursion
limit), aggregate document/session-memory accounting, compositor scratch-space accounting,
or command-duration/cancellation budget;
- one thread per accepted TCP connection;
- no structured security audit event stream;
- desktop screenshot capture/encoding and document import/export have no automation-specific
+1 -1
View File
@@ -14,7 +14,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem
### Native format
`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
`photocraft-format` bounds manifest, blob, and total decompressed bytes; admits the declared canvas (by default at most 300000 pixels per side, the largest canvas `file.new`, Canvas Size and PSB import create, so whatever saves reopens; `LoadOptions::max_canvas_side` / `max_canvas_pixels` let a caller lower it) before decoding anything for it; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
## Review checklist
Loaded 100 of 1232 files, more files were not shown because too many files have changed in this diff. Show more