Compare commits

...
Author SHA1 Message Date
Tanish Manku d9e1c10c30 Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines) 2026-10-15 15:22:09 -07:00
Tanish Manku 4a1acca879 Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute 2026-10-15 15:15:11 -07:00
Tanish Manku a95b3f0a85 Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied 2026-10-15 15:15:11 -07:00
Tanish Manku b7eab1a377 Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal 2026-10-15 15:15:11 -07:00
Tanish Manku 25a8200110 New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order 2026-10-15 15:15:10 -07:00
Tanish Manku 6443f5fb4c Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)
Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.
2026-10-15 15:15:01 -07:00
skylar taylor-barrick af54f4aed1 ci: limit the font pass to font-consuming crates (#6127)
Keep the full workspace and feature build selection while running the craft-fonts pass only for text, io and ui-egui. Ordinary workspace test coverage remains in the first test pass.
2026-10-15 16:03:39 -06:00
skylar taylor-barrick 1a4167936e ci: run tests with cargo nextest (#6118)
Run workspace tests with pinned cargo-nextest, a CI fail-fast profile and a ten-minute per-test hang limit. Keep cheap checks first and doctests in a separate step so failures propagate on Windows.
2026-10-15 16:02:57 -06:00
Tanish Manku 54f4baf6f9 Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names (#5856)
* Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names

With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls
changed only the active layer, silently (the layers audit's top panel gap). They now change
every selected layer, as in Photoshop, in one undo step. layer.setProps takes
"layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a
name for several layers is an error that changes nothing.

History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity
Change, Blending Change, Rename Layer (a mix stays Layer Properties).

* Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes

* Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856)

* Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856)
2026-10-15 14:33:51 -07:00
Tanish Manku 3cd0f6c954 Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) (#5349)
* Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a)

A key that another command holds now offers Photoshop's two answers under the warning.
Accept and Go to Conflict takes the key from the other command at once (its main key or
an alternate) and selects it for a new one. Undo Changes puts the edited command's main and
further keys back as they were. OK without either still accepts, as in Photoshop.

Keys the system takes before the app sees them are refused with a message: Alt+Tab,
Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and
Cmd+Opt+Esc on it.

* Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text
2026-10-15 14:18:14 -07:00
Tanish Manku 04db697b5d Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) 2026-10-15 14:17:22 -07:00
Tanish Manku c11ef015b7 Paste Layer Style carries Blend If and channels; align to an active selection with several layers (#5881)
* Paste Layer Style carries Blend If and channels; align to an active selection with several layers

From the layers audit (L2):
- Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but
  dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its
  Blending Options. Across colour modes only Blend If's Gray range carries over and the
  channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named
  struct (CopiedStyle) instead of a tuple.
- With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to
  their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to
  Selection), as it already did for one layer.

* Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests
2026-10-15 13:10:28 -07:00
Tanish Manku 77a258b9a0 Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles (#5868)
* Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles

From the layers audit (L2, silently wrong results):
- Release Clipping Mask on a clipped layer also releases the clipped layers above it, as
  Adobe documents ("removes the selected layer and any layers above it from the clipping
  mask"). Before, they stayed clipped to the released layer.
- Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a
  layer still clipped to their base (it was always unclipped). Merge Down's result also keeps
  the lower layer's colour label and link; Merge Layers' keeps the top layer's label.
- Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule
  3, as single Delete already does), instead of leaving them clipped to the layer below.

* Merge Layers keeps the result clipped only when the clipped layers share one clip chain

* fmt
2026-10-15 13:10:22 -07:00
e4b2868a02 Check deep EXR tile count tables against max_alloc (#5632)
* Check deep EXR tile count tables against max_alloc

A deep tile's sample-count table was decompressed at the header's full tile size with no
budget check, so a 20 KB file could build a 4 MiB table and a scratch copy under a 1 MiB
max_alloc. Try that size only when max_alloc has room for both; otherwise read the clipped size.

Fixes #4958.

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

* Keep deep EXR decompression within the size it was admitted at

The count-table check admits the table and its scratch copy, 2x the table, but Vec doubling
gave a 4 MiB ZIPS table 8 MiB of capacity (a 3 MiB RLE table 4 MiB), so an admitted table
and its copy could take 3x. Grow the decompressed buffer by doubling capped at the expected
size, inflating straight into it, and allocate it and the scratch copy fallibly.

Tests: decompressed buffers never reserve past the expected size; a near-maximal tile with a
clipped table decodes under every budget; per-tile admission; half and uint channels after
full-size tables; the 8 MiB boundary for uncompressed tables too.

Refs #4958.

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

* Tests: show deep tile tables are admitted one tile at a time

The 17x17 image in 16x16 tiles had an interior tile, which never reaches the max_alloc check,
so only three edge tables (6 KiB) were checked against the 8000-byte budget and a check that
summed admissions across tiles still passed. Use a 48x1 image instead: its three tiles are all
edge tiles, and under 4096 bytes a summed check fails at the third tile.

Refs #4958.

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

* Complete deep EXR count-table allocation admission

Account for scratch space on clipped and interior compressed tables while preserving raw-table budgets. Borrow packed input before admission and make initial RLE reservations and raw copies fallible.

Add single-channel admission boundaries, raw fallback controls, and hostile-size borrowed-read regressions.

* ci: run deep EXR regressions independently

* Drop the separate deep EXR CI job

It was added so these tests could run while main's workspace suite was
failing. Main is green again, and the regular test jobs run them.

This reverts commit 5aa4869b45.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: jonathnwxh <[email protected]>
2026-10-15 13:40:50 -06:00
Michael HigginsandClaude Opus 5.5 64520f645e Control channel: connections must authenticate within 10 s, however active (#4965) (#5229)
The only limit on an unauthenticated control connection was the 30 s
idle timeout, which starts again with every read. A local client that
kept sending bytes or blank lines without ever completing `auth` kept
its connection, and one of the 16 connection permits, for as long as it
liked; sixteen of them locked every other client out.

AuthDeadlineStream wraps the read half of a connection: until the
connection authenticates, reads fail once AUTH_DEADLINE (10 s) has
passed since it connected, and a blocking read waits no longer than
what is left. Both transports use it (the desktop app's control server
and the headless `photocraft-cli serve`). After `auth` succeeds only
the idle timeout applies, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:39:19 -06:00
Michael HigginsandClaude Opus 5.5 b24c0360f1 Dependencies: require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#4952) (#5225)
ruzstd before 0.9.1 lets a crafted zstd input under 128 KB allocate
several gigabytes at once. PhotoCraft decodes zstd when it opens .pcraft
and Affinity files, so such a file could end the process with an
out-of-memory abort.

Raise the minimum in both crates that use it and update the lockfile
from 0.9.0 to 0.9.1.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:37:56 -06:00
Khawar Zamman WaniandClaude Opus 5.5 1b666f4790 Pen: Auto Add/Delete on the path being drawn (#6140) (#6172)
While a path was being drawn, the Pen's Auto Add/Delete was off, so adding
a point meant finishing the path and switching to Add Anchor Point. Now a
click on a segment of the path in progress adds an anchor, a click on a
middle anchor deletes it, and drawing carries on from the last anchor. The
first anchor still closes the path and the last still breaks its handle.

The hover highlight draws over the path in progress, and the rubber band
hides where a click adds or deletes instead of placing an anchor.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:12:35 -06:00
ZuujisandClaude Opus 5.5 99083fca4c Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095) (#6108)
* Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095)

Image › Adjustments on an alpha channel or the Quick Mask ran on the surface's
stored content only. The black outside a selection-made Quick Mask (or a new
black alpha channel) is the surface default, never stored, so Invert left it
black: the whole mask turned red and leaving Quick Mask selected nothing.
Channel targets now go through `pixels::adjust_mask`, as layer masks do, which
adjusts the default pixel too.

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

* Quick Mask: test ⌘I with Color Indicates Selected Areas too (#6095)

A rectangle comes back as everything but the rectangle, and with nothing
selected ⌘I selects everything. Both failed on main.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:11:42 -06:00
skylartaylor d68a1cbc2d ci: preserve separate nextest and doctest results 2026-10-15 19:56:43 +02:00
skylartaylor bcf2cefd4b ci: preserve nextest failures on Windows 2026-10-15 19:56:43 +02:00
skylartaylor 4f6e617555 ci: retain the font filter with integrated nextest checks 2026-10-15 19:44:21 +02:00
skylartaylor 40a7c20430 ci: integrate nextest with early checks and runtime limits 2026-10-15 19:44:21 +02:00
skylar taylor-barrick 5f9190f8cc ci: run cheap checks before tests (#6124)
Run the existing Clippy, dependency layering and scorecard checks before the test passes so failures are reported earlier.

Preserve the existing commands, test coverage and runtime limits. Linux, Windows, corpus, WASM, web and packaging checks passed.
2026-10-15 11:42:53 -06:00
skylar taylor-barrick d2fd1a012a ci: add comfortable runtime limits to unbounded jobs (#5931)
Add explicit runtime limits to eight previously unbounded CI, documentation and packaging jobs, preserving existing limits and quality gates.

Validated the timeout-only diff and completed Linux, Windows, corpus, WASM, web, documentation and packaging checks.
2026-10-15 11:26:52 -06:00
midasmatterhorn c06eca9b68 fix(psd): preserve PSB foreign-order block lengths
Choose PSB block length width from the canonical big-endian key before converting the key for little-endian TIFF output. Preserve foreign payloads, padding and conversion of following blocks.

Addresses #5599, finding 2 only.

Fixes #6104.
2026-10-15 11:23:57 -06:00
skylar taylor-barrick f9ed9e3e57 ci: refresh timeout draft against current main 2026-10-15 16:27:05 +02:00
skylar taylor-barrick 4e7a17cd5d fix(compose): serialize effect identity cache misses
Keep effect-identity memo lookup, pure hashing and insertion atomic across concurrent render callers. Preserve existing identities and add a contention regression with eight synchronized callers.
2026-10-15 08:18:21 -06:00
David MannandClaude Opus 5.5 caa28cb7ab CI: run only the font tests in the craft-fonts pass (#6120)
The pass reran every test in the workspace, but CRAFT_FONTS is only read in photocraft-text,
photocraft-io (svg) and photocraft-ui-egui. The build selection stays the same (the disk warning
above still applies); a nextest filter runs those three packages' tests: ~3,800 of ~8,900.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:41:12 +02:00
David MannandClaude Opus 5.5 e22c4ec7ab CI: run the tests with cargo nextest (#6117)
cargo test runs the ~258 test binaries one at a time; nextest runs them in parallel. Its new `ci`
profile stops at the first failure (a red PR frees its runner early), fails a test that hangs for
10 minutes instead of letting it hold the runner, and repeats every failure at the end of the log;
the GPU test group still applies. Same --workspace --locked --features heif selection, so the build
is unchanged. Doctests stay on cargo test --doc.

postscript_only_style_shows_actual_subfamily relied on another test in the same process having
started the font scan; under nextest it runs alone, so it starts the scan itself (as #2933 does).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:40:15 +02:00
David MannandClaude Opus 5.5 9660dca263 CI: run clippy, layers and scorecard checks before the tests (#6119)
They take about 1.5 min together but ran after all the test passes, so a clippy failure (15 s)
showed red only 41-48 min into the job. Same steps, reordered: no cost on a green run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:22:13 +02:00
df055f3dcb CI: fix the tests that keep main red, and ship the wasm as compressed parts (#5926, #5302) (#5941)
* Fix two ui-egui tests broken on main by concurrent landings

- profile_open_tests: #5614 renamed the built-in profile to "(PhotoCraft)" after #5208
  hard-coded "(Photocraft)" in the Use-embedded button label.
- smart_object_curves_preview_keeps_its_size_on_a_reduced_proxy (#5779): the reduced
  square is in the right place, but its corner pixels are 220/255 after the smart
  object's resampling; assert full coverage inside and over half at the corners.

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

* Web: ship the wasm as compressed parts behind a boot module (#5302)

The release site held the app's wasm as one file, which sits at the 25 MiB
per-file cap of Cloudflare Pages, so the 24 MiB gate failed unrelated PRs.

packaging/web/package.sh now gzips the wasm and cuts it into parts of at
most 20 MiB under app/, and ships the page of a new crate,
apps/photocraft-web-boot. That module fetches the parts in order, has the
browser inflate them (DecompressionStream) and passes the stream to the
app's own wasm-bindgen loader, so the browser still compiles while it
downloads. A failed download is retried twice, then the page says so.

The check is now that no file in the site is over 24 MiB. trunk build in
apps/photocraft-web and the Docker image still give one .wasm.

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

* Web boot: inflate the parts in the module where the browser can't (#5302)

* Tests: the adjustment-layer drag preview test follows #5778 (#5926)

#5778 caps a live adjustment-layer preview by the view's zoom, so at 100 %
the drag shows full resolution even with Low Resolution Previews on. The
test still expected the reduced copy there. It now checks that at 100 % every
column shows by default, and moves the low-res on/off comparison to 50 %,
where the reduced copy is still used: on, the drag shows only the (inverted)
black columns; off, it matches the committed view.

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-15 09:21:30 +02:00
ad96df566b File > Save As / Save a Copy / CLI can write PDF (#3401) (#3441)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* PSD: a smart object's filter mask shows when the file opens

Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.

- compose::filter_mask holds the mix, moved from the engine, and its
  inverse.
- Import decodes the unfiltered pixels from FEid
  (smart_map::unfiltered_from_item) and keeps the mixed result as the
  smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
  the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
  back to what the document showed. Transparent pixels stay empty.

Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.

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

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* File > Save As / Save a Copy / CLI can write PDF: one flattened page with the embedded profile (#3401)

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

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

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

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

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

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

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: timothydaly1 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 01:03:27 -04:00
4e1e325b17 PSD: a smart object's filter mask shows when the file opens (#5005)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* PSD: a smart object's filter mask shows when the file opens

Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.

- compose::filter_mask holds the mix, moved from the engine, and its
  inverse.
- Import decodes the unfiltered pixels from FEid
  (smart_map::unfiltered_from_item) and keeps the mixed result as the
  smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
  the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
  back to what the document showed. Transparent pixels stay empty.

Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.

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

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

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

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

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

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

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

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:58:45 -04:00
8a6af66cf6 DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11) (#5112)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

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

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

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

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

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

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:54:55 -04:00
a7301f1db8 PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198) (#5207)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

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

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:51:37 -04:00
1a4f867958 TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab (#4949)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:48:20 -04:00
47cf5af9fd perf: keep effect-cache pins and History snapshots inside the memory budgets (#5336)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:44:55 -04:00
c7d9ade95b Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open (#4462)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:40:46 -04:00
cb8ed7620e Content-Aware Fill: no ghost edges or object colour around thin removed objects (#5290)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[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-15 00:36:50 -04:00
4446ec6d65 PSD: write tiff blocks without copies, inflate merged ZIP once (#5221)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[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-15 00:32:55 -04:00
5dafbeeafc Codecs: open Cineon (.cin) film frames read-only (#4691)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* cargo fmt the extension lists

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-15 00:23:03 -04:00
Vikram BhaskaranandBrandon Thomas 7edc7aa03d Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) (#4323)
Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-15 00:22:48 -04:00
17c85e4175 fix(ui): brush outline matches the stroke on Wayland at fractional scales (#5796)
* fix(ui): brush outline matches the stroke on Wayland at fractional scales

On Hyprland at 1.25 the brush outline was 1.25x too large and sat right of
and below where the brush painted, by more the larger the brush. The engine
places dabs exactly; the OS cursor bitmap was wrong. winit 0.30 sizes custom
cursor bitmaps on Wayland with a wp_viewport destination whenever the scale
isn't 1, and Hyprland (like Mutter before GNOME 48) ignores viewports on
cursor surfaces: it shows the bitmap one pixel per logical pixel with the
hotspot winit divided by the scale.

A client can't tell which compositors do this, so on Wayland at any scale
but 1 the brush outline and crosshairs are painted in-app, as on the web.
Other platforms and Wayland at scale 1 keep the low-latency OS bitmap.

Measured on Hyprland 0.56.2 at 1.25 (100 % zoom, 100 px brush): dab centre
(288.5, 560.5), diameter 97 px; outline before (303, 574.5), ~130 px;
after (288.5, 562), 107 px including its border.

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

* Merge main; 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-14 23:50:19 -04:00
96c683b25d Info panel: show guide coordinates while dragging outside the canvas (#5728) (#5768)
* Fix Info panel guide coordinates during ruler drags (storytold/photocraft#5728).

Show the dragged guide position on one axis while the pointer is outside the canvas, matching Photoshop.

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

* Merge main; fill GuideDrag's new press field in the test; cargo fmt

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-14 23:50:14 -04:00
FredApps be32a7c651 fix: commit type editing on Layers-panel selection (#5584) (#5612) 2026-10-14 23:50:08 -04:00
David MannandClaude Opus 5.5 8987980d16 Dialogs: a close box in the title bar cancels like Cancel (#5496) (#5505)
Dialogs drawn by the shared renderer had no close box: the only ways
out were Cancel/OK or Esc. As in Photoshop on Windows, the title band now
ends in a × (the icon button the floating panels close with) on Windows,
Linux and the web, none on macOS. A click on it joins Esc in the single
cancel path of `dialogs::show` (outcome = Cancel -> `cancel()`), so the
live preview goes and nothing reaches the document or history. The × is
left out of the title band's drag area, so a drag starting on it neither
moves nor closes the dialog; the rest of the band still drags.

Check Spelling (an untitled window) gets the same close box; it is Done,
as every change there is already made.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:50:03 -04:00
Valentin KleinandClaude Opus 5.5 94b1f0a4cb Warp: a thin layer stretched and bent keeps its curve on commit (#5793) (#5810)
* Warp: a thin layer stretched and bent keeps its curve on commit (#5793)

The mesh warp sized its triangle grid by the source rectangle only (one cell per 4 source px). A 3 px column scaled into a wide band and then warped got a single cell across, so the committed result was a straight, affine band while the preview showed the curve. The grid now also follows the destination length of the mapped rows and columns.

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

* Warp: module doc describes the grid sized on both sides of the map (#5793)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:58 -04:00
bazandClaude Opus 5.5 38b96f273a Move: Show Transform Controls frames every selected layer (#5917)
As in Photoshop, with several layers selected (Shift-click with Auto-Select,
or the Move marquee) Show Transform Controls draws one frame around all of
them (the Background excluded) and outlines every selected shape layer.
Before, the frame showed only the active layer, so the other selected layers
had no feedback on the canvas. The frame joins the boxes Free Transform would
open on each layer (transform_frame), and it hides while a corner radius
widget is dragged.

Free Transform works on one layer, so the shared frame has no handles and a
press on it doesn't start Free Transform; a drag inside still moves every
selected layer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:52 -04:00
WendelVolm 0c7308adc5 Snapping: snap a drawn marquee, Object Selection box or shape by its edges while Space repositions it (#5748) 2026-10-14 23:49:47 -04:00
Michael HigginsandClaude Opus 5.5 0c5709e9bd Free Transform: shapes and paths stay on whole pixels (#5751) (#5841)
* Free Transform: shapes and paths stay on whole pixels (#5751)

With Snap Vector Tools and Transforms to Pixel Grid on, only the pointer was
rounded, and only while a shape tool was active. Under the Move tool, or with
a grab offset or a proportional scale, the box landed between pixels (a
rectangle dragged to 540 px came out 539.6). An upright box on a shape layer
or path now keeps whole-pixel edges whatever the tool.

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

* Format (cargo fmt)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:42 -04:00
Michael HigginsandClaude Opus 5.5 6729fa1e50 Layer Style: drag on the image to move a drop or inner shadow (#5701) (#5893)
As in Photoshop, with the Layer Style dialog on a Drop Shadow or Inner Shadow
page, dragging on the image moves the shadow: its offset follows the pointer
and is stored as the effect's angle and distance (whole degrees and pixels),
moving the shared global light angle when the effect uses it. Space and the
middle button still pan; colour pickers still sample.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:36 -04:00
Michael HigginsandClaude Opus 5.5 56d3b7f983 Pen and Path Selection: Delete deletes the finished work path (#5702) (#5732)
A finished work path is drawn whenever the Pen, Freeform Pen or Path
Selection tool is active, and it is saved with the document, as in
Photoshop. But once the path was finished, Delete/Backspace fell through
to Edit > Clear (the pixels under it), so the only ways to remove it were
the right-click Delete Path and the Paths panel, and it looked impossible
to delete.

With those tools, no path being drawn and the work path the one they
edit (no shape layer or targeted vector mask in front of it), an
unmodified Delete/Backspace now runs path.delete on the work path: one
undoable Delete Path step. Direct Selection and the Curvature Pen are
left alone, since Photoshop deletes their selected anchors, not the path.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:30 -04:00
David MannandClaude Opus 5.5 8226139806 Image Size, Canvas Size: points and picas follow the Point/Pica Size preference (#5756) (#5757)
The dialogs converted points and picas at a fixed 72 pt/in, while the
rulers, Info, layer properties and the shape dialog use Preferences >
Units & Rulers > Point/Pica Size. With Traditional (72.27) a document
showed different point sizes in the rulers and in Image Size.

The dialog now stores the preference's points per inch when it opens
(__ptPerIn, 72 when missing) and uses it for points, picas (points / 12)
and typed units in the Width/Height fields.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:49:25 -04:00
David MannandClaude Opus 5.5 b404d50581 Type: Swap and Default Colors recolour the selected text (#5690) (#5808)
The toolbar's Switch Colors and Default Colors buttons ran their commands
without type_tool::foreground_changed, so characters highlighted in the
type layer being edited kept their colour. As in Photoshop, they now take
the new foreground, like picking a colour in the Color or Swatches panel.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:49:20 -04:00
envy-ai e47086717a Channels: load the targeted layer mask as selection (#5562)
* Load the targeted layer mask from Channels footer

* Fix Channels test setup after mask reveal
2026-10-14 23:49:15 -04:00
Vikram Bhaskaran f02c0cbef7 File › Scripts › Browse: reject Photoshop ExtendScript (#5406) (#5435)
* File › Scripts › Browse rejects Photoshop ExtendScript.

The picker listed .js/.jsx files that parse_script cannot run. Filter to
JSON/text/droplet scripts and return a clear error for .js/.jsx paths.

* File › Scripts › Browse keeps Photoshop extensions in the picker.

Review feedback on #5435: filtering .js/.jsx out of the picker hid the
rejection error from UI users. List Photoshop's script types (as #2452
asked) alongside PhotoCraft's so selecting one shows the ExtendScript
error instead of hiding the file.
2026-10-14 23:49:09 -04:00
safvanedgeandsafvanedge cc9967654c fix(ui): restore previous tool when committing or canceling text edit (#5527) (#5528)
* fix(ui): restore previous tool when committing or canceling text edit initiated via double-click

* fix(ui): restore return tool only while on type and stay on type for layer thumbs

- commit_with/cancel: restore return tool only while app.ui.tool.is_type()
- edit_active: keep tool on Type when editing from layer thumbnail (matches Photoshop)
- clear text_edit_return_tool when text_edit is dropped on lost text or stale transform
- test tool switch mid-edit and edit_active remaining on Type

---------

Co-authored-by: safvanedge <[email protected]>
2026-10-14 23:48:55 -04:00
David MannandClaude Opus 5.5 8d8db22c94 Type: a scaled type layer keeps its size when the Character fields show it (#5717) (#5718)
egui's DragValue clamps an existing value to its range and reports a change, so showing a
type layer scaled past 1296 pt (or below 0.1 pt) sent type.setStyle with the clamped size.
Value fields now clamp only edits, never a value they merely show.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:48:48 -04:00
Stevan MarjanovićandStevan Marjanović 49f878d91b Scaling around reference point no longer snaps center of image to reference point (#5510)
Co-authored-by: Stevan Marjanović <[email protected]>
2026-10-14 23:48:43 -04:00
Michael HigginsandClaude Opus 5.5 3fd787a2b5 Canvas: no scroll glide with Enable Animations off (#5721) (#5883)
Interface > Enable Animations off already made scroll areas jump straight
to their target, but the canvas still panned from egui's smoothed wheel
delta, gliding for several frames after each notch. With the preference off
a plain wheel scroll now moves the canvas by the whole notch in the frame it
arrives and stops there.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:37 -04:00
midasmatterhorn f79f21931e fix(ui): smart filter adjustments open their own editor on double-click (#5812)
An Image > Adjustments recorded on a smart object (Brightness/Contrast,
Levels, Curves...) stores image.adjustments.<kind>. The Layers panel row
called smart_ui::open_editor, which only knew filter_dialog and silently did
nothing for those commands, so the filter could never be reopened. Route
adjustments to adjust_dialog, seed it with the recorded params and let OK run
layer.smartFilter.setParams (#5689).
2026-10-14 23:48:32 -04:00
Michael HigginsandClaude Opus 5.5 f44d3b6f06 Brushes: dragging a preset to the list's edge scrolls the list (#5741) (#5862)
A preset, group or folder dragged in the Brushes panel or the Brush Preset
picker couldn't reach rows out of view: the list didn't scroll while
dragging, so the drag stopped at the last visible row. Holding the drag at
the list's top or bottom edge (or past it) now scrolls it, with the same
ramp as the Layers panel's row drags.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:26 -04:00
Michael HigginsandClaude Opus 5.5 b8e48d6d4c Filter dialogs: a Random button for seeds (#5710) (#5886)
Clouds, Difference Clouds, Add Noise, Fibers and the other seeded filters
exposed a seed number, but a new pattern meant typing a different one. A
Random button beside every seed field now picks a fresh seed, which the live
preview renders at once. "Random" gains its row in the zh-Hans, zh-Hant, ru,
fr and id catalogs, which claim complete coverage.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:20 -04:00
fd28da56da Type: keep text selection highlight visible while Command/Ctrl is held (#5542)
* Type: keep text selection highlight visible while Command/Ctrl is held

* Type: match Photoshop 1px dark frame and handle styling for type transform

* Merge main; keep the transform box and underline colours from the theme tokens

The selection-highlight fix stands on its own; the hard-coded box and
underline colours and the removed reference point were unrelated to it.

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

---------

Co-authored-by: safvanedge <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:46 -04:00
7efebfa050 fix(path): Shift locks a Direct Selection anchor drag to 45° (#5475) (#5498)
Dragging selected anchors with the Direct Selection tool ignored Shift,
so lengthening a slanted bar also nudged it vertically. As in Photoshop,
Shift now locks the anchor move to multiples of 45° from the press, in
the live preview and in the committed path.moveAnchors. It works whether
Shift is held from the press or added during the drag; Shift at the
press still toggles the anchor's selection as before.

Reuses stroke_constraint::snap45 (the Gradient tool's Shift lock).

Fixes #5475

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 22:46:41 -04:00
Michael HigginsandClaude Opus 5.5 3887d8f738 Title bar: dialogs no longer stop the window dragging on macOS (#5826) (#5860)
The overlay that keeps the title strip's free gap draggable under a dialog's
modal layer (#4913) only ran with the custom title bar. macOS keeps the OS
traffic lights over the app's own title strip (custom_titlebar is false), so
with the Embedded Profile Mismatch prompt or any dialog open the window could
not be moved. The overlay now runs whenever the strip is drawn.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:36 -04:00
Michael HigginsandClaude Opus 5.5 8e2c5ddb40 macOS: a bundled app keeps its own Dock icon (#5849) (#5879)
The main window set a runtime PNG icon everywhere. On macOS that replaces the
app bundle's PhotoCraft.icns in the Dock, so the system couldn't render it
in its own style (Liquid Glass and its tinted and dark variants). The runtime
icon is now skipped when running from an .app bundle; unbundled runs and the
Windows and Linux builds still set it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:30 -04:00
nrq6993-a11yandCursor b6cdb16caf UI: unsaved-changes buttons show every key in one style (storytold/photocraft#5564) (#5603)
German showed "Ja (Y)" next to "(N)ein". Keys now go inside the words only when
every translated label starts with its key; otherwise all labels append it
("Ja (Y)", "Nein (N)", "Abbrechen (C)").

Co-authored-by: Cursor <[email protected]>
2026-10-14 22:46:25 -04:00
Michael HigginsandClaude Opus 5.5 3aaffdde68 Adjustment layers: a live preview is never coarser than the screen (#5764) (#5778)
Dragging a Curves point (or any control) in the Properties panel of an
adjustment layer previews through `canvas::ensure_proxy_preview`, which
picked its downscale factor from the document size alone
(`proxy::preview_factor`): any document over 5 MP was drawn from a 2-5x
reduced copy at every zoom, so at 100 % the canvas turned blocky until
the mouse was released.

Cap the factor by the view's zoom with the rule the Image > Adjustments
dialogs already use (`adjust_preview::proxy_factor`: the proxy stays at
least at screen resolution) when the wgpu compositor can draw the
full-size document. At 50 % and closer the drag now shows the full-size
composite; zoomed out, the proxy still saves the work. Documents the GPU
compositor doesn't cover keep the old proxy (the full size would be a
CPU composite per change).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:20 -04:00
Michael HigginsandClaude Opus 5.5 be4cadd83d Rulers: Alt-drag makes a guide of the other orientation (#5698) (#5885)
As in Photoshop, dragging a guide out of the top ruler with Alt/Option held
makes a vertical guide, and out of the left ruler a horizontal one; without
Alt each ruler makes its own kind, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:15 -04:00
FredApps 37f57f2e9c Preserve Layers scroll per document (#5522) (#5540)
* chore: reserve draft implementation for #5522

* fix(ui): retain each document's Layers scroll position (#5522)
2026-10-14 22:46:09 -04:00
Valentin KleinandClaude Opus 5.5 2fbd171427 Options bar: Show Transform Controls in every theme (#5740) (#5802)
The Studio, Adwaita and Breeze themes' Move tool options bar had only Auto-Select and a hint, so Show Transform Controls could not be turned on there and a click on a layer never showed its box and handles. The toggle is now in every theme, as Auto-Select is (#1275).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:04 -04:00
Michael HigginsandClaude Opus 5.5 f4351cb37c Drag and drop: PhotoCraft comes to the front after a drop (#5687) (#5894)
Files dropped from another app's window (a file manager) were imported, but
the source window stayed in front of PhotoCraft. The frame that takes a drop
now asks for focus, so PhotoCraft comes forward as Photoshop does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:45:59 -04:00
Nigel Ihza Firdausy 9b59a8ca6e fix(pen): keep unfinished paths in their original document (#5460)
* fix(pen): finish draft before creating a document

* test(pen): keep open work path in its source document
2026-10-14 22:45:45 -04:00
midasmatterhorn 88c7286d99 fix(ui): the Move tool keeps Photoshop's arrow pointer (#5814)
The canvas cursor match mapped Tool::Move to CursorIcon::Move, the 4-way
move cursor, while Photoshop shows its standard arrow pointer and marks the
movable layer with the hover outline. Use CursorIcon::Default for the tool
(#5646).
2026-10-14 22:45:39 -04:00
Stevan MarjanovićandStevan Marjanović 2b934aa53a UI: centre text vertically in shared text fields (#5550)
* UI: centre text vertically in shared text fields

TextEdit draws its line at the top of its rect, and the theme's 24 pt
interact height made the edit taller than the field's inside, so text in
widgets::text_field sat a few pixels high (Preferences search, hex fields,
name fields) while dropdowns and value fields were centred. Fill the box
and centre the line in it.

* Fixed failing test

---------

Co-authored-by: Stevan Marjanović <[email protected]>
2026-10-14 22:45:34 -04:00
Bihacsy László 414ae0b2c2 i18n(hu): align six rows with the catalog conventions (#5434)
Three prompts used the formal verb form ("Adjon", "Válasszon",
"kattintson"); the catalog addresses the user informally throughout.

Two colour profile messages put an article before a placeholder
("A(z) „{name}”") and glued a suffix to one ("{mode}-profilja"). The
correct form depends on the inserted value, so the placeholders now
stand after a colon or in brackets.

The Ask On Mismatch hint kept the English option name; it now names
the Hungarian label of that option in Color Settings.
2026-10-14 22:45:29 -04:00
skylartaylor b2fafcf68c ci: add comfortable runtime limits to unbounded jobs 2026-10-15 04:27:54 +02:00
FredAppsandFredApps cb319fd742 Document layerFilter in the ui_set MCP schema (#5776)
Co-authored-by: FredApps <[email protected]>
2026-10-14 21:54:48 -04:00
Tanish Manku 7bb10a7759 style: rustfmt five files on main (fmt break from #5448, #5670, #5135) (#5873)
Formatting only; cargo fmt --all -- --check passes again.
2026-10-14 16:11:18 -07:00
wwew 98fc6ab072 fix(cms): write the product name as PhotoCraft in built-in profile descriptions (#5614) 2026-10-14 15:06:35 -07:00
Ikki Nishiharaandharanishi 42fb79e909 fix(ui): widen tool-options drag targets (#5135)
Co-authored-by: haranishi <[email protected]>
2026-10-14 15:06:22 -07:00
Ahmed0_0Mamdouh 1f1e8d734f Fix #5414: Keep created paths visible and editable (#5424)
* Paths: keep work and shape paths visible with any tool (#5414)

* Tests: improve path overlay regression coverage
2026-10-14 15:06:13 -07:00
agent-346022andagent-346022 602344bae0 Fix filter preview live during drag (#5549)
* Filter dialogs: paint the preview computed mid-drag instead of waiting for the pointer to stop

A preview that finished while the slider was still moving was thrown away before it
could reach the canvas, because the worker result had to match the request on every
field including the parameters. Every completion of a drag lands after the value has
moved on, so none of them matched, the worker was handed the newest value instead, and
that one went stale in turn. Nothing was ever painted while the pointer moved: the
effect appeared only once the slider had been still for about a frame of preview
time, which is what "I have to stop the slider and wait" looks like.

The display gate a few lines below already had the opposite rule. It compared the
document, revision, dialog, layer, channel target, command and proxy factor and
deliberately left the parameters out, so that a slightly old frame stays on screen
mid-drag. The publish gate disagreed with it and won, so the frame the display gate
was written to keep never existed.

Publish a successful result when the finished request matches the current one on
everything except the parameters, and let the existing display gate decide whether to
keep it. The two gates now share one predicate, FilterPreviewKey::same_target, so
they cannot drift apart again.

Errors keep the exact match. A value the pointer has already dragged past should not
raise a notice, and a failure is not something to show stale.

What this deliberately does not change:

- A reopened dialog has a new id, a committed document a new revision, a different
  command or channel target a different key, so a result from another preview is still
  never painted. Covered by a_reopened_dialog_discards_the_previous_dialogs_finished_preview.
- The freshly computed request is still what decides whether a new computation starts,
  so the worker still converges on the value the dialog actually holds.
- One preview runs at a time and a stale one is still cancelled only once the values
  settle, so this adds no work to the worker; it only stops discarding completed work.
- OK still recomputes at full resolution, so a frame shown mid-drag is never what gets
  written to the document.

Test: a_preview_computed_mid_drag_reaches_the_canvas starts a blur at one radius, moves
the slider before that preview lands, and requires the completed frame to reach the
canvas while the slider is still on the new value. It fails on the old gate, timing out
after 10 s with no frame published.

* Filter dialogs: three guards on a mid-drag preview, after review

The first version of this let any completed frame through that matched the dialog
on everything but the parameters. Review found three ways that is wrong, and all
three are now closed and pinned by a test.

A stale result with nothing to show. Storing it replaces a good frame with an empty
one, which the display gate paints as "draw nothing": the canvas blinks back to the
unfiltered layer mid-drag. The exact request may still store an empty result, which
is how a filter that no longer applies clears the canvas, so the guard belongs on
the stale arm only. Splitting the match arm is what makes that separable; the
earlier single arm forced a false choice between the two.

A late frame replacing an exact one. Drag the slider away and straight back: the
frame on screen is already exact for the dialog, and the in-flight frame for the
value on the way out would have overwritten it, showing a blur the user has already
undone. A stale result is now dropped when the stored preview is already exact.

Errors were already scoped to the exact request and stay there: a value the pointer
has dragged past should not raise a notice, and the value it comes to rest on will
raise its own when it is recomputed.

Also pinned by test, because both are easy to undo by accident:

- The reopen test was not reaching the gate. discard_closed reclaims a closed
  dialog's job a frame earlier in the real app, and the test called it too, so the
  stale result never arrived and the test passed even with the dialog id removed
  from the comparison. It now drives the gate directly. Verified by mutation.
- The mid-drag test asserted nothing about what the display gate does with the
  published frame, so re-adding params there passed. It now asserts the canvas
  actually returns something to draw, and it keeps polling until a frame for the
  dialog's current value lands, which is what pins fresh to an exact match.

Tests are deterministic: the worker job is gated on a channel and signals when its
result is already in the worker channel, so nothing sleeps and nothing races the
150 ms settle timer that cancels a job whose values stopped changing.

Verified by mutating each guard and confirming a test catches it:
  publish gate back to exact-match ................. killed
  drop the is_some guard ........................... killed
  drop the already-exact guard .................... killed
  same_target ignores the dialog id ............... killed
  display gate compares params again .............. killed
  fresh relaxed to same_target .................... killed
  same_target ignores k ........................... killed

k stays in the comparison on purpose. A 36 MP document previews at k=4, and
preview_factor lowers k as a pixel-sized parameter approaches its minimum, so k does
change under the slider. Relaxing it would paint a frame at a scale the preview
policy exists to avoid, and would loosen the display gate further than upstream
wrote it. The cost is a one-computation pause when the slider crosses a threshold
on a large document; that test keeps the choice visible.

* Filter dialogs: fix a race in the new tests that let CI pass them

The four new tests signalled each other through a channel the worker sends from inside
`compute`. But the worker only puts the result into the channel after `compute` returns:

    let result = compute(&work);
    let _ = tx.send(result);

So a test that woke on the signal and called `ensure_filter_preview` immediately could
reach `poll()` before that send. On a loaded CI runner it did: ubuntu failed
`a_mid_drag_empty_result_leaves_the_preview_alone` with no frame published at all. The
comment in the test claimed the result was already in the channel, which was the one
thing it was not.

The tests now drive the canvas until the state they need appears, which does not depend
on the order of that send. They also check their invariant on every pass rather than
only at the end: a wrongly published frame can be replaced by the next, correct one
before the loop stops, which had already left the reopen, late-frame and empty-result
tests unable to see their own mutation.

Unchanged: the assertions, the production code, and the four mutations that broke each
guard — re-verified, 7 of 7 still caught.

Runs: 30 back-to-back runs of the four tests on 12 cores, no failure; the full crate
twice, 2800 pass.

---------

Co-authored-by: agent-346022 <[email protected]>
2026-10-14 15:06:04 -07:00
David MannandClaude Opus 5.5 64efae47c8 Guide Layout: the disabled Rows field no longer turns Rows on (#5625) (#5626)
The disabled Number field still drew a DragValue with range 1..=1000,
which clamps the default rows 0 to 1 and reports a change; writing that
back made the Rows checkbox's `positive` fallback turn on, so OK added
guides at the top and bottom. Disabled number fields no longer write
back. Kittest regression covers Rows and Columns.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:05:55 -07:00
Jason Xiang 2a0bebb49a fix(engine): validate bounded extended filter parameters (#5586) 2026-10-14 15:05:46 -07:00
Sabbir Ahmed 2eabf45ba5 fix(ui): ⇧ marquee square keeps equal sides on the pixel grid (#5472) (#5506)
The ⇧ square (and a 1:1 fixed ratio) is built in continuous document
coordinates, and `marquee_px` then snapped each axis to the pixel grid on
its own: floor the near edge, ceil the far one. A press between pixels,
dragged 100.5 px, snapped x to 101 and y to 102, so the readout and the
committed selection were off by one. `marquee_preview_px` now grows the
shorter side to the longer one, away from the press point, so the square
still covers every pixel the drag touched. Ants, readout and commit share
this path.

Fixes #5472
2026-10-14 15:05:36 -07:00
Juan Antonio RedondoandClaude Opus 5.5 1f33e4f6d7 fix(ui): a held arrow key keeps stepping the focused value field (#5474) (#5497)
Holding Up/Down in a value field (Character panel tracking, size, …)
stepped it a couple of times, then jumped focus to the field above.

Each arrow step re-requested focus on the field that already had it.
egui's request_focus resets the field's event filter, so on the next
frame egui no longer knew the field takes vertical arrows. A key repeat
landing on that frame was read as "move focus up". Single presses
rarely hit that frame; held keys always do.

Only request focus when the field lost it (after a typed sum gives it up
to be worked out). Regression test feeds key repeats on consecutive
frames with a field above and failed before the fix (focus moved after
two steps).

Fixes #5474

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:05:26 -07:00
DaktylandNigel Ihza Firdausy 051959ea7b Double-clicking the empty workspace shows File › Open (#5367) (#5429)
Double-clicking the empty workspace shows File › Open (#5367)

Also covers the same fix as #5461; thanks to its author.

Co-authored-by: Nigel Ihza Firdausy <[email protected]>
2026-10-14 15:05:16 -07:00
Nigel Ihza FirdausyandMarco Arnulfo 23dc83928c fix(ui): expose toolbar column toggle in all themes (#3538)
fix(ui): expose toolbar column toggle in all themes

Also covers the same fix as #4680; thanks to its author.

Co-authored-by: Marco Arnulfo <[email protected]>
2026-10-14 15:05:06 -07:00
Sidney Markowitzandnrq6993-a11y d334d32c12 fix(heif): a grid's ICC profile is inherited from its tiles (#5553)
fix(heif): a grid's ICC profile is inherited from its tiles

Also covers the same fix as #5591; thanks to its author.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:04:55 -07:00
Sir Clangington III.andLabDennis 914d51f9bb perf(ui): bound carried selection outlines and stabilize clipping (#5699)
Limit whole-selection transform traces to a padded one-million-cell grid
while preserving normal viewport caching and thin boundary samples.
Use double-precision intermediates for clipping long perspective lines.

Add regressions for large/disconnected selections and viewport accuracy
near the projective horizon. Preserve the full-resolution selection and
the transform behavior already landed in #5337.

Related: #5297, #3402.

Co-authored-by: LabDennis <[email protected]>
2026-10-14 15:04:45 -07:00
Vikram BhaskaranandZer0C00L01 0f8c1a4106 Curves: apply to masks, alpha, and Quick Mask (#5335) (#5448)
Curves: apply to masks, alpha, and Quick Mask (#5335)

Also covers the same fix as #5578; thanks to its author.

Co-authored-by: Zer0C00L01 <[email protected]>
2026-10-14 15:04:37 -07:00
optimalbyteeandoptimalbytee 0e15fe77f7 Fix Nikon NEF lossy levels, packed decoding and metadata crops (#3238)
Co-authored-by: optimalbytee <[email protected]>
2026-10-14 15:04:27 -07:00
Michael HigginsandClaude Opus 5.5 7e94c507e9 Smart Objects: Curves preview no longer zooms in on a reduced proxy (#5766) (#5779)
Adjustments and filters on a smart object add a smart filter, so their live
preview re-renders the smart object from its source through its placement.
The preview runs on a proxy document scaled down by k (large documents), and
proxy_document scaled the smart object's cache but left its transform (and
perspective and filter mask) in full-size document coordinates. The re-render
then came out k times too large on the proxy canvas, so the object looked
zoomed in until OK committed the full-size result.

proxy_document now scales the placement and the filter mask with the proxy.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:04:19 -07:00
David Mannandnrq6993-a11y 772dfb9e7e Options bar: Brush and Eraser size go to 5000 px in every theme (#5708) (#5709)
Options bar: Brush and Eraser size go to 5000 px in every theme (#5708)

Also covers the same fix as #5724; thanks to its author.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:04:09 -07:00
FinnNkandClaude Opus 5.5 2807deb209 fix(psd): write the layer block after a kept merged-transparency marker (#5673)
A 16/32-bit document saved as PSD has its layers in a global Lr16/Lr32 block, which the writer put first among the global blocks. A Mtrn/Mt16/Mt32 marker kept from the opened file then followed it, and Photoshop refuses such a file (verified in Photoshop 2026 with psd-tools' transparentbg.psd re-saved both ways: Mt32 first opens, Lr32 first doesn't). The layer block now goes right after the last kept marker, as Photoshop orders them. Fixes #5068.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:03:59 -07:00
David MannandClaude Opus 5.5 1025914b0e Properties / New Document: tiny sizes in physical units no longer resize the document (#5661) (#5662)
The Width/Height fields in Properties > Document and in the New Document
dialog clamped their shown value to at least 0.01 of the unit. A 1 x 1 px
document at 300 ppi is 0.0033 in, so showing it in inches clamped it to
0.01 in, the field reported a change and the document became 3 x 3 px
(two history steps, no user edit). The fields now accept down to 0; the
pixel conversion afterwards already clamps to at least 1 px.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:03:50 -07:00
David Mannandnrq6993-a11y d8d592b1bb FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627) (#5628)
FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627)

Also covers the same fix as #5642; thanks to its author.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:03:42 -07:00
9a905959ed fix(linux): install the AppImage menu entry when AppImageLauncher skips it (#5551)
AppImageLauncher exports DESKTOPINTEGRATION even after
X-AppImage-Integrate=false makes it skip the AppImage. AppRun treated that
variable as "already integrated" and wrote no .desktop file, so the menu
stayed empty. Skip our entry only when another .desktop file's Exec already
points at this AppImage.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:03:32 -07:00
Skyfuller 9cc8be4392 fix(algo): keep clipped warp bounds from overflowing (#5451)
warp() computed the source-centre fallback as (x0 + x1) in i32 and the
inverse-mapped tile footprint as `floor() as i32 - 3` / `ceil() as i32 + 3`.
With a strongly skewed finite affine (for example Homography
[1, 1e12, 0, 0, 1, 0, 0, 0, 1] on a 32x32 clip) the float-to-int cast
saturates at i32::MIN/MAX and the following -3/+3 overflows, panicking in
debug builds and wrapping in release. A source rect with coordinates near
1.5e9 would overflow the centre sum the same way.

Widen the centre sum to f64 and use saturating_sub/saturating_add for the
footprint, which is intersected with src_rect afterwards anyway.

Add crates/algo/tests/warp_clipped.rs covering clipped-vs-full equivalence,
huge scale allocation, the extreme-affine footprint, and the far-origin
source centre.

Refs #3446
2026-10-14 15:03:24 -07:00
Saad AbdullahandSaad Abdullah e1706507b5 fix(geom): clamp huge sizes in Rect::from_xywh instead of wrapping, and saturate TileCoord::rect (#5161)
Co-authored-by: Saad Abdullah <[email protected]>
2026-10-14 15:03:16 -07:00
MarekSpodBiedry f1d13ca9c7 fix(ui): keep the close prompt up when a Save Document script edits the document after saving (#4953) (#5081) 2026-10-14 15:03:07 -07:00
MarekSpodBiedry dbfe8ba412 fix(history): count fill caches and smart filter masks in undo memory (#4972) (#5075) 2026-10-14 15:03:00 -07:00
MarekSpodBiedry 686f3ee944 fix(raw): reject truncated or oversized Deflate tiles in DNG import (#4977) (#5072) 2026-10-14 15:02:52 -07:00
mroxsoandhighperfocused 57d488032c fix(doc): variable definitions follow layer id remapping on copy and load (#5023)
* fix(doc): variable definitions follow layer id remapping on copy and load

A duplicated document (image.duplicate, history.newDocument) or a .pcraft
loaded with remapped ids assigned fresh layer ids but cloned VariableDef.layer
unchanged, so datasets silently drove no layer (or, after remap, a colliding
one). Remap variable targets through the same id map as layer comps and
slices; a dangling target keeps a non-colliding placeholder. A merged-only
duplicate drops the whole variable binding set, matching how it drops comps.

Closes #4969

* style: cargo fmt duplicate_variables test

---------

Co-authored-by: highperfocused <[email protected]>
2026-10-14 15:02:43 -07:00
Nigel Ihza Firdausy 4c8101b576 fix(ui): avoid overflow when allocating layer style instance IDs (#4884)
* fix(ui): avoid overflow when allocating layer style instance IDs

* fix(ui): avoid panicking when layer style IDs overflow

* fix(ui): format non-panicking layer style ID allocation
2026-10-14 15:02:35 -07:00
Nigel Ihza Firdausyandbuivanvu94 13f893f998 fix(export): prompt for a Render Video output directory (#3365) (#3378)
fix(export): prompt for a Render Video output directory (#3365)

Also covers the same fix as #3390; thanks to its author.

Co-authored-by: buivanvu94 <[email protected]>
2026-10-14 15:02:27 -07:00
Nigel Ihza Firdausy 5c78d0848f fix: limit Render Video timeline and GIF staging allocations (#5670) 2026-10-14 15:02:17 -07:00
Valentin KleinandClaude Opus 5.5 bcaadccbfa Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) (#5204)
* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

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

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:02:09 -07:00
8d840eab59 i18n(es): complete dynamic UI translations (#4306)
* i18n(es): complete dynamic UI translations

* es.tsv: keep Blur Angle once after merging main

---------

Co-authored-by: gorevyoneticisi <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:36:32 -04:00
7e0f48a290 Blur Gallery: Tilt-Shift and Spin Blur on the image (#3373) (#3829)
* Blur Gallery: Field Blur pins on the image (#3373)

Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.

The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.

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

* Blur Gallery: Iris Blur's pins and handles on the image (#3373)

Iris Blur opened a parameter dialog with one ellipse. It now uses the
Field Blur workspace too, with Photoshop's handles on the selected pin:
the four on its outline resize that axis and turn the shape, the square
at its corner rounds it towards a rectangle, and the four dots inside
set the sharp core, where the blur starts. A click adds a pin with the
selected pin's shape and blur; pins move, their ring sets the blur,
Delete removes them, and the preview follows.

The pins keep the command's own keys (radiusX/radiusY as fractions of
the bounds' shorter side, angle, roundness, feather), and the handles
use the engine's superellipse geometry, so what the outline shows is
where the filter blurs. Values from automation are cleaned before use.

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

* Blur Gallery: Tilt-Shift and Spin Blur on the image (#3373)

Tilt-Shift and Spin Blur opened parameter dialogs; they now use the
Blur Gallery workspace with Photoshop's handles.

Tilt-Shift: the solid lines bound the sharp band (drag to widen or
narrow it), the dashed lines mark where the blur is full (drag to set
the transition), the dots on the solid lines turn the band, the pin
moves it and its ring sets the blur. The engine takes one band, so a
click adds none and the dialog keeps the command's own centerX,
centerY, angle, focus, transition and blur.

Spin Blur: pins like Iris Blur's, with the outline handles resizing and
turning the ellipse; the ring and the dialog set the blur angle (one
turn of the ring is 360 degrees). Pins keep the command's keys
(radiusX, radiusY, angle, blurAngle).

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

* i18n(vi): translate the Field Blur pin strings

main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(vi): translate the Iris Blur handle hint

main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (tay nắm for handle, Kéo for drag).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(vi): translate the Tilt-Shift and Spin Blur hints

main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (ghim, tay nắm, Chuyển tiếp, Góc làm mờ).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* Blur Gallery: Alt+Cancel Reset starts again from one pin

main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Field Blur pin strings

main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Iris Blur handle hint

Terms follow each catalog: fogantyú for handle (Hungarian), tutamaç
for handle (Turkish).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Tilt-Shift and Spin Blur hints

Terms follow each catalog: tű, fogantyú, Átmenet (Hungarian); pim,
tutamaç, Geçiş (Turkish).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the Field Blur pin strings

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n(sr): translate the Iris Blur handle hint

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n(sr): translate the Tilt-Shift and Spin Blur hints

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n: keep main's catalog row where both added the same label

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

* docs/gaps.md: the Tilt-Shift and Spin paragraph replaces the Field/Iris one

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:33:05 -04:00
Sir StarchandBrandon Thomas cd046f4a70 Arrow-key nudges: a run of nudges is one History state, with Tools preferences (#2259) (#2764)
* Arrow-key nudges: a run of nudges is one History state, with Tools preferences (#2259)

Tapping or holding an arrow key nudged the selected layers one pixel per
History state, so one Undo took back one pixel. Nudges of the same thing
that follow each other now share one `coalesce` key (the engine already
folds edits with the same key into one step), so a run is one state and
one Undo takes it back.

- nudge_bundle.rs decides which run a nudge belongs to: the previous nudge
  was of the same thing (layers, selection outline), came within the
  pause, and left the document as it is now (no other edit, Undo/Redo,
  layer selection or save in between).
- move_mods.rs runs layer.translate and select.transformSelection through
  it; Alt-nudges (a copy each) never join a run. Selected pixels float
  until dropped, which is one state already.
- Free Transform keeps its own session history; its box nudges fold into
  one session step (transform_tool.rs).
- Tools preferences: Bundle Arrow-Key Nudges (default on; off gives a
  state per press as before) and Nudge Bundle Pause (ms) (default 1000,
  100..10000, hidden while bundling is off). Labels translated in all
  catalogues.

The two #1428 tests that asserted a state per press for the selection
outline now cover both settings.

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:32:39 -04:00
3ebc42a200 Export As: exact pixel size, named presets, and profile options (#5344)
* Export As: exact pixels, named presets, and profile options

Users can set an exact pixel size with the aspect ratio locked or free,
save and reuse named export presets in the preferences document, and
choose whether to convert to sRGB, embed the colour profile, and keep
EXIF or all metadata. Percent scale and the last-used settings stay.

Part of #2289

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

* Translate Export As's Color Profile and EXIF labels in every catalog

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:17:44 -04:00
a61df64cc9 feat(ui): select guides, the guide context menu and Edit Selected Guides (#3734)
* feat(engine): view.editGuide changes a guide in one step

Photoshop's Edit Selected Guides (the guide context menu) reopens New
Guide on the selected guide. view.editGuide takes the guide by orientation
and index and changes whatever is given: "newOrientation", "position"
(pixels or a length with a unit, % of the new orientation's extent) and
"color" (a name or "#rrggbb"), as one undoable "Edit Guide" step. A guide
that keeps its orientation keeps its index; one that turns goes to the
end of the other list. Without "color" the guide keeps its own colour, or
none. Bad params are refused without a history step.

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

* feat(ui): select guides, the guide context menu and Edit Selected Guides

As in Photoshop, a click on a guide with the Move tool (or ⌘ with a
selection tool) selects it, drawn in Photoshop's selected-guide blue
(#0000ff); a press anywhere else deselects it, and Delete or Backspace
deletes it, before Edit › Clear can reach the pixels. A guide that is
dragged stays selected, and a click no longer nudges a guide to the
pointer. One guide at a time.

Right-click on a guide selects it and opens Photoshop's guide menu: Edit
Selected Guides, Lock Guides, Clear Guides, Clear Canvas Guides, Clear
Selected Guides and Clear Selected Artboard Guides. Edit Selected Guides
opens the New Guide dialog on the guide (its orientation, its position in
the ruler unit and its colour, Cyan for a guide dragged from a ruler) and
OK runs view.editGuide, sending the colour only when it was changed.

The selection is view state (UiState.selected_guide, readable over the
control channel), keyed by document, orientation and position like guide
colours, so it lapses by itself when its guide goes. The menu's two new
labels are in every catalogue.

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

* feat(ui): Alt-click turns a guide; double-click opens the guide preferences

As in Photoshop, with the Move tool:
- Alt-clicking a guide turns it: a vertical guide becomes horizontal
  through the point clicked, and the other way round. It keeps its colour
  and stays selected; one undoable Edit Guide step (view.editGuide).
  Locked or hidden guides can't be turned.
- Double-clicking a guide opens Preferences › Guides, Grid & Slices.

docs/ui-parity.md lists the guide interactions.

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

* feat(i18n): Hungarian and Turkish strings for the guide menu

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

* fix(ui): the spelling rows call engine_call with the app

main's spelling suggestions (choose_spelling) came in calling
engine_call(menu, command); on this branch engine_call takes the app
first (the guide menu needs it), so the merge didn't compile.

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

* i18n(sr): translate the guide context menu items

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

* fix(ui): give main's move-hover test guide drag a press point

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-14 15:17:10 -04:00
9788e5ee5e Shortcuts: a command can answer a second key (F2/F3/F4, Shift+F7, Cmd++) (#3665) (#3746)
* Shortcuts: a command can answer a second key (F2/F3/F4, Shift+F7, Cmd++) (#3665)

Photoshop's default set gives some commands a second key besides the one
its menu shows: F2/F3/F4 Cut/Copy/Paste beside Cmd+X/C/V, Shift+F7 Inverse
beside Cmd+Shift+I, Cmd++ beside Cmd+= for Zoom In, Shift+Backspace for
Fill. A command could hold only one key here, so these couldn't exist and
a .kys import kept only a row's first key (a set listing Copy as Cmd+C and
F3 could leave Cmd+C unbound).

- prefs::ALTERNATE_SHORTCUTS holds Photoshop's second keys;
  Preferences.alternate_shortcuts (alternateShortcuts) replaces a
  command's defaults, [] removes them. Old settings files load unchanged.
- edit.keyboardShortcuts takes `alternates`; reset, moving a key (taken
  from another command's main key or alternates) and the conflict list
  count every key, and a call's changes now apply atomically.
- .kys import keeps a row's other keys as the command's alternates.
- Dispatch matches every key (a user's main key wins over a default
  alternate); the UI-only SECONDARY table is folded in, so Fill's
  Shift+Backspace survives an override of its main key. Menus still show
  only the main key.
- Keyboard Shortcuts dialog shows each command's alternates, removable
  one by one, with Add Shortcut to add one; Use Default / Reset All
  restore them.

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

* Translate Add Shortcut into Hungarian; regenerate the scorecard

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

* Scorecard: regenerate for the new alternate_shortcuts preference

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

* docs: regenerate scorecard after merging main

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

* Regenerate docs/scorecard.md after merging main

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

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:16:43 -04:00
Fabio LissiandBrandon Thomas 4cc0518169 Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) (#5091)
As Photoshop's menus on Windows: a tap on Alt (or F10) puts the keyboard
on the in-window menu bar, with File lit; ← and → move along it, ↓, ↩
and Space open the lit menu with its first row lit, Esc or a click
leave. Alt+letter opens the menu with that mnemonic, open or not, and
in an open menu a letter runs the row with that mnemonic or opens its
submenu.

Mnemonics are assigned per menu level from the labels as shown (so
translated), unique within the level: Photoshop's F E I L Y S T V W H
for the top menus where the label has the letter, else the first unused
letter, word starts first; only ASCII letters and digits, since those
are the keys the keyboard reports. They are underlined while Alt is
held, the bar is focused or the menu was opened from the keyboard; a
menu opened with the mouse shows none until Alt, as on Windows. The
underline is a layout job with the placeholder colour, so disabled rows
stay dim; without underlines the text path is unchanged.

The shortcuts defer to the focused bar as they do to an open menu, so E
opens Edit instead of picking the Eraser and the arrows don't nudge the
layer. Rows carry their mnemonic in the navigation state. macOS keeps
the native menu bar, where this doesn't run.

Tests: two unit tests (assignment, key letters) and seven harness tests
(Alt focus and arrows, second Alt / Esc / click, Alt+letter and Enter,
letters in an open menu, underline conditions, the layout job, and the
whole app's shortcut deferral).

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:16:09 -04:00
9b34c49128 Add Shift+scroll snap zoom to Photoshop zoom ladder levels (#3695)
* Add Shift+scroll snap zoom to Photoshop zoom ladder levels

When zooming with the scroll wheel (either via Alt+scroll or via the
"Zoom with Scroll Wheel" preference), holding Shift snaps to the next
Photoshop zoom ladder level instead of the continuous ×1.1 step.

- zoom_with_wheel ON: Shift+scroll snaps to the ladder
- zoom_with_wheel OFF: Shift+Alt+scroll snaps to the ladder

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

* Handle Shift+wheel arriving as horizontal delta in snap zoom

Some systems (notably egui on certain platforms) deliver Shift+scroll
as a horizontal delta (x != 0, y = 0) rather than preserving the
vertical axis. Use `raw.y + raw.x` so the snap-zoom path catches
both orientations, matching the existing Shift+Alt pattern on the
line above.

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

* Snap zoom: per-viewport fractional accumulation, Camera Raw ladder stepping, and horizontal-delta handling

Port the improvements from TheSeanLavery/photocraft#33:

- `read()` gains `owner: Id` and `hovered: bool` so each viewport
  (canvas, navigator, Camera Raw) accumulates its own snap residual
  without leaking into others.
- Fractional scroll input (sub-notch point events) now accumulates
  across frames with an idle timeout, so precision trackpads and
  high-resolution mice reach the next ladder rung.
- Camera Raw snap zoom steps through the ladder via `step_in_range()`
  instead of being a no-op.
- Shift+wheel arriving as a horizontal delta (x != 0, y == 0) is
  handled by using `raw.y + raw.x` in the snap path.
- `zoom_levels::step_in_range()` extracted for reuse with custom bounds.
- 8 new tests (24 wheel_nav total), 4 new Camera Raw snap tests,
  1 new zoom_levels test.

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

* Fix test destructure after merge with main (#5080) and reformat

#5080 added a test that destructures gesture() as a 2-tuple, but this
branch makes it return 3 values.  Update the destructure and run
cargo fmt --all.

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:15:42 -04:00
5cb5ab52fc Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823) (#3827)
* Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823)

image.crop gains contentAware (bool, default false). After a turned crop or one that extends
past the canvas, the area the original image doesn't cover is filled from its surroundings on
the Background (else the active pixel layer), in the crop's one undo step. Each connected piece
(corner, edge strip) gets its own Content-Aware sampling window and samples only covered pixels;
the patch search is Edit > Fill > Content-Aware's (fill_cmds::content_aware_native, shared). A
crop that uncovers nothing is unchanged; one with nothing to sample from fails and rolls back.

Crop options bar: Fill: Background (Default) / Content-Aware Fill (ToolOptions
crop_content_aware, ui.set cropFill). Generative Expand is not offered.

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

* Crop: adapt to Document::background after main removed is_background

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

* Crop: translate Background (Default) for Hungarian and Turkish

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

* Fix the merge with main: UI_SET_FIELDS length, Serbian Background (Default)

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

* Merge main: UI_SET_FIELDS counts both new fields

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:15:15 -04:00
9df067b297 Shapes: on-canvas corner radius widgets for live rectangles (TOOL-214-12) (#5276)
* Shapes: on-canvas corner radius widgets for live rectangles (TOOL-214-12)

As in Photoshop, with Path Selection or the Rectangle tool the active shape
layer's live rectangle shows a round widget at the centre of each corner arc
(a few screen points inside a sharp corner). Dragging one rounds every corner
alike, Alt drags that corner alone; the radius follows the move along the
corner's diagonal from the press, within half the shorter side. The drag
previews the new outline and commits one shape.edit with radii on release,
one history step; a click changes nothing.

Docs: ui-parity row done, TOOL-214-12 note updated (still partial: shape
feather), target-app-parity, regenerated scorecard.

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

* Corner radius widgets: every selected rectangle, Move tool, cursor and readout

As in Photoshop:
- every selected live rectangle shows its widgets, also with the Move tool
  when Show Transform Controls is on;
- dragging any widget gives every selected rectangle the same radius (each
  capped at half its own shorter side); Alt changes that corner on each;
  all the shape.edit calls share one history step (coalesce);
- the pointer over a widget is the arrow with a rounded-corner badge, and a
  drag shows an "R: 25.0 px" readout;
- while dragging, the transform box and the Move rollover highlight hide, and
  the Move tool's snapping started by the press is dropped.

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-14 15:14:49 -04:00
Brandon ThomasandClaude Opus 5.5 1c6f138763 Posterize and Threshold commands use the 16-bit rules on 16-bit layers (#5711)
#5267 and #5263 gave 16-bit adjustment layers Photoshop's measured Posterize and
Threshold rules, but the destructive commands still passed no depth for 16-bit
layers and used the 8-bit rules. The live preview (an adjustment layer) then
disagreed with what OK wrote by up to 92/255, failing ui-egui's
adjust_preview::preview_composite_matches_the_destructive_command on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:14:30 -04:00
d56aa35bce Blur Gallery: Iris Blur's pins and handles on the image (#3373) (#3822)
* Blur Gallery: Field Blur pins on the image (#3373)

Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.

The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.

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

* Blur Gallery: Iris Blur's pins and handles on the image (#3373)

Iris Blur opened a parameter dialog with one ellipse. It now uses the
Field Blur workspace too, with Photoshop's handles on the selected pin:
the four on its outline resize that axis and turn the shape, the square
at its corner rounds it towards a rectangle, and the four dots inside
set the sharp core, where the blur starts. A click adds a pin with the
selected pin's shape and blur; pins move, their ring sets the blur,
Delete removes them, and the preview follows.

The pins keep the command's own keys (radiusX/radiusY as fractions of
the bounds' shorter side, angle, roundness, feather), and the handles
use the engine's superellipse geometry, so what the outline shows is
where the filter blurs. Values from automation are cleaned before use.

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

* i18n(vi): translate the Field Blur pin strings

main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(vi): translate the Iris Blur handle hint

main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (tay nắm for handle, Kéo for drag).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* Blur Gallery: Alt+Cancel Reset starts again from one pin

main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Field Blur pin strings

main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Iris Blur handle hint

Terms follow each catalog: fogantyú for handle (Hungarian), tutamaç
for handle (Turkish).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the Field Blur pin strings

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n(sr): translate the Iris Blur handle hint

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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-14 15:14:12 -04:00
21c7cf637a Curvature Pen: edit finished paths (drag, add, smooth/corner) with an adaptive cursor (#5197)
* Curvature Pen: drag an anchor of the active path without a modifier

As in Photoshop, the Curvature Pen with no path being drawn shows the active
path's anchors (work path, active shape layer or targeted vector mask), and a
press-drag on one moves it with no Ctrl/Cmd. The curve reflows: every smooth
knot of that subpath gets its handles recomputed with the Curvature Pen's own
curvature_knots, so it keeps flowing through all its anchors; corners keep
their handles. The drag previews live and commits one history step.

- vector: edit::move_anchors_curvature
- engine: path.moveAnchors takes "curvature": bool=false
- ui-egui: direct_select routes the idle Curvature Pen press (Edit::Curvature)

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

* Curvature Pen: add anchors, toggle smooth/corner and adapt the cursor on finished paths

As in Photoshop, with no path being drawn the Curvature Pen also:
- adds an anchor where a segment of the active path is clicked
  (path.addAnchor), and a drag pulls it like any anchor; adding and dragging
  are one history step;
- toggles an anchor between smooth and corner on a double-click
  (path.convertPoint with the new "curvature" param, vector
  edit::toggle_curvature_point): a Pen-drawn corner rounds off and the
  segments around it curve through it; a second double-click makes it a
  corner again;
- shows what a press does in its cursor: + over a segment, four arrows over
  an anchor (also an anchor of the path being drawn).

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

* rustfmt

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:13:46 -04:00
524d608b50 feat(psd): editable Filter Gallery smart filters (Chrome) (#3426)
* feat(psd): map Filter Gallery smart filters to filter.filterGallery (Chrome)

A PSD smart filter from Filter > Filter Gallery (`GEfc`) was kept verbatim
as psd.unsupportedFilter, so it could not be edited. Its effect stack
(`GEfs` list of `GEfc` effects with `GEfk` effect enum and `GELv`
visibility) now maps to `filter.filterGallery {effects, foreground,
background}` when every effect and setting is modelled, and is written
back as `GEfc` on export. Layout taken from a Photopea-authored PSD.

The table starts with Chrome (`Chrm`: `Dtl ` detail, `Smth` smoothness).
Stacks with other effects or unknown settings stay verbatim as before.

* feat(smart-filters): edit Filter Gallery smart filters in the gallery dialog

Double-clicking a Filter Gallery smart filter in the Layers panel did
nothing. It now opens the Filter Gallery dialog with the recorded effect
stack and colours, previewing on the pixels below that filter; OK updates
the filter in place with layer.smartFilter.setParams (one history step).

* fix(psd): read and write Photoshop's inline Filter Gallery effect

Photoshop 27 writes a Filter Gallery smart filter with a single effect
inline in `Fltr` (`GEfc { GEfk, <settings> }`) rather than as a `GEfs`
list, so such files still opened as psd.unsupportedFilter. Both layouts
are read now. A single visible effect is written inline as Photoshop does;
several or hidden effects keep the list, which is the only layout with a
visibility flag (`GELv`).

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:13:20 -04:00
28e58d20d9 fix(ui): snap distorted corners to visible guides (#3928)
Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:12:49 -04:00
a62ef68ca2 CMYK: Hue/Saturation and Color Balance keep the black ink (#5000)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* CMYK: Brightness/Contrast curves each ink, as Photoshop does

Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.

- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
  the channel curve once (brightness_contrast_lut) and runs each
  separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
  inks directly, so it is exact; a selection mixes them.
  adjusted_channels now takes the per-pixel operation, and works out
  luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
  ink rules.

cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%

The rest is our separation of the RGB composite, not the rule.

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

* CMYK Threshold compares the inks' own luma, as Photoshop does

Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.

stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.

brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.

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

* CMYK: adjustment layers composite on the document's own inks

The compositor works in display RGB. In a CMYK document, an adjustment
layer that Photoshop evaluates on the inks could only act on our
separation of the RGB composite, and that separation's black differs
from the file's. So even exact ink rules missed: CMYK Invert was 10%
off, Levels and Curves 50-65%.

The ink-native compositor (compose::native, until now only for
flattening plain raster stacks) now takes adjustment layers too:
- Invert, Posterize, Threshold and Brightness/Contrast (modern and
  legacy), and Levels and Curves on CMYK channels, prepared once
  (InkStack, InkOp) and applied to the straight inks.
- Opacity and an unfeathered layer mask mix the inks linearly, as in
  Photoshop; raster layers' masks scale their coverage the same way.
- A CMYK stack of plain raster layers and such adjustments renders this
  way for display, export and reduced views (render_cmyk, tiles in
  parallel); flattening and merging keep the adjusted inks exactly.
- Stacks of raster layers alone keep the RGB compositor, which the GPU
  matches. The GPU already leaves these adjustments to the CPU.

Photoshop oracles: cmyk8 and cmyk16 invert, posterize-4, threshold-128,
levels, curves, brightness-contrast and brightness-contrast-legacy all
pass (14 files), 151 -> 165 of 258; floor 151 -> 165. psd-tools and io
corpora unchanged. 24 MP CMYK8 with one adjustment renders in 286 ms on
the inks, against 442 ms through the RGB compositor.

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

* CMYK ink compositing inlines open groups

The ink compositor gave up on any group. Now a group at full opacity
without a mask is inlined:
- a pass-through group's children composite straight onto the backdrop,
  so an adjustment inside it reaches the layers below;
- an isolated (Normal) group of raster layers alone composites as its
  children would, since Normal is associative.

An isolated group with an adjustment, which only sees the group's
content, keeps the RGB compositor. So do a translucent group and an
artboard. Nesting is bounded.

psd-tools' CMYK adjustment files keep their swatches in pass-through
groups. With #4948's fair comparison, curves_cmyk drops from 37% to 0.3%
bad pixels and levels_cmyk from 14% to 0.2%. The photoshop corpus is
unchanged at 165.

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

* CMYK: Hue/Saturation and Color Balance keep the black ink

In a CMYK document Photoshop applies Hue/Saturation (not Colorize) and
Color Balance to C, M and Y as the complement of an RGB colour, and
leaves the black ink alone. On the photoshop corpus's own inks,
cmyk16 hue-saturation.psd follows this on every pixel within half a
level (cmyk8 within two), and color-balance within our RGB Color
Balance's own error. We applied both to the separated screen colour,
which also rewrote K.

- adjust::cmy_adjustment and apply_cmy run the RGB adjustment once per
  band on (1 - c, 1 - m, 1 - y).
- The ink compositor (InkOp::Cmy), the RGB compositor's CMYK branch and
  destructive Image > Adjustments (pixels::adjusted_cmy) use them. The
  GPU leaves these to the CPU in CMYK documents, as it does the other
  ink rules.
- Colorize separates its colour again, black included, so it keeps the
  screen-colour path.

cmyk8/16 oracles, bad pixels:
- hue-saturation: 100% -> 0.4-0.7%
- color-balance: 74% -> 25%

Neither passes yet. Half a level of cyan ink is three levels of red
through the profile at saturated cyan, and our RGB models leave that
much. The pass count stays at 165.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* CMYK: Posterize works on the stored values (1 - ink)

Photoshop posterizes a CMYK channel's stored value, 1 - ink, not the
ink: with levels whose steps are uneven (3, 7, 13, 21) the two differ,
e.g. 3 levels take an ink of 85 to 128, where posterizing the ink drops
it to none. psd-tools posterize_cmyk.psd goes from 13.8 % of pixels off
to exact; psd-tools 241 -> 242, floor 242.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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

* Raise corpus floors to the results of the landed adjustment fixes

Photoshop-authored 165 -> 185 of 258, io 146 -> 148 of 170, psd-tools 242 -> 249 of 309,
measured with the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and
pass-through PRs merged together.

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-14 14:12:06 -04:00
1e6f565a7b CMYK: adjustment layers composite on the document's own inks (14 oracles pass) (#4945)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* CMYK: Brightness/Contrast curves each ink, as Photoshop does

Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.

- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
  the channel curve once (brightness_contrast_lut) and runs each
  separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
  inks directly, so it is exact; a selection mixes them.
  adjusted_channels now takes the per-pixel operation, and works out
  luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
  ink rules.

cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%

The rest is our separation of the RGB composite, not the rule.

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

* CMYK Threshold compares the inks' own luma, as Photoshop does

Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.

stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.

brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.

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

* CMYK: adjustment layers composite on the document's own inks

The compositor works in display RGB. In a CMYK document, an adjustment
layer that Photoshop evaluates on the inks could only act on our
separation of the RGB composite, and that separation's black differs
from the file's. So even exact ink rules missed: CMYK Invert was 10%
off, Levels and Curves 50-65%.

The ink-native compositor (compose::native, until now only for
flattening plain raster stacks) now takes adjustment layers too:
- Invert, Posterize, Threshold and Brightness/Contrast (modern and
  legacy), and Levels and Curves on CMYK channels, prepared once
  (InkStack, InkOp) and applied to the straight inks.
- Opacity and an unfeathered layer mask mix the inks linearly, as in
  Photoshop; raster layers' masks scale their coverage the same way.
- A CMYK stack of plain raster layers and such adjustments renders this
  way for display, export and reduced views (render_cmyk, tiles in
  parallel); flattening and merging keep the adjusted inks exactly.
- Stacks of raster layers alone keep the RGB compositor, which the GPU
  matches. The GPU already leaves these adjustments to the CPU.

Photoshop oracles: cmyk8 and cmyk16 invert, posterize-4, threshold-128,
levels, curves, brightness-contrast and brightness-contrast-legacy all
pass (14 files), 151 -> 165 of 258; floor 151 -> 165. psd-tools and io
corpora unchanged. 24 MP CMYK8 with one adjustment renders in 286 ms on
the inks, against 442 ms through the RGB compositor.

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

* CMYK ink compositing inlines open groups

The ink compositor gave up on any group. Now a group at full opacity
without a mask is inlined:
- a pass-through group's children composite straight onto the backdrop,
  so an adjustment inside it reaches the layers below;
- an isolated (Normal) group of raster layers alone composites as its
  children would, since Normal is associative.

An isolated group with an adjustment, which only sees the group's
content, keeps the RGB compositor. So do a translucent group and an
artboard. Nesting is bounded.

psd-tools' CMYK adjustment files keep their swatches in pass-through
groups. With #4948's fair comparison, curves_cmyk drops from 37% to 0.3%
bad pixels and levels_cmyk from 14% to 0.2%. The photoshop corpus is
unchanged at 165.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* CMYK: Posterize works on the stored values (1 - ink)

Photoshop posterizes a CMYK channel's stored value, 1 - ink, not the
ink: with levels whose steps are uneven (3, 7, 13, 21) the two differ,
e.g. 3 levels take an ink of 85 to 128, where posterizing the ink drops
it to none. psd-tools posterize_cmyk.psd goes from 13.8 % of pixels off
to exact; psd-tools 241 -> 242, floor 242.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:11:42 -04:00
92c7922230 CMYK: Brightness/Contrast curves each ink, as Photoshop does (#4930)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* CMYK: Brightness/Contrast curves each ink, as Photoshop does

Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.

- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
  the channel curve once (brightness_contrast_lut) and runs each
  separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
  inks directly, so it is exact; a selection mixes them.
  adjusted_channels now takes the per-pixel operation, and works out
  luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
  ink rules.

cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%

The rest is our separation of the RGB composite, not the rule.

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

* CMYK Threshold compares the inks' own luma, as Photoshop does

Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.

stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.

brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:11:19 -04:00
852d2a1dfd Resize cursors turn with the box and with a rotated or flipped view (#5188)
* Type and Content-Aware Move boxes: resize arrows follow a rotated view

The type box (paragraph text handles, Ctrl/⌘ transform corners) and
Content-Aware Move's Transform On Drop box chose their resize arrows with
type_transform::resize_cursor, which mirrored the direction for a flipped
view but ignored a rotated one: on a view turned 90°, the side handles
showed ↕ although they resize left and right on screen.

The helper now takes the view and maps the handle's direction through it
(ViewXform::map_vec: rotation, flip and pixel aspect) before rounding to the
nearest of egui's four resize arrows. It moves to the canvas cursors as
tool_cursor::resize, so the other tools with resize handles can use it too.

The new test, resize_follows_the_view, checks views turned 0°, 45° and 90°
and a flipped view, with the expected arrows written out by hand; it fails
when the view's rotation is ignored.

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

* Free Transform, Warp, Artboard, guides, Crop: arrows follow the view

The remaining tools with resize arrows chose them without the view:

- Free Transform picked the arrow from the handle's index alone (corners 0
  and 2 ↖↘, 1 and 3 ↗↙, edges 0 and 2 ↕, 1 and 3 ↔), so a rotated box showed
  wrong arrows (all 8 handles at 45°, 90° and 135°), and so did a rotated or
  flipped view.
- Warp's Split Vertically/Horizontally cursor was always ↔/↕, though the
  split line follows the grid (turned when Warp starts on a rotated box) and
  the view.
- The Artboard tool used a fixed table, ignoring a rotated or flipped view.
- Guides turn with a rotated view (#3277), but the cursor over them stayed ↔
  for a vertical guide and ↕ for a horizontal one.
- Crop followed the frame's angle and a rotated view, but not a flipped one:
  its corners showed the other diagonal.

Free Transform and the Artboard tool now get the canvas's view and use
tool_cursor::resize: Free Transform with the direction from the opposite
handle, the Artboard tool with the handle's outward direction.
rulers::guide_cursor uses it for the direction across a guide, and Warp's
split_cursor for the grid's own direction across the split line at the
pointer (split_cursor and quick_split_kind take the grid position, so the
cursor looks it up once). Crop keeps its own resize_icon, which now mirrors
the turned direction when the view is flipped. On an upright, unflipped view
every arrow is as before.

New tests, with the expected arrows written out by hand, fail on the old
code: resize_cursors_turn_with_the_box_and_the_view (Free Transform),
warp_split_cursors_turn_with_the_grid_and_the_view,
resize_cursors_turn_with_the_view (Artboard),
guide_cursors_turn_with_a_rotated_view, and flipped-view checks in Crop's
cursor_follows_the_frame.

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-14 14:11:10 -04:00
00c16f2c6d Modern Brightness/Contrast: Photoshop's brightness curve (six oracles pass) (#4722)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:10:57 -04:00
GiuseppeandBrandon Thomas 86037b2354 feature(ui): added brushes panel dockable with floating option (#4262) (#4603)
* feature(ui): added brushes panel dockable with floating option (#4262)

* feat(ui): implement dockable and floating brushes panel (#4262)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:49 -04:00
4c450b3879 Lab: adjustment layers composite on the stored L, a and b (seven Lab oracles pass) (#5171)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:10:36 -04:00
4fb866942c Layers panel: Clip Thumbnails to Layer Bounds (#4281) (#4312)
* Layers panel: Clip Thumbnails to Layer Bounds (#4281)

Add interface.clipThumbnailsToLayerBounds (off by default), toggled from the
Layers panel menu or Preferences > Interface. When on, a pixel layer's
thumbnail frames the layer's own pixels (cut to the canvas) instead of the
whole canvas, so small objects in a large document stay visible. Empty or
fully off-canvas layers keep the canvas frame.

The frame is computed only when the thumbnail is rebuilt (tile-cached
content bounds) and is part of the cache key, so toggling rebuilds it.

Closes #4281

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: badaaif <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:22 -04:00
d24f6e9d5f Levels: whole levels at every step and Photoshop's shadow toe (levels_rgb + stack oracle pass) (#5143)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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

* Empty commit to resync the pull request head

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:14 -04:00
835f33a476 Pen tool: Shift constrains segments and handles to 45° (TOOL-214-10, #3751) (#3756)
* Pen tool: Shift constrains segments and handles to 45° (TOOL-214-10, #3751)

Shift-click puts the next Pen anchor on the nearest 45-degree ray from the
previous anchor (at the pointer's projection onto it); Shift held while
dragging out direction handles turns them to 45-degree steps, read live
per move. The first anchor, closing on the first anchor and the last-anchor
break are unaffected; after Alt breaks the pair Shift constrains the
outgoing handle alone. The rubber band previews the constrained point.
Reuses stroke_constraint::snap45. Flips scorecard TOOL-214-10 to done.

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

* Regenerate docs/scorecard.md after merging main

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

* Regenerate docs/scorecard.md after merging main

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

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:01 -04:00
f5606093cd Brush Settings: a response curve per dynamic control (#3294) (#3302)
* Brush Settings: a response curve per dynamic control (#3294)

Every brush Dynamic (Size, Opacity, Flow, Roundness, Scatter, Count,
Depth, Foreground/Background...) now carries its own response curve for
its control (Fade, Pen Pressure, Pen Tilt, Stylus Wheel, Rotation),
applied after the global Preferences pen curve. It is part of the brush
JSON, so tools.setBrush sets it (e.g. shapeDynamics.size.curve), actions
record it and presets keep it; linear by default, so existing brushes
paint unchanged.

- paint: ResponseCurve, the global pen curve's monotone cubic moved down
  from the engine as a fixed-size Copy type (no allocation per dab);
  serialises as its point list. dynamics::dynamic_value applies it.
- engine: prefs::PressureCurve is now that type.
- ui-egui: each Control row gets a curve button (lit when the curve isn't
  linear) that shows the same editor as Preferences > Tools.
- scorecard: TOOL-213-11 done.

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

* i18n: Response curve for hu, tr

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

* docs: regenerate scorecard after merging main

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

* Fix i18n after merge: Serbian Response curve, drop duplicate hu row; regenerate scorecard

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

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:09:39 -04:00
973e868173 Brushes: import an image file as a brush tip (#3279) (#3292)
* Brushes: import an image file as a brush tip (#3279)

`brush.presets.importImage` reads an image (path, or base64 data with
an optional fileName) through the normal importer, so any format and
bit depth File > Open reads works. Its composite becomes a sampled tip
the way Define Brush Preset reads a selection: darkness x alpha,
cropped to the painted extent, at most 2500 px a side. The bounds are
found band by band first, so a large mostly-white image never needs a
full-size buffer. The preset is named after the file (never replacing
an existing one) and becomes the current brush unless select is false.

Import Brushes... in the Brushes panel and the brush preset picker now
opens a picker for .abr and image files; images go to the new command,
.abr files to brush.presets.importAbr as before. Automation refuses an
ambient `path` for it like the other import commands.

Define Brush Preset and the new command share `sampled_brush`.

Scorecard TOOL-213-12 -> done.

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

* Regenerate scorecard

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

* docs: regenerate scorecard after merging main

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

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:09:08 -04:00
david419krandBrandon Thomas 03aa7e4962 fix(ui): remember the selected tool between launches (Photoshop parity) (#5043)
* fix(ui): remember the selected tool between launches

* fix(ui): preserve explicit Eraser selection during temporary tool overrides

Clear temporary pen and spring state for explicit toolbar, menu, preset and control selections. Keep spring-loaded key behavior; only permanent key choices clear the pen marker. Add exit/relaunch coverage for explicit Eraser with pen and spring overrides.

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:08:43 -04:00
aad56285ad Batch: Include All Subfolders (AUTO-219-5, #3753) (#3758)
* Batch: Include All Subfolders (AUTO-219-5, #3753)

File > Automate > Batch and File > Scripts > Image Processor take an
optional "includeSubfolders" (default false): a folder input also yields
the images in its subfolders, walked depth first in name order, bounded at
32 levels and 100000 files, with a seen-set of canonical folders against
symlink loops and the output folder left out. Each result keeps its
relative subfolder under the output folder, so same-named files in
different subfolders no longer collide. A non-boolean value is BadParams.

Both dialogs gain an "Include All Subfolders" checkbox (translated in
every complete catalog). Scorecard AUTO-219-5 stays partial (naming
template, progress/cancel, error log file); gaps.md G11 updated.

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

* Batch: translate Include All Subfolders for Hungarian and Turkish

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-14 14:08:20 -04:00
Zach SchneiderandBrandon Thomas 7833a6cad4 Layers panel: filter-type dropdown with Kind, Name and Mode (#5048) (#5268)
The filter row's "Kind" was painted text. It is now a dropdown, as in
Photoshop: Kind keeps the five layer-kind toggles, Name lists layers whose
name contains the typed text (ignoring case), and Mode lists layers with
one blend mode (Pass Through included), filtering by Normal at first.

The criteria live in UiState (layer_filter_by, layer_filter_name,
layer_filter_mode; serde defaults, so saved settings still load) and are
drivable with ui.set {layerFilter: {by, kinds, name, mode}}, validated
before anything changes. ui-parity.md gets a partial row: Effect,
Attribute, Color, Smart Object, Selected and Artboard are still missing.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:07:59 -04:00
3ee78a368b Free Transform: the box is the selection, and the marching ants follow it (#5297) (#5337)
With a selection, ⌘T and the Move tool's Show Transform Controls framed the
layer's content clipped to the selection, so the box was smaller than the
marching ants wherever the layer did not fill the selection. The ants then
stayed where the selection started until the transform was applied.

- transform_frame / target_frame (engine): the box is the selection's own
  bounds when a selection limits the transform. transform_bounds is unchanged,
  so snapping and the command defaults are as before.
- The selection outline is drawn through the open box (homography, or the warp
  in Warp mode), so it follows move, scale, rotate, skew, distort, perspective
  and warp before the transform is applied. The whole outline is traced while
  such a box is open, not only the part in view where it started.
- marching_ants_lines: ants along screen-space lines; non-finite lines are
  skipped and very long ones are clipped to the view.

Part of #3402.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:07:42 -04:00
620bccef42 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped, as in Photoshop (#5011)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:03:03 -04:00
eb26e8f0d7 Masks: feathers blur with exactly the feather's variance (mask-parameters-no-real-channel passes) (#5163)
* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:02:40 -04:00
9954c2935e Threshold: split on Photoshop's luminosity of whole samples (threshold_rgb, threshold_16bits_rgb pass) (#5263)
* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:02:26 -04:00
3feebb9b13 Groups: a pass-through clipping base with an adjustment renders isolated (passthrough_clipping_mask_adjustment passes) (#5272)
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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-14 14:01:50 -04:00
8504dc615e Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does (#5267)
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:01:01 -04:00
texjerandBrandon Thomas f499d97eee Embedded Profile Mismatch: the profile names on the choices, and Remember this choice (#5208)
The embedded and working profile names now sit on the choices they belong
to ("Use the embedded profile (Display P3)", "Convert to the working space
(sRGB IEC61966-2.1)") instead of two lines above them; the sentence naming
the file stays. The missing-profile prompt is unchanged.

"Remember this choice for future images" saves the answer as the Color
Settings policy for the file's mode (preserve, convert or off) and turns
Ask On Mismatch off, so files read from then on open without asking. Files
already waiting in the queue with a mismatch in the same mode get the same
answer (open_prompt::answer_remembering); a different question still asks.
The checkbox's tooltip says where to turn the prompt back on.

The catalogs drop the rows nothing uses any more ("Embedded: {embedded}" and
the two old choice labels) and add the four new strings in every language.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:47:43 -04:00
caad010b61 feat(actions): add Button Mode on the current Actions model (#5213)
An action can take one of Photoshop's button colours, and the panel can switch to Button Mode so a click plays that action. The colour is stored on the action and is empty until it is set. The new labels are in every complete catalog.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:47:27 -04:00
c208e8ffc1 feat(ui): Darken and Lighten Interface (Ctrl/Cmd+Shift+F1, F2) (#3709)
* feat(ui): Darken and Lighten Interface (Ctrl/Cmd+Shift+F1, F2)

Photoshop's Shift+Ctrl+F1 (⇧⌘F1 on macOS) darkens the interface by one of
its four brightnesses and Shift+Ctrl+F2 lightens it, stopping at the
darkest and lightest. PhotoCraft has the same four as themes (Pro, Pro
Medium Gray, Pro Light Gray, Pro Light), so view.darkenInterface and
view.lightenInterface step through them from the theme on screen and save
the choice like picking it in Preferences › Interface (setting the dark or
light theme and the matching appearance). From another theme they step
from the nearest brightness: Medium Gray for a dark one, Light Gray for a
light one. They work with no document open. Both labels are in every
catalogue.

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

* feat(i18n): Vietnamese strings for Darken and Lighten Interface

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

* feat(i18n): Hungarian and Turkish strings for Darken and Lighten Interface

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

* i18n(sr): translate Darken and Lighten Interface

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-14 13:47:09 -04:00
da6ceeaf96 Layer Style: Bevel & Emboss Technique dropdown (#5147) (#5181)
The engine, renderer and PSD mapping already supported Smooth, Chisel Hard
and Chisel Soft, but the dialog's Bevel & Emboss page had no control for it.
Add the Technique dropdown under Style (Photoshop's order), a smooth default
for new bevels, and translations.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:46:51 -04:00
Elvis33LEandBrandon Thomas aa44eafe2c ACEScg: File Handling option opens colour-less EXR renders in the ACES working space (#4908)
* ACEScg: File Handling option opens colour-less EXR renders in the ACES working space

Our render pipelines write ACEScg (AP1 primaries, ACES white ~D60,
linear) and — like most — state no colour space in the EXR header, so
io's linear-sRGB default misrepresented them (saturated hues shifted).
- cms: AcesCg joins the built-in profiles, generated from the published
  AP1 primaries and ACES white point with a linear TRC (id "aces-cg",
  aliases acescg).
- engine: File Handling gets "Open color-less EXR as ACEScg". When on,
  an EXR that io tagged with the linear-sRGB default opens as ACEScg
  instead; embedded or EXIF-declared profiles stay untouched.
Translated in every catalog. ACES view transforms (OCIO-based display)
remain future work; this covers the document's source colours.

* scorecard: 26 of 158 (main's #3472 preference added one too)

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:46:27 -04:00
David MannandClaude Opus 5.5 a3a47e9cb0 History panel: the trash deletes the selected snapshot (#5324) (#5325)
Clicking a snapshot now selects it (highlighted in place of the current
state) until the history moves on. With a snapshot selected the footer's
trash runs history.deleteSnapshot instead of history.deleteState, as in
Photoshop. Snapshot rows and past state rows can also be dragged onto
the trash to delete them.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 13:46:08 -04:00
fabiodalez-dev 32fdc4e402 i18n(it): translate the 103 labels other catalogs added since #3742 (#4860)
* i18n(it): translate the 103 labels other catalogs added since the last sync

* i18n(it): translate the 53 labels main added
2026-10-14 13:28:56 -04:00
Elvis33LE 3e74c4d8d3 raster: a shifted surface copies in one pass per row without pre-filled tiles (#2667)
* raster: a shifted surface copies in one pass per row without pre-filled tiles

Surface::translated's misaligned path allocated every target tile through
Tile::filled (a per-pixel default fill when the default is nonzero)
before overwriting nearly all of it, and addressed spans through
per-row closures. The path now writes each stored row directly: content
spans copy as single memcpys crossing source tiles as needed, vacated
edges fill from one prebuilt default row, and filled() builds its
default rows in bulk. Same output (the read-back oracle covers every
depth, sign and tile crossing), cheaper for nonzero defaults and one
less pass over each tile.

* raster: a missing source tile in the shifted content reads the default pixel

The misaligned path skipped absent tiles inside the content rect,
leaving zeros where the surface's default pixel belongs (a white mask
with sparse tiles turned black after a move). The span now fills from
the prebuilt default row. echelon's snippet is a regression test, and a
randomized oracle compares the whole path against a per-pixel reference
gather across formats, default pixels, sparse tiles and tile-boundary
crossing shifts.

* ci: retrigger wasm32 (the full workspace wasm check is clean locally; the job failed before writing a log and the run is stuck queued)
2026-10-14 13:28:48 -04:00
jonathanwxh-cellandjonathnwxh 7e447dfd40 Start Free Transform after placing an image from the file picker (#5390)
* test(ui): reproduce missing transform after menu placement

* fix(ui): start placement transform after choosing a file

* test(ui): exercise native linked placement file paths

* test(ui): pass context to existing menu wrapper tests

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-14 13:28:42 -04:00
FredApps c73bb0ed95 Fix custom-frame maximization with a top taskbar (#5237)
* fix(windows): respect top and side taskbars in custom-frame maximize

* Keep work-area correction stable for narrow taskbar reservations

* style: apply rustfmt to taskbar window correction

* fix(window): repaint after custom-frame work-area correction
2026-10-14 13:28:35 -04:00
Vikram BhaskaranandBrandon Thomas 8215b0b969 Undo and redo Camera Raw adjustments from the keyboard. (#4136)
* Undo and redo Camera Raw adjustments from the keyboard.

Changing a slider and pressing Ctrl+Z did nothing, because the dialog blocked application shortcuts and kept no history of its own. Each finished edit, including one slider drag, is now one step. The control channel accepts ui.undo and ui.redo.

Refs #4100

* Format camera_raw_ui.rs (rustfmt)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:28:27 -04:00
Sabbir Ahmed 4dec65f4ce fix(ui): guides draw over the Free Transform preview, under its box (#5086) (#5214)
The canvas drew guides before transform_tool::draw_overlay, so the moving
pixels of a layer under Free Transform covered them. Split the overlay:
draw_content paints the preview (plain, styled or warped) and the layers
above it right after the document, under the grid and guides; draw_overlay
keeps the box, handles, reference point, warp grid and split guides over
them, as in Photoshop. The hand-pan drag path does the same.

Test: guides_draw_over_the_transform_preview_and_under_its_box checks the
painted order preview < guide < box; it failed before the fix.
2026-10-14 13:28:18 -04:00
Valentin KleinandClaude Opus 5.5 e51ba6720d Actions: a recorded File > Save of a flat file saves the file it is played on (#3531) (#3537)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:28:11 -04:00
max 6a766345d3 Layer styles: editing effects no longer re-enables a disabled effect list (#3648) (#3799)
* Layer styles: editing effects no longer re-enables a disabled effect list (#3648)

* Layer styles: follow #3648's exact dialog steps in a test

Preview off shows the document as saved (the in-dialog stroke disable is
a pending edit), so the state the issue reports is Photoshop's preview
semantics, not a resurrected effect: the live preview honours the
disable, and OK applies it.

* style: cargo fmt
2026-10-14 13:28:04 -04:00
90b3f9e891 Large document: large damage over the GPU budget redraws in the background; open warms layer bounds (#4024) (#5083)
On top of #5001. Two UI-thread stalls it leaves on a 16000 × 10000, 30-layer PSB
whose layers exceed the GPU memory budget:

- GpuCanvas::composite checked the memory budget for full refreshes only. Hiding
  or showing a layer that covers most but not all of the canvas took the
  synchronous GPU path, paging every layer in on the UI thread. Damage of
  DEFER_PX or more now gets the same check, so it goes to the background redraw.
  Small edits keep the GPU path without the check.
- The first draw after a background open scanned every layer's content bounds
  on the UI thread (GPU planning). The open job now warms the shared tile bounds
  cache before handing the document over (warm_layer_bounds, cancellable between
  layers).

Co-authored-by: syncroot <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:27:58 -04:00
12a474d550 Interface: hide the Home screen's recent files (#4382) (#4387)
* Interface: a preference to hide the Home screen's recent files (#4382)

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

* i18n: drop the hu/tr 'Enable animations' lines, #4362 adds them

Both PRs added the same two lines; whichever merged second would leave a duplicate key (bundled_catalogs_are_consistent rejects it).

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

* docs: regenerate scorecard after merging main

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

* Merge main; fix i18n (duplicate ja row, missing sr row) and regenerate scorecard

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

* Drop the duplicate hu row

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-14 13:27:52 -04:00
Ivan · VanTan 5ae3ee0ae5 fix(ui): show brush tip thumbnails while dragging presets (#5203)
* fix(ui): show brush tip thumbnails while dragging presets

* test(app): wait for autosave sidecar revision before recovery assertions
2026-10-14 13:08:05 -04:00
Sabbir Ahmed 11c46b7121 fix(ui): swatches touch with no padding, as in Photoshop (#5103) (#5253)
The Swatches panel used the shared preset browser grid, with a 4 px gap
between cells and a rounded, bordered card for each. Photoshop's panel
packs flat squares edge to edge. Place gets a `flush` flag: no gap, no
per-cell border (only the selected and hovered cells are outlined) and
square corners. Swatches sets it; gradients, patterns, styles and shapes
keep the gap, now the named GRID_GAP and GRID_INSET constants.

Test: swatches_touch_without_a_gap clicks 1 px past the first swatch and
expects the second one selected; with the gap that point was empty.
2026-10-14 13:08:00 -04:00
MarekSpodBiedry 94fdd279e8 fix(puppet-warp): drop open Puppet Warp and other distort sessions when the active document changes (#5092) (#5123) 2026-10-14 13:07:55 -04:00
mroxsoandhighperfocused a076f617e0 fix(ui): give the Quick Selection tool its own icon (#5116) (#5184)
Quick Selection reused the Ellipse Marquee's dashed circle. It now has a
distinct glyph: Lucide's brush with a dashed selection arc around the
bristles (Photoshop's Quick Selection mark). A rasterized test asserts
the two icons read as distinct and that the arc actually renders.

Co-authored-by: highperfocused <[email protected]>
2026-10-14 13:07:48 -04:00
imSlicedBread 237a82b87f fix(ui): show feathering in layer mask views (#5342) 2026-10-14 13:07:41 -04:00
FinnNkandClaude Opus 5.5 51674512db perf(ui): the Info panel composites its Color Sampler readouts once per revision (#4528) (#4753)
While the Info tab was visible, every frame ran
`view.colorSamplers.list` through the command dispatcher, compositing
each sampler point (up to 10) through the whole layer stack, with layer
effects building a 1-pixel effect-map entry each time.

sampler_readouts keeps the readouts for (document, revision). Adding,
moving or deleting a sampler and every pixel edit bump the revision,
so the readouts refresh exactly when they can change and are shared
between frames otherwise.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:35 -04:00
Valentin KleinandClaude Opus 5.5 a7f7f1fd1e Edit › Menus: Menus, Keyboard Shortcuts, Preferences, Save and Exit can't be hidden (#5096) (#5183)
Hiding Edit › Menus (or Keyboard Shortcuts) left no way back into the
dialog, and Save, Save As, Exit and Preferences could disappear too.
edit.menus now refuses to hide them, the dialog greys their Visible box,
and the menus keep them even when a preferences file lists them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:28 -04:00
Pharbieandkwesi f053a924a6 fix(ui): Color Picker hex field accepts repeated pastes (#5398)
The hex field's six-character limit dropped any paste into a full field
(every paste after the first) and any paste with a leading #. A pasted hex
colour now replaces the whole code.

Co-authored-by: kwesi <[email protected]>
2026-10-14 13:07:22 -04:00
44bb43e94d Number fields: centre the number in its box (#5150) (#5226)
#1322 lifted the number's slot 1 pt above the box's centre because digits
have no descenders. Measured on today's mono font at 4x, the digits' ink
already sits within 0.2 pt of centre without the lift; with it the ink is
0.9 pt high, the focus outline has 1 pt of gap above and 3 pt below, and the
digits start 1 pt above the unit. The slot is centred again.

Tests: value_field_digits_ink_is_centred_and_level_with_the_unit renders the
field (Pro and Studio) and checks the digits' ink is centred and starts level
with the "%"; value_field_number_slot_is_centred_in_its_box replaces the
1 pt-lift test, and the #2823 slim-box test now expects centring. Both new
assertions fail with the lift restored.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:15 -04:00
0f3d2f133e Type: hovering a font previews it with the Character section shown too (#5152) (#5222)
The options bar and the Properties panel's Character section both draw the
"type-font" family picker, and the Character one is drawn later. With its
menu closed it removed the shared hover key every frame, so the options bar's
hover never reached the canvas. Only the picker whose menu is open now clears
the key; follow_hover already drops it each frame once nothing writes it.

Test: options_bar_hover_previews_with_the_character_picker_also_shown runs
the whole app (Pro theme, both pickers on screen), hovers a font in the
options bar and checks the canvas shows it, then that leaving the row
restores it. It fails without the fix.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:08 -04:00
Nicolas FalesyandClaude Opus 5.5 89a763a368 Display profile notice: one card for all displays without a usable profile (#5312)
Two monitors without ICC profiles (a common Linux desk, e.g. DP-4 and DP-5)
showed two identical "Display profile not used" cards at every launch. A
reading now posts one notice: one reason line per display, then the advice
line naming them all. Displays added by a later reading still get a card of
their own, and a reading with nothing new stays quiet, as before.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:06:47 -04:00
FinnNkandClaude Opus 5.5 8428645311 perf(gpu): keep the DX12 mip scratch textures for Intel adapters only (#5354)
The separate mip render targets work around reduced-preview corruption on Intel DX12 drivers (#892, verified on Intel UHD 620), but every DX12 adapter took them. Creating a scratch texture per level on every refresh costs about 1 ms of CPU time per brush dab on an NVIDIA DX12 adapter. Gate them to Intel under DX12; PHOTOCRAFT_SEPARATE_MIPS=1/0 forces either way. Part of #4515.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:06:39 -04:00
MarekSpodBiedry a904bd7a39 Frame tool: take type, shape and smart object layers into the frame (#4435) (#4441) 2026-10-14 13:06:32 -04:00
Ivan · VanTan df10129f6c fix(ui): fit Curves dialog width to its graph (#5160) 2026-10-14 13:06:25 -04:00
Michael HigginsandClaude Opus 5.5 42c5cc64be Canvas: themes without a dot pattern draw no dots texture (#4542) (#5093)
* Canvas: themes without a dot pattern draw no dots texture (#4542)

Switching the dark theme to Pro filled the empty workspace with red and
black blocks on the reporter's machine. The blocks repeat every 22 px,
the tile of the canvas-surround dots texture, so it is that texture
showing wrong contents.

Pro and ten other themes set canvas_dot to their canvas colour: they
have no dot pattern, yet still uploaded a texture for it and tiled it
over the whole pasteboard. dots() now returns None for them, so nothing
is uploaded or drawn. Studio, Studio Light and Solarized Dark, whose
dots differ from the canvas, are unchanged.

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

* Contributors: credit Higgs32584 by GitHub profile name

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:06:18 -04:00
mjohnson7cd 08d9c1d67d fix(transform): keep masked objects visible during transform (#5239) (#5319) 2026-10-14 13:06:12 -04:00
Gert Pellin - GPE fe8fa3b9bc Color Range: sample colours outside the active selection (#5244) (#5296)
The eyedroppers (the points/subtractPoints params, and clicks on the
canvas while the dialog is open) refused points outside the active
selection. Photoshop samples anywhere and only limits the result to the
selection, which Color Range already does; the refusal is gone.
2026-10-14 13:06:05 -04:00
Nigel Ihza Firdausy 08def9648d fix(ui): make Preferences modal resizable on desktop (#5170) 2026-10-14 13:05:59 -04:00
mjohnson7cd be64d2dcbc fix(windows): handle GPU initialization failures gracefully (#5191) (#5309)
On Windows with the automatic backend, GL adapters now rank after every
other adapter, WARP included. VMware SVGA3D over GL and GL-only old GPUs
drew a black window (#1788, #1647); WARP renders.
2026-10-14 13:05:53 -04:00
mroxsoandhighperfocused b37a148759 ui: remove the title-bar Discord button (#1737)
Co-authored-by: highperfocused <[email protected]>
2026-10-14 13:05:47 -04:00
Zach Schneider 72d4359999 Font preview test: skip installed fonts whose Arabic glyphs are blank (#5259) (#5261)
a_system_arabic_face_renders_both_samples took the first family whose
character map claims Latin and Arabic and expected its Arabic sample to
render. A font can map those characters to empty glyphs (for example
Black Mountain), so cargo test failed on machines that have one
installed. The test now renders every candidate and checks the first one
that actually draws an Arabic sample, and skips only if none does.
2026-10-14 13:05:26 -04:00
Nigel Ihza Firdausy b8ae1cd92f fix(brush): preserve Dual Brush tip spacing and scale its size with the primary brush (#4856)
* fix(abr): prefer dual brush tip spacing over wrapper spacing

* test(abr): cover conflicting dual brush spacing descriptors

* fix(paint): scale secondary brush tip when changing primary diameter

* fix(engine): preserve dual tip proportions through brush commands

* test(brush): cover secondary tip scaling across settings, strokes and presets

* style: apply rustfmt to Dual Brush ABR code

* style: apply rustfmt to Dual Brush ABR code
2026-10-14 12:47:23 -04:00
Valentin KleinandClaude Opus 5.5 2d5e60ad51 Compose: build a layer's effect-map identity once per render, not once per tile (#4437) (#4438)
effect_maps hashed the whole layer (Debug of effects and vector mask, a walk
over every pixel and mask tile, every child of a group) on every call, i.e.
once per render tile per effect layer. The identity depends on the layer
only; memoise it in the render's Ctx for layers that are part of the
document being rendered (their addresses cannot be reused during the render).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:51 -04:00
Valentin KleinandClaude Opus 5.5 88b2a21b33 Linked smart objects: read embedded WebP/GIF/BMP/EXR sizes from their headers (#5216)
source_size_probe decoded every embedded file other than PSD/TIFF/PNG/JPEG
in full just to learn its size. photocraft_codecs::probe_size reads the
size from the same decoders' headers (WebP chunks, the image crate's
GIF/BMP/TGA/ICO/QOI/HDR headers, flat EXR part headers), with the same
limit checks and the EXIF quarter-turn swap, so it agrees with decode
wherever decode succeeds. Deep EXR, AVIF and anything else still decode.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:44 -04:00
David MannandClaude Opus 5.5 8fb3298161 Color Balance: fold the tones into one Levels per channel (#3423) (#5339)
Photoshop doesn't apply the shadows, midtones and highlights curves one after
another: per channel it builds a single Levels (black point from shadows,
white point from highlights, one midtone gamma 2^((midtones + (shadows +
highlights) / 2) / 100)), and a gamma > 1 starts with slope 2^gamma at black
instead of a pure power curve. Applying the tones in sequence let a lowered
shadows and a raised highlights slider compound their gammas (up to 85/255 off
on mixed settings), and the pure power curve over-brightened the shadows of
the issue's red +100 / magenta -100 repro by up to 16/255.

Measured against Photoshop on generated 16-bit ramps and pure colours:
worst error 0.8/255 (was 85/255), the issue's repro 0.02/255 (was 16.5).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 12:46:38 -04:00
Wil 0eb008ce5d Actions: record layer selections by name and display the target (#2441)
* Record action layer selections by name and show their targets

* Use document pixel format in grouped-layer regression

* Document the named layer action UI with a synthetic capture
2026-10-14 12:46:31 -04:00
Valentin KleinandClaude Opus 5.5 3b45e8baee PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202) (#5206)
* PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202)

The flattened Bitmap/Indexed/Duotone path retried every composite_rgba8
error, including LimitExceeded, by allocating the refused RGBA buffer
(OOM abort on e.g. a 50000x50000 1-bit PSB). LimitExceeded now
propagates; the Multichannel fallback checks its output against the cap,
reserves fallibly, borrows the merged image, uses get for planes and
converts in TILE_SIZE bands. A failed composite now warns.

Checked usize arithmetic for alpha channel names, merged plane sizes and
the gradient map name length (overflowed on 32-bit targets).

Also: EXR part import drops a cache that never hit but cloned each
image; raw KRA tiles are borrowed; flat import detects the format once.

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

* PSD import: flat Background opacity check reads a band at a time (#5200)

flat_background read the whole surface into floats to see whether it is
opaque, undoing the banded conversion. Read TILE_SIZE row bands, stop at
the first transparent pixel, and drop the RGBA8 buffer before.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:25 -04:00
Gert Pellin - GPE 7b08b56531 feat(engine): layer.list, a read-only listing of the layer tree (#5059) (#5148)
The layers were only in document.inspect, together with history,
channels and much more, so agents looked for a layer.* command that
didn't exist.

layer.list returns the layers top-down with id, name, kind, visibility,
bounds, selected and active; groups list their children, or with
"flat": true every layer is one row with its depth and parent group.
It takes the same optional "document" index as document.inspect, is not
journaled and bad params are errors.
2026-10-14 12:46:18 -04:00
45f8921ae8 Free Transform: solid pixel stretch (edge-repeat sampling, selected-pixel bounds) (#3867)
Stretching a 1 px row or column faded out along the stretch: bicubic and
bilinear sampling read transparent pixels past the source edge, and with a
selection the edge was the whole layer's bounds (lifted pixels kept their
colour at zero alpha). Samples past the source rect now repeat the edge and
the outline is antialiased by coverage, and the lifted pixels' bounds are the
selected pixels'.

Fixes #3773

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:11 -04:00
8d2806f66c Expose shared Smart Object instances in inspection and replacement results (#4832)
* Smart Objects: inspect and Replace Contents show shared instances (#4075)

Placed layers that share a source have imported as one set of contents
since #2222, and Replace Contents changes every instance, but nothing
reported it: `linkGroup` is Link Layers, and `smartSource` can't tell
copies of one contents from independent copies of the same file.

document.inspect now gives each smart object `smartContents`: the
contents `id` its copies share and how many `instances` there are.
Replace Contents, Relink to File and Convert to Embedded/Linked list the
`instances` they changed.

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

* Test rendered caches after shared Smart Object replacement

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:04 -04:00
David MannandClaude Opus 5.5 d9eb46b485 Performance: the lower-stack cache follows an edit up the stack (P9 / P11, #5286) (#5288)
Repeating a duplicate or paste of a 12 MP layer cost as much as the first time because the
GPU compositor's lower-stack cache stayed at the checkpoint of the first edit after it was
filled: an edit higher up only checked that the layers below that checkpoint were unchanged,
so every refresh re-ran all passes above it (in the #209 layered document, 87 of 110 passes
over the 12 MP damage rect; the duplicate itself takes 0.3 ms, its tiles are already shared).

Each cached chunk now remembers its own checkpoint. A render captures at the highest
checkpoint below the edit, also where it resumed from a lower cached one, so the next
refresh of that edit (its undo, redo or repetition) resumes just below it. Chunks a refresh
doesn't reach keep their lower prefix; a chunk moved by a partial capture holds only that
rectangle afterwards. Validity is unchanged: a chunk survives only while the layers below its
checkpoint are untouched.

Release perf_scenarios, layered group, Windows 11, RTX 5070 Ti, 7 interleaved runs:
P9 p50 22-29 -> 12-17 ms, P11 p50 37-41 -> 11-18 ms; P8 and P10 fall too; P1-P7, P12 unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 12:45:59 -04:00
Michael HigginsandClaude Opus 5.5 a37fa8171b GPU compositor: pattern textures are freed on close, when idle and over a budget (#4971) (#5232)
The compositor kept every pattern texture it ever uploaded (RGBA32F,
16 bytes per pixel) until the device was lost: `forget_doc` and
`retain_docs` skipped them and no budget applied, so a long session
that opened and closed documents with pattern fills or pattern effects
only ever grew.

Each cached pattern now records the document that last painted with it
and the frame it last painted in:

- closing a document (`forget_doc`, `retain_docs`) frees its patterns;
- a pattern no frame has painted with for 240 frames is dropped;
- over PATTERN_BUDGET (256 MiB) the least recently painted go first,
  never one the current frame paints with.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:45:52 -04:00
Michael HigginsandClaude Opus 5.5 b059a6d8b3 ICC parser: checked arithmetic on offsets and counts from the file (#4976) (#5227)
Offsets and counts stored in a profile reach u32::MAX, which is
usize::MAX on 32-bit targets such as wasm32. Several places added to or
multiplied them before any bounds check, so a malformed profile could
panic (overflow checks on) or read from a wrapped offset (checks off).

- curv: reject an over-long count before computing the element length.
- mAB/mBA: the matrix and CLUT read through one bounds-checked slice at
  the stored offset instead of adding to the offset.
- mluc: the record offset is computed with checked arithmetic.
- XYZ: one bounds-checked slice instead of off + 4 and off + 8.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:45:46 -04:00
卢文彬andDeepSeek 6b9e173477 affinity: decoded layers are admitted against an aggregate pixel budget (#4960) (#5266)
The archive limits bound what one import extracts (256 MiB an entry, 1 GiB in
total), and one pixel layer is skipped past 64 megapixels, but nothing bounded
what the layers materialized out of that cost: each bitmap's declared
`BmpW`/`BmpH` sizes its RGBA output and its decode planes, with or without tile
data, so a small document with many layers could still allocate without end.

`Limits` gains `max_pixel_bytes` (1 GiB by default, the budget the other
importers allow decoded pixels). `Reader` admits a layer before `decode`
allocates it: the layer's RGBA joins the layers already materialized, and its
working planes are counted with them, since every earlier layer is still live
while this one decodes. Past the budget the import fails with `Error::Limit`,
which `photocraft-io` reports and falls back from to the embedded preview.

Tests: a synthetic 300-byte document declaring layers with no tiles at all - one
layer inside the budget imports, the second past it is rejected before
allocation, and a declared 2048x2048 layer is rejected under a 1 MiB budget.
The fuzz targets get a small pixel budget so mutations reach the new rejection
path.

Co-authored-by: DeepSeek <[email protected]>
2026-10-14 12:45:41 -04:00
shmuel biller 7b8e5034d0 Fix/scale marker rollback layer state 4975 (#5391)
* fix(engine): restore document targeting on failed compound edits

* test(engine): prove failed scale-marker rollback preserves layer targets

* fix(engine): resync tool mask target after restoring channel view

* test(engine): cover channel and tool-mask rollback after failed compound
2026-10-14 12:45:36 -04:00
Roberto DutraandClaude Opus 5.5 887dcd7ef9 Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284) (#5295)
* Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284)

On Wayland the app opens its window through Xwayland when a pen is attached
(#639), and it took any input device whose key bitmap has BTN_TOOL_PEN for a
pen. A virtual keyboard made with uinput (RustDesk's, for one) claims every
key code, that one included, so the window went through Xwayland on machines
with no pen at all. On a fractionally scaled desktop (GNOME 46 at 125%) the
X11 window is drawn at the logical size and upscaled by the compositor, so
the UI came out blurred even though Auto UI Scale already followed the
system's 1.25 (#1237): native Wayland, where winit negotiates
wp_fractional_scale_v1 and renders at the physical 3840 px, was never used.

A pen now also needs ABS_X and ABS_Y in its absolute-axis bitmap, as
libinput asks of a tablet tool; a keyboard has no axes. Regression tests
with the virtual keyboard's own bitmaps, a pen beside it, and a pen display.

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

* Docs: on a fractionally scaled Wayland desktop, Xwayland is blurred and native Wayland has no file drops (#5284)

Say where the trade-off is: a native Wayland window is sharp at 125%,
150% and 175% but takes no dropped files (#386); an Xwayland window takes
drops and pen input (#639) but is enlarged by the compositor. List the ways
to open through Xwayland and what counts as a pen.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-14 12:45:30 -04:00
Gert Pellin - GPE 56c5b355a4 feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5140)
* feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5060)

The Elliptical and Single Row/Column Marquee are options of select.rect
("ellipse":true, "line"), but the command-list filters only looked at
the id and the label ("Rectangular Selection"), so 'ellipse' and
'marquee' found nothing.

Commands can now carry search keywords. command.list / engine.commands
rows include them as "keywords", and the three filters (CLI commands
--filter, the engine.commands RPC and the MCP command_list) share one
matcher over id, label and keywords. select.rect gets the Photoshop
marquee names as keywords.

* Command list replies: document the optional keywords key

The reply-shape test from #3684 pins the exact keys of each row; select.rect
now also carries keywords, so the test and docs/control-protocol.md allow
that one optional string.
2026-10-14 12:45:24 -04:00
Valentin KleinandClaude Opus 5.5 3888e95e47 Text: non-finite numbers and negative indices from PSD files are sanitized (#4429) (#4433)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:45:19 -04:00
Abdul karim mia 1ab33d5113 Map .atn duplicate-into-new-document (Mk) to layer.duplicate (#5371)
* Map .atn duplicate-into-new-document (Mk) to layer.duplicate

Photoshop records Layer > Duplicate Layer with Destination: New as a
Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy
name), verified against a ScriptListener/XTensions export. Import maps
it to layer.duplicate {document: new}; export writes it back, so the
steps round-trip instead of becoming kept Photoshop steps that fail at
playback. A Dplc with a T document reference (another open doc) now
imports as a kept step rather than misplaying as a same-doc copy.

* Fix Rust formatting in ATN command tests

* Fix rustfmt formatting in ATN command tests
2026-10-14 12:45:11 -04:00
MarekSpodBiedry d12dc6b718 fix(layers): merging layers keeps pixels that lie outside the canvas (#5067) (#5124) 2026-10-14 12:45:05 -04:00
93a16218de Gradient Map is driven by the W3C luminosity, as in Photoshop (#5018)
Photoshop's Gradient Map picks each pixel's place on the gradient by
0.3 R + 0.59 G + 0.11 B, the luminosity of the blend modes. We used the
Rec. 601 grey (0.299, 0.587, 0.114). That is a few thousandths off, but
it is enough to land two levels away on steep stretches of a gradient.
The photoshop corpus's rgb8 and rgb16 gradient-map.psd now pass (they
were 2 levels off on 0.8-1.7% of pixels). Sampling the gradient through
a 256- or 1024-entry table didn't help; the weights did.

CPU (compose::adjust) and GPU (the shader's lum) both change. Lab
documents keep their Gradient Map driven by L, and grey pixels map as
before.

Photoshop oracles 145 -> 147 of 258; floor 145 -> 147.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:59 -04:00
Vikram BhaskaranandBrandon Thomas c4bf9eb045 Bound selection sampling buffers and report allocation failures. (#4594)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:54 -04:00
Vikram BhaskaranandBrandon Thomas e34cdcbcca Keep channel pastes transactional when pixel allocation fails. (#4588)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:47 -04:00
Brandon ThomasandClaude Opus 5.5 c699e3cda6 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:16:05 -04:00
Brandon ThomasandClaude Opus 5.5 7250e6e29a Tests: autosave retry waits for the finished write, not a mid-write listing
Since ed7df56a, list_recovery also lists a committed bundle whose sidecar
is not yet published (revision 0). The worker commits the manifest before
it writes the sidecar, so the retry test could catch the bundle between
the two and read revision 0 (seen on windows-latest; 215/300 local runs
on Linux under load). Wait until the entry carries the expected revision.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:21:47 -04:00
MarekSpodBiedry ed7df56a24 fix(format): recover autosaves with missing sidecars
Discover committed autosave bundles whose JSON sidecar was never published. Rebuild recovery metadata from the readable manifest and retain these bundles during orphan cleanup.

Fixes #4967
2026-10-14 02:02:34 -06:00
Valentin KleinandClaude Opus 5.5 706638b284 Tests: bad-layer-id checks use an id that can't exist (#3343) (#3351)
* Tests: bad-layer-id checks use an id that can't exist (#3343)

Layer ids come from one process-wide counter (shared with document and
smart-object ids, and bumped by ensure_ids_above on file loads). A full
`cargo test` run passes ids like 9999, so a layer a test just created can
get the "bad" id, the command succeeds and the assertion fails at random
(seen on CI in type_extra_cmds::tests::vertical_orientation_renders_columns_inside_the_canvas).

Use u64::MAX, which the counter never reaches, for every "no such layer"
sentinel in the engine and ui-egui tests. Test-only change.

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

* Tests: use u64::MAX for bad-layer-id sentinels added on main since (#3343)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:43 -04:00
Valentin KleinandClaude Opus 5.5 74812f4874 IO: abr fit and gradient eval check their own bounds (#5218)
fit() computed (y + 1) * h in u32, which passes u32::MAX for a plane
over 2^32 / max rows; the ABR and pattern parsers' edge caps keep real
inputs below that, but the guard lived in another crate. Spans are now
computed in u64. gradient_bake::eval indexed stops[0] and stops[i + 1]
relying on bake's two-stop check; it now handles an empty or lone stop
itself. Same results for every input that worked before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:38 -04:00
Timothy DalyandClaude Opus 5.5 ff57499774 PSD: Mt16/Mt32 blocks mark the merged image's transparency (#5178)
The merged image carries its transparency in the first extra channel
when the layer count is stored negative, or when a "Saving Merged
Transparency" global block (Mtrn, Mt16, Mt32) is present. Photoshop
saves 32-bit documents the second way: a positive layer count, Mt32,
and a fourth channel named "Transparency" (psd-tools transparentbg).
merged_has_alpha only knew the first, so opening such a file added a
"Transparency" alpha channel to the document, and the oracle compared
it as if it were opaque white (83.7 % of pixels off; now 0.9 %, all on
the shape's edge).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:32 -04:00
7ad75b5db2 Layer masks: Reveal Selection drops the selection, as Photoshop does (#5251)
A mask made from the selection takes the selection with it in Photoshop: after
Layer > Layer Mask > Reveal Selection (the Layers panel's mask button with a
selection) the marching ants are gone. PhotoCraft kept the selection, so the
usual next step, Invert (Cmd/Ctrl+I) on the new mask, inverted only the part
inside the old selection and left the whole mask black, instead of flipping
the mask. Hide Selection (the Alt-click variant) already dropped it.

Reveal Selection now clears the selection in the same history step as the
mask: one undo brings the selection back and removes the mask.

Tests: adjust_cmds::tests::reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask
(8/16/32-bit). Tests that deselected right after Reveal Selection no longer
need to (Deselect is disabled without a selection); layer_mask_depth and the
automation agent tasks now expect the selection to be gone.

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:27 -04:00
c7366b8938 Drop shadow: knocked out beneath the whole shape when the layer's mode blends with it (#5304)
Layer Knocks Out Drop Shadow only knocked the shadow out through see-through fill: at
100 % fill the layer was taken to cover it. That holds in Normal mode, but a layer in
Exclusion, Difference, Multiply, Screen and the rest blends with what's beneath it,
shadow and all: a white Exclusion bar with a drop shadow over black type came out as a
blank white bar instead of white type on black (Photoshop's merged image of the same
file shows the shadow kept out from under the bar), and a Multiply layer was darkened by
its own shadow. shadow_see_through() now knocks it out wholly in those modes, for the CPU
compositor and the GPU plan alike.

Co-authored-by: Daniel van Lith <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:23 -04:00
c0cb78090b panic_hunt: count panics that Session::execute contains (#4982) (#5219)
Session::execute catches a command's panic and returns an ordinary Err (the
last-resort guard in jobs.rs), so the unwind never reached panic_hunt's own
catch_unwind and a panicking command counted as a pass.

The harness now installs a panic hook that counts every panic in the process,
including on rayon workers, and a run fails if the count moved. Runs are
serialised so a panic is charged to the run that caused it. No production
code changes.

Test: contained_panics_count_and_graceful_errors_do_not. A contained panic
fails the run and a graceful Err passes; the first assertion fails without
the hook. The full --ignored fuzz stays green, so no command panics today.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:18 -04:00
Nicolas FalesyandClaude Opus 5.5 7a0e60c104 test(engine): add Color Range fuzziness 0 exact-match test (TOOL-214-3) (#3581)
* test(engine): add Color Range fuzziness 0 exact-match test (#214)

Adds an explicit test for Sampled Colors at Fuzziness 0 (point sampling
and hex colors) asserting that exact matches are fully selected and
adjacent shades are rejected. Updates scorecard TOOL-214-3 to done.

* docs: regenerate scorecard (TOOL-214-3 done)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017WeVmjnnPPKdxVTpr2Vjtw

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:13 -04:00
Gert Pellin - GPE 7e28900df3 layer.setProps: refuse opacity and fill outside 0..1 (#5242) (#5291)
Opacity and Fill are fractions in layer.setProps but percentages in the
Layer Style commands, and a value above 1 was clamped silently: 55 gave
an opaque layer. Out-of-range or non-numeric values now fail with an
error that names the range, and the layer is left alone.
2026-10-14 01:59:09 -04:00
Gert Pellin - GPE 15f7618504 Object Selection: say so when the sampled layer is empty (#5246) (#5298)
select.object samples the active layer by default. On an empty layer
(scripts often add one above the photo) it found nothing and returned
without an error; the failure only surfaced at the next command that
needed a selection. It now fails with an error that names the cause and
the sampleAllLayers option, also when the layer's pixels lie outside the
rectangle or point.
2026-10-14 01:59:04 -04:00
Gert Pellin - GPE 4bbcede572 Fibers: fill an empty layer, as Photoshop does (#5241) (#5289)
Fibers renders from the foreground and background colours without
reading the layer, but its output area was the layer's content grown by
a zero halo: on an empty layer that is empty, so nothing was drawn (and
a following transform found nothing to transform). On a partly filled
layer only the content's bounding box was filled. It now writes the
reference bounds (the selection's, else the canvas) like the global
filters, without reading the whole layer as they do.
2026-10-14 01:59:00 -04:00
Gert Pellin - GPE 2da2889546 Background: an opacity, fill, blend mode or Select and Mask layer mask makes it Layer 0 (#4837)
The Background has none of them. layer.setProps applied them to it as it
was, and Select and Mask's Layer Mask output gave it a mask. Like
Photoshop (and like PhotoCraft already does for moving, transforming,
erasing, styles and Layer > Layer Mask), it now becomes a normal layer,
"Layer 0", first. Values that leave its look alone (opacity and fill
100 %, Normal) keep it the Background.

Fixes #4836
2026-10-14 01:58:55 -04:00
Vikram Bhaskaran 7c916d9ecd Validate selection modification limits before allocating the canvas mask. (#4591) 2026-10-14 01:58:51 -04:00
Vikram Bhaskaran 79e2107f71 Report channel copy allocation failures without losing the clipboard. (#4587) 2026-10-14 01:58:46 -04:00
33b360d5ee Reject clipboard origins that cannot form a new document (#4586)
* Reject clipboard origins that cannot be moved into a new document.

* Fill Clip.resolution in the unrepresentable-origin clipboard test.

main added resolution to Clip; the synthetic overflow fixture omitted it and failed engine lib tests.

* Format the clipboard origin test (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-14 01:58:42 -04:00
Gert Pellin - GPE 077e513686 Color Range presets: weigh dark colours like Photoshop (#5243) (#5293)
Reds/Yellows/Greens/Cyans/Blues/Magentas selected hue closeness times
chroma. Measured against Photoshop 2025 on 126 colours per preset
(Reds, Yellows, Blues; hue +-40 degrees, saturation 5..100 %,
brightness 33..100 %), Photoshop also multiplies by (2 - max): bright
colours were already exact, darker ones came out up to 61 levels short.
With the factor every sample matches within one level. The muted navy
#3d3f54 from the issue now gets 35/255, as in Photoshop (it was 21):
Photoshop also leaves it below 50 %.
2026-10-14 01:58:36 -04:00
Ivan · VanTan e12b7f7352 fix(prefs): preserve selected themes during legacy migration (#5179) 2026-10-14 01:58:31 -04:00
Zer0C00L01andCursor Agent a954194277 PSD import: ResolutionInfo stays pixels per inch when the display unit is cm (#5280)
hRes and vRes are stored as pixels per inch. The unit that follows only
controls how that number is shown, so a 300 ppi file with unit cm opened
at 762 ppi. Export already writes the document ppi and does not divide
by 2.54.

Fixes #5201

Co-authored-by: Cursor Agent <[email protected]>
2026-10-14 01:58:27 -04:00
Michael HigginsandClaude Opus 5.5 2388d82165 Docs: oracle floor, open issues, workspace size and layer filters match main (#5376) (#5382)
- ROADMAP.md: the oracle floor is 145 of 258 (crates/io/tests/corpus.rs,
  gaps.md G1), not 133 of 256; 953 open issues, not 579.
- docs/architecture.md: ~539,000 lines in ~1,310 files and ~8,040
  #[test] functions, recounted 2026-10-13.
- README.md: "a layer kind filter" instead of "layer filters"; only the
  Kind filter exists.
- AGENTS.md: drop an empty bullet at the end of section 7.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:58:22 -04:00
LorenzoFasolino d11be6de78 docs: drop stale missing-tool rows, only Art History Brush is missing (#5063)
Task T-0002 del lab artcraft-lab.

File toccati:
   docs/brush-parity.md      | 5 +++--
   docs/gaps.md              | 6 +++---
   docs/target-app-parity.md | 5 +++--
   docs/ui-parity.md         | 7 ++++---
   4 files changed, 13 insertions(+), 10 deletions(-)

Evidenze: artifacts/T-0002/ (nel lab, non in questo repo).
2026-10-14 01:58:18 -04:00
wwew 9df59c96b8 docs: write product name as PhotoCraft in development guide (#5228) 2026-10-14 01:58:14 -04:00
skylar taylor-barrick 369704ac55 Merge pull request #4599: native Windows printer settings and RGB printing
Share native Windows driver settings and RGB printing through craft-print
2026-10-13 22:38:25 -06:00
Tanish Manku 583425b4df Stroke latency trace: PHOTOCRAFT_STROKE_TRACE times each frame of a brush stroke (#1343) (#4403)
Testers report ~50 ms input-to-pixel while painting on Linux and macOS, and asked for a
per-stage breakdown. With PHOTOCRAFT_STROKE_TRACE=1 the app prints, per stroke, p50/p95 of
the frame interval, pointer moves per frame, the live stroke's render, the canvas refresh
(composite + upload), the UI build, eframe's CPU time (UI, tessellation, submission) and the
remaining vsync/swap-chain wait, with the GPU backend; =<file.csv> also appends every frame.
The last summary is in the control channel's perf.strokeTrace.

RTX 5090, Vulkan, 120 Hz: interval 8.33, cpu 2.2 (ui 0.9, stroke 0.8, canvas 0.4), wait 6.0 ms.
2026-10-13 18:09:37 -07:00
skylartaylor a9ac922fc1 Align printer menu regression with the merged system-printer cache 2026-10-14 02:21:09 +02:00
skylartaylor 6c1a3590f5 Remove obsolete app-owned Windows printing layer comment 2026-10-13 21:33:44 +02:00
skylartaylor 235d4e22fa Merge current main while preserving native printing and system printer menus 2026-10-13 21:18:34 +02:00
skylar taylor-barrick 3a2d657b9a Keep custom paper for Windows PDF exports and repair merged catalogs 2026-10-13 20:43:45 +02:00
Scott C Hughes 99e1138877 Stroke layer effect: the outer edge stays round at wide sizes (#3646) (#3650)
* fix(compose): a wide stroke effect keeps a round outer edge (#3646)

A stroke's outside distance came from the 5x5 chamfer, whose level sets are polygons: at a
245 px outside stroke the boundary wanders ~10 px into visible flat facets. Use the exact
Euclidean distance (edt_nearest, already used for glows and chiselled bevels) for the outer
edge; the inside edge and spread/choke keep the chamfer, whose sub-pixel edge seeding is
fitted to Photoshop at small offsets. A point source dilated by 60 px now stays within 0.5 px
of a circle (was 2.5 px).

Tests: a_wide_outside_stroke_is_round_not_a_polygon; stroke_edges_use_the_exact_euclidean_distance
(a 3 px outside stroke's (1,3) corner coverage is 0.838, the exact distance; it was 0.764).
Full photocraft-compose suite: 206 pass; fmt and clippy clean.

* fix(gpu): a wide stroke keeps its exact outer edge on the GPU (#3646)

1080008a fixed the CPU compositor's stroke outer edge to use the exact
Euclidean distance instead of the 5x5 chamfer, whose level sets are
polygons and facet a wide stroke. The GPU compositor builds the outer
stroke field from FieldKind::StrokeOutside, which still maps to the
chamfer, and the canvas is drawn on the GPU, so the fix did not reach the
app.

Add FieldKind::StrokeOutsideExact (dist_outside_by Euclidean) used only by
the stroke. The spread of drop shadows and softer outer glows keeps the
chamfer on purpose (fitted to Photoshop, #224), so StrokeOutside keeps its
meaning. StrokeOutsideVector is exact too, matching the CPU vector outline
path.

Tests: crates/gpu/src/fx.rs a_raster_stroke_uses_the_exact_outer_field
(stroke uses the exact field, shadow spread keeps the chamfer), and the
banded/whole field test covers the new kind.

* fix(stroke): a stroke keeps the chamfer narrow and the exact edge wide (#3646)

The exact Euclidean outer edge (#3646) fixed a wide stroke's faceting but
regressed Photoshop's own composites against the PSD flatten oracle: the
fitted 5 × 5 chamfer matches Photoshop at small offsets, and only at a large
outside width do its octagon facets show. Pick the metric by the stroke's
outside width instead: below `STROKE_CHAMFER_MAX` (20 px) keep the chamfer and
its sub-pixel edge seeding, at or above it use the exact Euclidean transform.

The measured chamfer deviation from a point source is 0.027 · r px — 0.48 px at
r = 18, the widest outside stroke any Photoshop oracle matches with the chamfer,
and 1.6 px at r = 60, where the boundary visibly facets. Half a pixel is reached
at r ≈ 19, so the switch sits at 20. Inside stroke edges keep the chamfer.

The CPU and GPU compositors pick the same field by the same width
(`stroke_outside_exact`); `FieldKind` gains `StrokeOutsideVectorChamfer` for a
narrow shape-layer outline, while the shadow / glow spread keeps its chamfer.

io corpus: 140 → 148 (floor 146) and psd-tools 231 → 237 (floor 236), both back
to main's counts with no file newly differing; a 60 px outside stroke stays
round (`a_wide_outside_stroke_is_round_not_a_polygon`).
2026-10-13 13:24:53 -04:00
Elvis33LE 1bcb662828 Deep EXR: open cropped renders on their full display-window stage (#4907)
A renderer's data window is its crop of the frame; the composition lives
on the whole display window (our MY27 renders: data 1782x1267 at
(1109,1100) inside a 4000x3000 stage). decode_deep now validates the
display window against the limits and reports it as stage + origin on
DeepImage; flatten pads the composed region to the stage at its offset
(transparent elsewhere) and tags the image with a DisplayWindowStage
warning. Files without a usable display window open as before.
Includes a cropped-deep generator and a stage/offset oracle test.
2026-10-13 13:24:46 -04:00
Ikki Nishiharaandharanishi 2571423bb3 fix(gpu): contain errors in unwatched compositor work (#4894)
* fix(gpu): contain errors in unwatched compositor work

* test(gpu): gate recovery fixtures on format capabilities

---------

Co-authored-by: haranishi <[email protected]>
2026-10-13 13:24:37 -04:00
Gert Pellin - GPE 5064d80b1e TIFF: write and read EXIF as the file's own Exif and GPS directories (#4876)
A flat TIFF dropped EXIF on export ("EXIF not supported") and ignored it
on import. TIFF is EXIF's own container: IFD0 points to the Exif (34665)
and GPS (34853) directories and holds the descriptive tags.

- Export: the EXIF block's Exif and GPS directories are written as
  directories outside the page chain, IFD0 points to them, and IFD0's
  descriptive tags (ImageDescription, Make, Model, Software, DateTime,
  Artist, Copyright) are added unless the text metadata already wrote
  them. Values are copied as bytes, reordered to the file's byte order,
  so unknown tags survive.
- Import: a classic TIFF with an Exif or GPS directory gets an EXIF
  block built from them and IFD0's descriptive tags.
- TIFF's format caps now include EXIF. Layered TIFFs keep EXIF as
  resource 1058 as before (read back unchanged); the layered importer
  already preferred the TIFF's own EXIF when there is one.

exiftool and Photoshop 2025 read the Exif and GPS tags of such a TIFF.

Fixes #4875
2026-10-13 13:24:27 -04:00
David MannandClaude Opus 5.5 b41e34c20f Rulers: drag the zero point from the ruler corner, double-click resets it (#2955) (#3505)
Drag out of the rulers' upper-left corner to move the zero point: cross hairs mark where it
lands, Shift snaps it to the ruler ticks and View > Snap To targets apply otherwise; dropping it
back on the corner cancels. Double-clicking the corner puts it back at the image's top-left.
The rulers then read 0 at the zero point (negative left of and above it) and the Info panel's
X/Y measure from it.

The zero point is a document field (`ruler_origin`, document pixels), saved in .pcraft with
`#[serde(default)]` so older files open at the top-left. It is set by two undoable engine
commands, `view.setRulerOrigin {x, y}` and `view.resetRulerOrigin`, so agents can drive it.
It is not written to PSD: the public PSD spec documents no resource for it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 13:24:17 -04:00
Gotya-OFFCIALandGotya-OFFCIAL 34ce5766d2 raw: decode Panasonic RW2 RawFormat 3 and 4 (vendor-compressed) (#3286)
The compressed RW2 layouts of earlier Lumix bodies (e.g. the DMC-GX7)
decode at full sensor resolution instead of falling back to the camera's
embedded JPEG preview. The layout was established by observation of
sample files (fixed 16 bytes per 14 pixels, predictor groups with
per-parity predictors, page-permuted downward bit order); the black
level of these files reads 15 counts low.

Adds round-trip tests through a mirrored synthetic encoder, cursor and
predictor-group unit vectors, and updates the support matrix.

Co-authored-by: Gotya-OFFCIAL <[email protected]>
2026-10-13 13:24:09 -04:00
optimalbyteeandoptimalbytee 50c4f72af7 Speed up large-radius Unsharp Mask with guarded FFT convolution (#2987)
* Speed up large-radius Unsharp Mask with guarded FFT convolution

* Address Unsharp Mask review feedback

---------

Co-authored-by: optimalbytee <[email protected]>
2026-10-13 13:23:55 -04:00
Elvis33LE fc0a579a4a PSD: parallel RLE for the merged image; layer channels decode and gather on all cores (#2754)
Saving a 36 MP document spent most of its time packing the merged image's
RLE rows one after another on one thread (packbits over ~144 MB), and
importing it decoded each layer's channels sequentially. The merged-image
row packer now runs in parallel chunks that concatenate in order (rows are
independent; errors surface like the sequential path's), the layer gather
runs in row bands on all cores before the per-channel RLE, the layer's
channels decode side by side, and the full-surface clone per layer is
gone.

Measured on a 6000×4000 8-bit document with a photo, a paint layer and a
Levels adjustment: save 2.86 s -> 2.55-2.64 s, open's import stage
1.12 s -> 1.07 s (psd_to_document 745 -> 564 ms). The remaining save time
is dominated by the merged composite render itself (~0.7 s).
2026-10-13 13:23:44 -04:00
ragtuxandClaude Fable 5.1 995bc89c40 Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur (#2073)
* Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur

`edit.autoBlendLayers` with `method: stack` and `depthMap: true` adds a hidden gray Depth layer, the layer each pixel is sharpest in (black = the bottom layer, white = the top), from `photocraft_algo::dff`: the ring difference filter as focus measure, guided-filter aggregation with the merged picture as guide, a streaming peak search with Gaussian interpolation and a confidence, edge-aware weighted least squares solved by multigrid-preconditioned conjugate gradients, and guided upsampling. Once on a layer mask it drives Lens Blur's depth map.

Closes #2067.

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

* Depth from focus: a guide or plane of the wrong length is the empty map

Review follow-up on #2073: `depth_from_focus` is public and indexed
`guide[i]` without checking that `guide` and every `luma` plane are
`w × h` long. It now returns the empty map (depth 0, confidence 0) for a
mismatch, before anything is read, and the test covers a short guide, a
short plane and a size that matches neither.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-13 13:23:35 -04:00
DomiLuebbenandClaude Sonnet 5.5 d35725200d i18n(de): translate the labels that were still English (344 entries) and fix three display bugs (#4008)
* i18n(de): translate the labels that were still English

A walk through the German UI with a lookup recorder (every menu item, every
tool's options bar, every dialog it opens) found 344 texts that fell through
to English. They were invisible to the existing i18n test, which only scans
tl!("literal") calls: the labels of the schema-driven filter dialogs are built
from the engine's parameter names ("centerX" -> "Center X"), and status and
system-info templates are looked up at run time.

- de.tsv: 344 new entries. Filter, Render (Flame, Picture Frame, Tree, Lighting
  Effects), Blur Gallery, Lens Correction, Color Lookup, Shadows/Highlights,
  Apply Image / Calculations, Layer Style blending options, Find and Replace,
  Document Info, the keyboard shortcut editor's commands, system info and the
  tool names that were missing (Color Sampler, Vertical Type, Patch).
  Written from the plain meaning of each label, in the vocabulary the catalog
  already uses (see its header: no proprietary localisation resource consulted).
- "Light" was translated as "Light": the appearance mode in Preferences and the
  toolbar tooltip read "Light" next to "Dunkel". It is "Hell" now (Camera Raw's
  Light section and the font weight have their own contexts).
- Export As: the dialog title was built with format!() and could not be
  translated; it goes through the catalog now.
- Names such as "iPhone 14 Pro Max" (the artboard dialog's preset choices) were
  split like camelCase keys and read "I Phone 14  Pro  Max"; a name with a
  space is shown as it is. Test: display_names_are_not_split_like_camel_case_keys.

Left English on purpose: product and file-format names, units, document and
layer names the engine creates ("Untitled", "Background": other code compares
the latter), brush and swatch preset names (data), and words that are the same
in German (Filter, Info, Zoom, Median...).

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

* i18n(de): drop the process note and give the new sections English headings

The first heading described how the entries were found (and when), which is not
catalog content, and the other headings were German while the rest of the file
uses short English section comments. Only comment lines change.

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

* i18n(de): US spelling in a section heading

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:21:38 -04:00
Timothy DalyandClaude Opus 5.5 be7a1f80fc Blur Gallery: Field Blur pins on the image (#3373) (#3801)
* Blur Gallery: Field Blur pins on the image (#3373)

Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.

The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.

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

* i18n(vi): translate the Field Blur pin strings

main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* Blur Gallery: Alt+Cancel Reset starts again from one pin

main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Field Blur pin strings

main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the Field Blur pin strings

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 13:10:11 -04:00
kayrockscreenprintingandClaude Opus 5.5 50631b54f7 Profile prompts on open: one file at a time, before it opens, and the answer goes to that file (#3471)
* Profile prompts on open: one file at a time, before it opens, like Photoshop

With Ask When Opening on, files opened together put up every Embedded
Profile Mismatch prompt at once, each answer went to the active document
rather than its own file, and no prompt named its file. Checked in
Photoshop 27.11: one prompt at a time, naming the file, before that file
opens; OK opens it and asks about the next; Cancel opens neither it nor the
files still to come.

A file that asks is now held back until answered: the engine's background
open parks the decoded document (ColorState::open_question,
Session::take_held_open) and the shell queues it (open_prompt). The answer
is applied to that document (ColorState::resolve_open) before it opens.
Files that finish reading meanwhile wait their turn. A developed raw has no
embedded profile, so it doesn't ask.

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

* Profile prompts on open: the prompt's text is localized in every catalog

The two messages are templates now, one line each so the catalog check covers
them: the sentence naming the file and the mode, "Embedded: {embedded}" and
"Working: {working}". The mode reads as the catalogs name it (French "RVB"),
and the file's name goes in last, so braces in it are never taken for a
placeholder. Rows added to all 17 catalogs; main's two "colour profile"
templates (Czech and Italian only) are removed, nothing uses them.

The Camera Raw control test from main expected a mismatch prompt when a raw
opens. With this change a developed raw is not asked about (it has no embedded
profile), so the test now checks that no prompt stands before Camera Raw.

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

* Profile prompts: rows for the hu, sr and tr catalogs; tests hold for any read order

The prompt's four templates join the Hungarian, Serbian and Turkish catalogs
that landed on main.

Files read in the background ask in the order their reads finish, which is the
order given unless a later read overtakes an earlier one. Three tests assumed
the order given and one failed once under a loaded test run; they now follow
whichever file the prompt names (and still require the order given for files
read on the spot).

New test: a file held for its prompt still reports type in a font that isn't
installed, once.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 13:10:04 -04:00
jonathanwxh-cellandjonathnwxh 01b354a340 Add filename templates to Batch export (#4466)
* Add filename templates to Batch export

* Keep Batch naming evidence out of the shared gap-summary header

* Avoid catalog append conflicts for Batch naming labels

* Keep Batch naming labels clear of catalog append conflicts

* Keep Batch naming details in the automation parity row

* Separate Batch documentation from evolving Actions details

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 13:03:02 -04:00
jonathanwxh-cellandjonathnwxh 5f8733c74d Speed up retouch blur convolution with exact-output coverage (#4511)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:56:28 -04:00
25a82978b2 fix(segment): let Remove Background's cancel stop Select Subject's min cuts (#4878)
Select Subject ran every graph cut (the GrabCut iterations and the
full-resolution band re-cut in `refine_band`, tile by tile on rayon)
through the non-cancellable `grid_cut`, so the Remove Background job only
saw Cancel/Esc before and after the whole "Finding subject" step.

Add `select_subject_with`, `grabcut_with` and `finish_region_with`, which
take an `Interrupt` and return `Err(Cancelled)`: each band tile checks it
before reading pixels and its cut runs through `grid_cut_cancellable`, so
in-flight tiles stop at the max-flow's next poll and unstarted ones never
start. Remove Background runs the step under `JobCtx::stage`. The old
functions keep their signatures and results (they pass `Interrupt::NONE`),
so `select.subject`, Object Selection and the layer menu are unchanged.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:56:21 -04:00
Sir Starch 3260f6abb9 fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928) (#4950)
* fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928)

In 16-bit and 32-bit PSD files saved by Photoshop, the Layer Info section
either contains 0 layers or 8-bit compatibility layers, while the actual
16-bit/32-bit layers are stored in the global Lr16/Lr32 tagged blocks.

Previously, candidate_seen was initialized to layer_info.is_some(), which
caused PsdFile::from_bytes to skip the Lr16/Lr32 block whenever the section
was non-empty. This left the document with empty layer lists or 8-bit records
that failed channel decoding at depth 16/32, causing all layers to be empty
and invisible.

Prioritize the depth-specific tagged block (Lr16 for 16-bit, Lr32 for 32-bit)
over the compatibility section.

* test(psd): improve 16-bit multi-layer compatibility regression tests
2026-10-13 12:56:15 -04:00
MrCloudy2andClaude Opus 5.5 57624bd02e PSD: Backgrounds survive a round trip through PhotoCraft (#3560)
* PSD: Backgrounds survive a round trip through PhotoCraft

Photoshop opens a PSD's bottom layer as its Background when that layer's
record has no transparency (-1) channel. PhotoCraft always wrote one, so
every file with a Background came back from PhotoCraft with an ordinary
"Layer 0"-style bottom layer instead. Measured by opening PhotoCraft's
re-exports of the 427 corpus files in Photoshop 27.11: 317 lost their
Background this way.

- Export: the bottom top-level layer is written without the -1 channel
  when it is a locked (transparency and position), unclipped, unmasked
  raster that is opaque over the whole canvas at full opacity and Normal,
  as a Background always is; flags as Photoshop writes them (bit 4 marked
  meaningful, and "pixel data irrelevant" on adjustment layers).
- Import: like Photoshop, a bottom raster layer whose record has no -1
  channel becomes the Background (locked, named "Background") whatever
  the file names it: psd-tools' "Фон" and ag-psd's "bg" or "Layer 1" open
  as Backgrounds in Photoshop.

Of the 227 corpus files whose bottom layer is a Background, PhotoCraft's
export now writes all 227 the way the original does (it was 0), and no
other bottom layer turns into one. Corpus oracles and round trips are
unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258 on this
commit; every round trip renders the same).

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

* PSD: a flat Bitmap / Indexed file's image opens as the locked Background

Probe 7 (PhotoCraft's re-exports opened in Photoshop 27.11): with the
Background fix, 314 of 318 Backgrounds survived; the other four were
psd-tools' flat 1-bit Bitmap and Indexed files. Photoshop opens a flat
file's image as its Background, but PhotoCraft's import of these modes
left the "Background" layer unlocked, so the export wrote it as an
ordinary layer. An opaque flat image now opens locked (as flat PNG/JPEG
and layered-but-flattened PSDs already did) and saves as a Background.

Corpus unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258;
every round trip renders the same).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:56:08 -04:00
Ian Swope 74cb293570 Large document: full CPU canvas redraws run in the background, visible part first (#4024) (#5001)
When the wgpu compositor can't draw a refresh (layers over its memory
budget, or a feature it doesn't cover), the CPU compositor redrew the
whole document inside the draw call: minutes of Not Responding on a
multi-gigabyte PSB. With background jobs, a refresh of 4 MP or more is
now composited by a worker in 1024 px blocks, the visible ones first;
each frame uploads the finished blocks and the canvas keeps its previous
contents until they land. An edit while it runs draws its own damage and
restarts the worker on the edited document with the blocks still to
draw. The status bar shows Redrawing N%. Web and tests keep the
synchronous path.
2026-10-13 12:49:58 -04:00
9a4d925b34 Brush: remember the last selected brush across restarts (split from #2516) (#3472)
* Brush: remember the last selected brush across restarts (split from #2516)

The persistence half of #2516 on its own, so it can merge and be maintained
apart from the brush-outline half. The tool's brush (settings, tip
references, and the name of the preset it was picked from) is saved once the
pointer is up and restored at launch: options bar, painting cursor and
Brushes panel highlight all start where the session left off.

Review fix from #2516 -- no per-frame work:
- `ToolState::brush_rev` is bumped by every brush/preset setter
  (`tools.setBrush`, preset save/delete/rename/setCurrent/deleteGroup,
  `brush.defineFromSelection`, `brush.presets.importAbr`, the store's tip
  re-linking, and the shell's tool-switch/reset paths); `remember_brush`
  serialises only when it moved since `brush_saved_rev`, so an unchanged
  brush costs a counter compare per frame, not a clone and a JSON dump of
  the whole brush (sampled tip previews included).
- Restoring counts as saved: the launch value is never rewritten.

Bitmaps stay out of the preferences document: remembered tips and patterns
are references (a loaded stored tile drops its full bitmap), and a sampled
bitmap too big to embed is skipped, keeping the previous value so a preview
never enters the prefs file. A malformed remembered brush falls back to the
default.

docs/scorecard.md regenerated with `cargo xtask scorecard` (34 of 154).

Tests: engine 1267 lib (5 new: revision gating, rejected changes, picked
preset, restore round trip, no embedded bitmaps), ui-egui 1982 lib; clippy,
layers, wasm green.

* rustfmt the StoredTile line and regenerate the scorecard after the main merge

* engine tests: destructive Levels keep the signed float overshoot at 32 bits (#2592)

* Scorecard: regenerate after merging upstream/main

* Export As: compare batch paths without the Windows separator

* font fix ?

* fix(ui): only Filter menu dialogs get the in-dialog preview window

#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.

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

* cargo    fmt --check max_width = 160 fix

---------

Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:48:41 -04:00
jonathanwxh-cellandjonathnwxh d0fc974aae Wrap Clone Stamp strokes and sampling in Pattern Preview (#4997)
* Wrap Clone Stamp sampling and strokes in Pattern Preview

* Preserve per-dab flow and source alpha across pattern seams

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:48:33 -04:00
David MannandClaude Opus 5.5 a666ab5a85 History: jump to a state in one step (history.selectState) (#2966) (#3533)
Clicking a History panel state ran Edit > Undo / Redo once per state. Each
step bumped the document revision, so the canvas could not tell what the
jump changed and recomposited the whole document (re-uploading every
changed layer on the GPU path).

history.selectState {state} returns the document to a state (numbered as
the panel lists them, redo states included) in one command: the history
moves by pointer swaps, the document becomes current once, with one
revision and the damage rect between start and target, and the targeted
layers restored as Undo restores them. The panel runs it once per click;
an open Free Transform refuses it (panel guard kept, and the transform
intercept greys and refuses it for every caller). A floating piece goes
back as with Undo instead of being dropped as a new step.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:48:26 -04:00
Abdul karim mia ca5755f5b0 Layer: Duplicate Layer to a new document, as in Photoshop (#4805)
The Duplicate Layer dialog gains Photoshop's Document: New destination
with a document-Name field (As stays the copy's name, multi-select keeps
names, single group gets the Duplicate Group title). layer.duplicate
takes document index-or-new plus optional documentName, mirroring
channel.duplicate: the new document matches the source canvas, mode,
depth, resolution and profile, holds just the selected layers at their
positions, and becomes active. Smart-object contents are remapped within
the batch so copies are independent of the source. The dialog is fixed to
its content width so no dead strip remains.
2026-10-13 12:48:18 -04:00
ce6fccc3f5 fix(type): missing-font checks follow layout's PostScript resolution; split hyphenless style suffixes (#608)
* fix(text): split hyphenless PostScript style suffixes

A PostScript name without a hyphen kept its style in the guessed family:
`GmarketSansBold` became the family "Gmarket Sans Bold" at weight 400.
The guess now drops a trailing capitalised style word (Gmarket Sans, 700).
A lowercase ending ("Daylight") is not a style, and a style word after the
vendor tag MT stays in the legacy family ("Arial Rounded MT Bold").
Hyphenated names keep the last-hyphen parsing from #3878.

PSD import stores this guess when the exact face is absent. A legacy family
that ends in a style word without MT before it ("CooperBlack") is stored as
the shorter family (Cooper, 900) until the face is installed.

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

* fix(type): missing-font checks follow layout's PostScript resolution

When a run's stored family is not installed, layout draws it with its exact
PostScript face, or with the family guessed from that name if installed.
Missing-font detection compared only the stored family label, so such a run
was reported missing, and Replace Missing Fonts rewrote it whenever an
unresolved run shared the label. missing_fonts, missing_fonts_in_layer and
the replacement now use layout's decision.

#1406 already corrects the label when a PSD or Affinity file is opened with
the face installed, and #1844 finds exact faces outside the family prefix.
The label still stays on a run reopened from .pcraft after its font was
installed, on fonts that arrive after opening, and on a run created with
both "font" and "postscriptName". The tests take the .pcraft and command
paths.

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

---------

Co-authored-by: kijoong <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:48:09 -04:00
DeWarePebandClaude Opus 5.5 d740ea638d Puppet Warp: a linked layer mask moves with the pixels (#4910) (#4989)
Edit > Puppet Warp warped only a layer's pixels and left a linked mask
where it was, so the mask no longer lined up with the content. Free
Transform, Warp and Perspective Warp (#3083) already move a linked mask.

`puppet_warp_with_mask` solves the mesh once and moves the mask on the
same mesh: the mask under the mesh is lifted, deformed like the pixels
and laid over the rest; the area it leaves takes the mask's default and
the mask away from the mesh keeps its values. With a selection the mesh
covers only the lifted pixels, so only that part of the mask moves. An
unlinked mask stays put.

Tests: the moved stripe at 8/16/32-bit, undo, an unlinked mask, a
selection, and a hide-all mask in the algo crate. The engine tests fail
before the fix.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:48:02 -04:00
Nicolas FalesyandClaude Opus 5.5 4b76f10072 perf: encode and quantize canvas pixels in parallel (display_color, Buffer::to_rgba8) (#4768)
* perf(display): encode linear documents for the screen in parallel

CanvasDisplay::texture_buffer (every CPU-path canvas upload of a linear
document, which every 32-bit document is since #3792) and to_rgba8's sRGB
encode ran one pixel at a time on one thread. Both now split the pixels into
16K-pixel pieces across rayon off wasm; results are bit-identical.

24 MP linear document, median of 5 (22-thread laptop, dev profile):
- texture_buffer: 348.5 ms -> 29.6 ms
- to_rgba8:       107.8 ms -> 10.6 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

* perf(compose): quantize Buffer::to_rgba8 in parallel

The plain 8-bit quantize behind the CPU canvas, previews, thumbnails and
8-bit exports ran on one thread. Split it into 16K-pixel pieces across
rayon off wasm; results are bit-identical.

24 MP buffer, median of 5 (22-thread laptop, dev profile): 122.3 ms -> 11.9 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

* perf(canvas): build canvas images in parallel, fused with the quantize

display_image (CPU canvas updates, filter previews, the navigator, the crop
pasteboard) quantized to RGBA8 and then ran egui's single-threaded
ColorImage::from_rgba_unmultiplied over every pixel. The identity path now
quantizes straight to Color32 in one parallel pass; the colour-managed path
converts in parallel. Results are identical to egui's conversion.

24 MP buffer, median of 5 (22-thread laptop, dev profile): 50.9 ms -> 12.7 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:47:55 -04:00
Gert Pellin - GPE b007b1d355 Replace Color: Localized Color Clusters (points, localized, range) (#4477)
* Replace Color: Localized Color Clusters (points, localized, range)

image.adjustments.replaceColor takes the Localized Color Clusters of
Select › Color Range: "points" (where the colour was picked),
"localized" and "range" (% of the canvas's longer side). With clusters
on, the colour only matches near the points, through the same
color_range_samples as Color Range, so the same colour elsewhere in the
photo (a near-white background next to a cream garment) is left alone.
Without "localized" nothing changes. localized without points, and
malformed points, are refused.

The dialog part follows once the canvas eyedropper of #4247 is in.

Refs #4457

* Replace Color dialog: Localized Color Clusters and Range

The dialog gets Photoshop's Localized Color Clusters checkbox and its
Range slider (greyed while the box is off), and the canvas eyedropper
(#4247) now also records where the colour was picked (points), so the
localized match centres on that spot. The command's params list
localized, range and points as real keys in Photoshop's order.

Refs #4457
2026-10-13 12:47:47 -04:00
FinnNkandClaude Opus 5.5 b3df06b8c4 perf(engine): lasso and polygon selections rasterise only the rows they span (#4532) (#4773)
polygon_selection rasterised every lasso over the whole canvas (a
w×h f32 mask) and combined it over the whole canvas, whatever the
polygon's size.

Without feather it now rasterises the full-width band of rows the
polygon spans and combines over that band; Add and Subtract keep the
old selection outside it, Replace and Intersect leave it empty there,
exactly as the whole-canvas combine does. Full-width rows keep
`polygon`'s coverage bit-identical (its x arithmetic is relative to the
area's left edge, its y samples whole rows plus the same fractions);
a feathered lasso keeps the whole-canvas path, whose blur reaches past
the polygon. A test compares 24 random polygons, partly off the canvas,
in every mode, with and without anti-aliasing, against the old path.

Measured, a 2000-point lasso of a 400 px circle on 5472×3648: Replace
15–16 ms → 5 ms; Add to a selection unchanged (about 30 ms).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:47:40 -04:00
David MannandClaude Opus 5.5 55ae9bc11b Docs: document the engine.commands reply shape (#3684) (#3750)
* Docs: document the engine.commands reply shape (#3684)

engine.commands, the engine command command.list and the MCP tool
command_list reply with a bare JSON array of command objects, which the
docs never said. Document the shape (keys, types, an example element,
how filter/enabled_only narrow it) in control-protocol.md and mcp.md,
state it in the MCP tool description, and pin it with an rpc test so
the docs cannot drift. The shape itself is unchanged, so existing
agents and scripts keep working.

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

* Command list docs/test: include main's disabledReason and needsDocument keys

Main added `disabledReason` and `needsDocument` to every command list row
(#3664), so the documented shape and the pinning test now list all eight keys.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:47:32 -04:00
Sean Hellwig 1460187522 Layers: Cmd/Ctrl-click New Layer adds the layer below the current one (#4475)
Like Photoshop, Cmd/Ctrl-clicking the Layers panel's New Layer button adds the
layer below the selected layer instead of above it. The Background stays at the
bottom: below it, the layer lands above it.
2026-10-13 12:47:24 -04:00
.CooCoo 0a5cc0d1a0 Swatches: importing an .ase file numbers repeated group names in linear time (#5087)
swatches.import named each imported group with unique_group, which
compares a candidate name with the library's groups one by one and
tries "Name", "Name 1", "Name 2" and so on until one is free. Groups
without a name all take the import's group name, so a file of n
unnamed groups cost about n^3/6 string comparisons. In a test build,
importing a file of 9,994 unnamed groups took about 4.5 minutes.

The import now builds the set of the library's group names once and
keeps the next number to try for each repeated name, as it already
does for swatch names. Group numbers still start at 1, and groups get
the same names as before.

unnamed_groups_are_numbered_in_linear_time imports such a file on a
thread of its own and fails when the import takes more than 20
seconds. repeated_group_names_take_the_first_free_number checks the
names when a group in the file has a name that the import already gave
to an earlier group.
2026-10-13 12:47:17 -04:00
F4LCON ef8dc9ea30 Layer locks: locking the pixels clears the transparency lock, as in Photoshop (#4892) (#4898) 2026-10-13 12:47:10 -04:00
Gert Pellin - GPE 1bac788706 info: a smart object embedded in a PSD is reported as embedded (#4843)
PSD import keeps every placed layer as SmartSource::Linked { path:
<Idnt uuid> } so duplicates share the file and export stays
byte-stable, and info reported that as a link whose path is the uuid.
When the uuid has an embedded file (a liFD item in the preserved
lnk2/lnk3/lnkD blocks), the document's info now says
{"kind": "embedded", "fileName", "uuid"}; the new
linked::embedded_file_name finds it without copying the file.

Fixes #4842
2026-10-13 12:47:01 -04:00
jonathanwxh-cellandClaude Opus 5.5 ecf54d1baf Count document patterns in history budgets (#5105)
A pattern fill or layer style copies its pattern into the document, but
the tile visitor behind History's byte budget skipped Document::patterns.
The pattern's pixels were left out of the budget, of trimming and of
Edit > Purge > Histories, including after an undo left them only to the
redo states.

Related to #4972.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:46:53 -04:00
fec0573e71 fix(window): reopen at the saved window position instead of re-centring (#4826) (#4850)
eframe applies `NativeOptions::centered` after restoring the saved window
geometry, so the window moved back to the centre of the primary monitor on
every launch. Centre only when ui.ron holds no saved position.


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

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:46:46 -04:00
Gert Pellin - GPE 663863e62b Smart Objects > Export Contents: another extension exports the contents in that format (#4847)
Export Contents wrote the smart object's file as it was whatever the
path, so out.png held .pcraft or .psb data. With the contents' own
extension (or none) it still copies the file; with another one it opens
the contents and exports them in that format, as File > Export does
("converted": true, with the export's warnings). jpg/jpeg and tif/tiff
count as the same; an unknown extension is an error.

Fixes #4846
2026-10-13 12:46:40 -04:00
Nigel Ihza Firdausy 0df333f623 fix(gpu): require consent to persist CPU recovery (#3539) 2026-10-13 12:46:33 -04:00
FinnNkandClaude Opus 5.5 52fcca883e perf(paint): sampled tips share their samples, so cloning brush settings is cheap (#4534) (#4798)
A stroke clones its brush settings on every pointer event
(StrokeRenderer::tail_preview clones the dab generator and the context,
each holding a BrushSettings), and a sampled tip's GrayTile carried its
samples in a Vec<u16>. With a large sampled tip, common in imported ABR
brushes, every pointer event copied the tip: measured 1.1 ms per event
for a 1500² tip and 3.8 ms for 2500², at 120+ events a second.

GrayTile's samples are now an Arc<[u16]>: a clone copies a pointer.
Nothing mutates a tile's samples after building it, so only
construction changes (`.into()`), and equality, serde and every read
are unchanged. Measured the same way: under 0.01 ms per event at any
tip size. Textures and patterns stored as GrayTile share alike.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:46:25 -04:00
Tarek Mohamed 2307acef92 feat(select): preserve existing mask metadata and allow explicit target in refineEdge (#4499)
- Preserve existing layer mask metadata (density, feather, linked, enabled) during select.refineEdge when outputting to layerMask.
- Honor explicit mask targeting (target: "mask") in refine_edge when both selection and mask exist.
- Add tests for mask metadata preservation.
2026-10-13 12:46:17 -04:00
Vikram Bhaskaran b52115fc0a Preserve document and history when Fade cannot allocate its buffers. (#4593) 2026-10-13 12:46:10 -04:00
Gert Pellin - GPE f389d4ee64 info: adjustment layers are reported with the params their command takes (#4871)
document.inspect dumped an adjustment's internal model (enum name as
key, snake_case fields, 0..1 values), which no adjustment command takes.
It is now {"kind": "levels", "params": ...}: the kind and parameters
from adjust_params::to_params, which from_params reads back.

Fixes #4865
2026-10-13 12:46:03 -04:00
Persi 207d05a0e2 Channels: keep the cached composite across view-only changes (#4915)
* Liquify: simplify the brush-resize gesture condition

Same logic; clears clippy's nonminimal_bool on 1.96.

* Channels: keep the cached composite across view-only changes

Channel eye toggles bump the revision the cache was keyed on, so it was always dropped. The test only passed when the freed buffer came back at the same address, and failed under load.
2026-10-13 12:45:56 -04:00
Vikram Bhaskaran 213770008a Return allocation errors when clearing a color channel. (#4589) 2026-10-13 12:45:49 -04:00
Vikram Bhaskaran 759780e40d Clip Fill source reads to the canvas before allocating pixels. (#4590) 2026-10-13 12:45:42 -04:00
Gert Pellin - GPE 0bdaf905cc Curves: points outside 0..255 are refused, as the message says (#4868)
A point outside 0..255 was moved onto the edge without a word, while the
error for bad points says they must be in 0..255; Photoshop refuses
them too. The error also named the command twice-quoted (`adjustment
`curves``); it is now `curves adjustment`.

Fixes #4867
2026-10-13 12:45:35 -04:00
Antonandkuvshin888 911881880d Web: show the brush preset storage banner only while a change is unsaved (#4918)
When the browser denies IndexedDB from the start (a sandboxed iframe without
allow-same-origin, blocked site data), the banner "Brush preset changes are not
saved: SecurityError … Browser storage is unavailable" stays above the app for
the whole session, before any preset was touched and with nothing to dismiss.

Show it only while a preset change is unsaved. Nothing is lost before the first
change; that change brings the same banner with the reason. Write errors and
rejected records keep showing it, as both leave changes unsaved.

Co-authored-by: kuvshin888 <[email protected]>
2026-10-13 12:45:26 -04:00
David MannandClaude Opus 5.5 523f1f06bc Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532) (#2589)
* Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532)

Replace the ~300 px theme preview cards with Color Theme swatches (a light
and a dark row, since we follow the system appearance) and group the page
like Photoshop: Appearance (mode, themes, canvas colour and border),
Presentation (UI language, font size, scaling) and Options (checkboxes).
At 1000×750 the language now shows without scrolling, in Photoshop's
place rather than moved to the top.

The swatch rows come from LIGHT_THEME_OPTIONS / DARK_THEME_OPTIONS, so
Breeze Light and Breeze Dark show up. Each swatch keeps the favourite star
(#2445) as a badge on its corner, shown on hover and kept for a favourite,
with the same "Add to favorites: <theme>" / "Remove from favorites: <theme>"
labels. Preferences search filters the grouped page: rows that don't match
are hidden and a group left empty is skipped.

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

* Preferences: rustfmt the grouped Interface page

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

* Tests: 32-bit destructive Levels keep the HDR range (main is red since #2592)

#2592 made float Levels keep values outside 0..1 and updated compose's
levels_dont_clip_in_32_bit, but the engine-side copy still expected 0 and 255
at 32 bits, so test (ubuntu/windows) and corpus tests fail on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 1ebaf7612e)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:41:08 -04:00
Aurosandluy 2e242bde67 Fix Export As freeze on large layered PSDs (#5104)
Co-authored-by: luy <[email protected]>
2026-10-13 12:41:01 -04:00
bazandClaude Opus 5.5 1e916a4e4a Pen: Ctrl/Cmd edits the path being drawn in place (#4022) (#4526)
Ctrl/Cmd-pressing an anchor, a shown handle or a segment of the Pen (or
Curvature Pen) path in progress now edits it like Direct Selection does,
and drawing carries on from its last anchor once Ctrl/Cmd is released,
as in Photoshop. A Ctrl/Cmd-press anywhere else still ends the path.

The path in progress is a new Direct Selection candidate, PathRef::Pen,
built from ui.pen; its edits apply to ui.pen directly (no command, no
history step) since the path isn't in the document until it's finished.
The Add/Delete Anchor Point tools handle it the same way.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:34:23 -04:00
jonathanwxh-cellandjonathnwxh 907752daf2 Give Image Statistics a source-file dialog and stack mode selector (#4778)
* Give Image Statistics a source-file dialog and stack mode selector

* Keep stack sources in vertical rows and translate the mode label

* Place the new label alongside stack-mode translations

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:34:16 -04:00
Gert Pellin - GPE d3d86da130 Select › Color Range: Skin Tones (#4473)
select.colorRange takes {"select": "skinTones"}: a soft skin-tone model
in the YCbCr chroma plane (an ellipse along the skin run of Chai & Ngan,
1999) with a luminance gate, so near-black and near-white pixels drop
out. Fuzziness widens or narrows it as it does for Sampled Colors; the
selection modes, Invert and Sample All Layers work as for the other
presets. The dialog lists Skin Tones with Fuzziness enabled.

Detect Faces needs a face detector, which there is none of yet:
detectFaces: true is refused with a message instead of being ignored.

Fixes #4455
2026-10-13 12:34:04 -04:00
F4LCON 96c0241b26 Distort: a quad dragged past the horizon draws only its front side, in the preview and the result (#4863) (#4870) 2026-10-13 12:33:53 -04:00
Tarek Mohamed c4f0b8aa8b fix(compose): redraw shapes crossing canvas bounds and include vector bounds in damage (#3946) (#4493) 2026-10-13 12:33:47 -04:00
Marco Arnulfo e4f005d3dc Palette PNGs open in Indexed Color mode with their colour table (part of #4804) (#4817) 2026-10-13 12:33:39 -04:00
Valentin KleinandClaude Opus 5.5 efb67fc826 Text: binary-search the run lookup and probe smcp once per layout (#4430) (#4432)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:33:33 -04:00
Ikki Nishiharaandharanishi 66e07cbf79 fix(gpu): refresh patterns when defaults or formats change (#5013)
Co-authored-by: haranishi <[email protected]>
2026-10-13 12:33:27 -04:00
FinnNkandClaude Opus 5.5 752f611264 perf(compose): the shape stroke split cache keeps to a byte budget and empties on Purge (#4530) (#4774)
shape_split caches a stroked shape's fill and stroke rendered over the
canvas, for shapes with clipped layers: up to 32 entries of two
canvas-sized surfaces each, with no byte cap (several GB at 24 MP) and
nothing in Edit › Purge › All to clear it.

It now evicts least recently used entries until a new one fits both
the 32-entry cap and a 256 MiB pixel budget (an entry larger than the
whole budget is used and not kept), and Purge › All clears it as
"shape stroke cache", reporting the bytes like the other caches.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:33:21 -04:00
MrCloudy2andClaude Opus 5.5 5f2c23e142 Filters: Photoshop's Blur More kernel, Find Edges strength and Polar Coordinates direction (#4652)
Measured against Photoshop 27.11 on a test chart: Blur More uses the
[1 2 1; 2 2 2; 1 2 1]/14 kernel, Find Edges the full Sobel magnitude, and
Polar Coordinates turns counter-clockwise from 12 o'clock with repeated
edges and Photoshop's sampling grid. All four now match within 2 levels.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:33:15 -04:00
FinnNkandClaude Opus 5.5 cdfec72f71 perf(ui): the Navigator draws the GPU canvas's texture instead of a CPU thumbnail (#4518) (#4701)
With the GPU canvas, the Navigator still composited the document on
the CPU on the UI thread after every edit: render_reduced_damage over
the full-resolution damage, or a full thumbnail_buffer composite when
the document has effects. On the #209 layered 20 MP document that was
126–137 ms after a small opacity edit and about 700 ms for a
full-canvas layer, against 3.4 ms for the canvas itself.

While the main canvas shows the plain document on the GPU canvas, the
Navigator now paints that same texture through the canvas's own
shader (GpuCanvas::paint) at its size: no composite, no readback, with
the display transform the canvas uses. Everywhere else (a CPU canvas,
a flipped view, an adjustment proxy or preview) it keeps the CPU
thumbnail. Until the canvas has drawn a new document it waits up to
three frames rather than build a CPU thumbnail it would use once.

The preview is resampled through the display mips instead of the CPU
thumbnail's area sampling: thin lines average to even grey instead of
aliasing, and edges are a little softer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:29:25 -04:00
Dan HiornsandClaude Opus 5.5 13697cf5f5 Preset panels: the footer stays in view in a short dock group (#4790) (#4824)
The Swatches, Gradients and Patterns browsers capped their list with a
fixed allowance for the footer (30 or 40 px) and kept a minimum list
height (60 or 90 px, and egui's 64 px minimum for a scroll area). In a
short dock group the list took that room anyway and pushed the footer
(size slider, New Group, New, Delete) out of view: Swatches' slider was
cut in half, Patterns' footer was hidden entirely.

The shared browser now measures its footer and gives the list only the
height left after it, so the list scrolls and the footer stays visible,
as in Photoshop.

Fixes #4790

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:29:19 -04:00
JGFandClaude Opus 5.5 033ec991c2 fix(ui): show a magnifier cursor for the Zoom tool on Windows (#3735) (#3783)
winit maps CursorIcon::ZoomIn/ZoomOut to the plain arrow on Windows, so
the Zoom tool showed no cursor feedback there. Draw the magnifier (plus,
or minus while Alt zooms out) as an OS cursor bitmap, like the Hand
tool's #2196 fix; macOS and Linux keep their stock zoom cursors. Camera
Raw's preview zoom uses it too.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:29:13 -04:00
Dan HiornsandClaude Opus 5.5 ef2905ea8e Control: list and answer the unsaved-changes prompt (#4920) (#4924)
When a document with unsaved changes was closed, reverted, or the app was
asked to quit, the Save / Don't Save / Cancel prompt showed in the window
but not in ui.inspect, and a control client had no way to answer it: the
app stayed open until someone clicked.

ui.inspect now lists the prompt among its dialogs with the id "discard":
the waiting command, the document it asks about, how many are left and
the choices. ui.dialog.confirm {"dialog": "discard", "choice":
"save"|"dontSave"|"cancel"} answers it through the same code as the
buttons (one document at a time when quitting), and ui.dialog.cancel
cancels it. The automation policy sees the parked command before a save
or don't-save answer runs it.

Fixes #4920

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:24:35 -04:00
b21956aef7 fix(ui): Match Color's preview reads the Source document (#4851) (#4886)
The filter preview runs the command in a fresh session holding only a proxy
of the edited document, so a document param such as Match Color's Source
(or Displace's map) pointed at nothing there: with another document as the
Source the preview failed and the canvas kept showing the unchanged image,
and with the first open document it matched the image to itself. OK then
applied a different result from what the dialog showed.

The preview now adds each referenced open document (as a proxy at the same
factor) to its session and points the param at it, so it shows what OK
applies.


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

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:24:28 -04:00
avdyliisni9-pngandZulejhaAvduli cd35577c45 Polygonal Lasso: drop the polygon in progress when another document becomes active (#5015)
Follow-up to #4021, which drops the polygon when another tool is selected. The polygon now also remembers the document it was started on (UiState::polygon_doc) and is dropped when another document becomes active, as the Lasso and the Magnetic Lasso already do; before, its outline showed on the other document. Adds tests that every selection tool and Move/Brush drop the polygon (#4021 tests Quick Selection only), that a document switch drops it, and that a held Space or Cmd temporary tool keeps it.

Co-authored-by: ZulejhaAvduli <[email protected]>
2026-10-13 12:24:22 -04:00
F4LCONandpersi-man 62bf7391ce New Document: a custom background colour, from Custom… or the swatch beside the menu (#4789) (#4801)
* New Document: a custom background colour, from Custom… or the swatch beside the menu (#4789)

* New Document: test the swatch's colour for each Background Contents choice (#4789)

Co-authored-by: persi-man <[email protected]>

---------

Co-authored-by: persi-man <[email protected]>
2026-10-13 12:24:16 -04:00
f4b3766b91 Smart Filters: drag a filter up or down its stack to reorder it (#4897)
In Photoshop the order of smart filters matters (the bottom one is applied
first: Unsharp Mask over a Gaussian Blur sharpens the blurred image, under it
the sharpening gets blurred), and a filter row is dragged up or down the stack
to change it. The engine already had `layer.smartFilter.move` and re-renders in
the new order, but the Layers panel had no way to reach it: a filter row could
only be dragged onto another smart object or onto Delete (#4271).

Dragging a filter row over another row of its own stack now draws an insertion
line above or below that row (the Smart Filters row: on top of the stack), and
the release runs `layer.smartFilter.move`, one undo step. The moved filter ends
up selected with its smart object active, as after a click. Drops next to the
filter's current place, or on itself, do nothing.

Tests: `smart_ui::tests::a_filter_dropped_above_or_below_a_row_lands_next_to_it`
(the index math) and `smart_filter_rows_tests::dragging_a_filter_up_or_down_its_stack_reorders_it`
(whole app: above, below, onto the Smart Filters row, selection, undo).

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:42 -04:00
Dan HiornsandClaude Opus 5.5 33d9cb6ac4 UI fonts: draw a variable CJK fallback at Regular, not Thin (#4820) (#4830)
On systems with only Noto Sans CJK's variable collection (Fedora's
google-noto-sans-cjk-vf-fonts), the UI's CJK fallback was registered at
the font's default instance, which is Thin (wght 100), so CJK labels and
layer names came out hairline next to Latin text.

A fallback face with a weight axis whose default isn't Regular is now
registered with wght 400 (clamped into the axis) through egui's
FontTweak::coords. Static faces and variable faces that already default to
Regular are unchanged.

Fixes #4820

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:36 -04:00
OctaNebulaandClaude Opus 5.5 026de5e2a3 fix(layers): Ctrl+Alt+G releases the clipping mask when pressed again (#4927)
Cmd/Ctrl+Alt+G only ever ran Create Clipping Mask, so pressing it on a
clipped layer did nothing. In Photoshop the Layer menu has one item that
reads Create or Release Clipping Mask with the same shortcut, so the key
toggles the clip.

The key now runs Release Clipping Mask when the layers Create would clip
are all clipped already (the selected layer, or every selected layer above
the lowest), next to the Delete key's redirect in shortcut_dispatch. The
engine commands are unchanged, so agents still get exactly what they call.

The Layer menu follows: its Create Clipping Mask slot reads Release
Clipping Mask (and runs it) with the same shortcut while the key would
release, and the separate shortcut-less Release item that sat at the
bottom of the menu is gone.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:28 -04:00
Dan HiornsandClaude Opus 5.5 ad62f350e5 Symmetry: Transform Symmetry gets commit/cancel buttons and its own cursors (#4495) (#4508)
Transforming the painting symmetry could only be applied with Enter or
dropped with Esc, which a pen or touch user may have no key for, and the
canvas kept the brush outline although a press there doesn't paint.

While the transform is open, the options bar now shows "Transform
Symmetry" with commit and cancel buttons, like Free Transform's bar. The
canvas shows the move cursor over the centre, the rotate cursor over the
rotation handle and the arrow elsewhere.

Test: transform_has_commit_and_cancel_buttons_and_its_own_cursors.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:21 -04:00
Michael HigginsandClaude Opus 5.5 c72dda1914 Assign Profile: list only profiles of the document's colour mode (#4951) (#5020)
The profile menu offered every built-in and installed profile, and picking
one of another colour space only produced "profile is Rgb; the document is
Cmyk" in the status bar. The menu now lists the profiles the command accepts
for the document's mode, and a remembered choice that doesn't fit falls back
to the working space.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:11:56 -04:00
imSlicedBread a0627311ab Fix window dragging with modal dialogs (#4913) 2026-10-13 12:11:50 -04:00
f43d019a72 Brush Settings: use an amount slider for Hue Jitter (#4874)
Use the shared percentage row so the jitter amount does not look like a hue selection. Cover the rendered themed track, numeric and pointer edits, neighboring settings and engine journal replay.

Addresses the Hue Jitter appearance item in #3825; other assessment items remain open.

Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
2026-10-13 12:11:44 -04:00
Dan HiornsandClaude Opus 5.5 05d361a367 Shortcuts: ⌥⇧⌘N makes a new layer without the dialog (#4794) (#4807)
⇧⌘N opens the New Layer dialog, as Layer › New › Layer… does in Photoshop.
Photoshop's companion shortcut, the same keys with ⌥ (Ctrl+Alt+Shift+N on
Windows and Linux), makes the layer at once; PhotoCraft didn't handle it,
so there was no way to get 0.7.0's dialog-free behaviour from the keyboard.

New Layer's shortcut with ⌥ added now runs layer.new.layer directly. It
follows a rebound New Layer shortcut, and a command bound to that key
itself keeps it.

Fixes #4794

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:11:37 -04:00
Dan HiornsandClaude Opus 5.5 e4d281eb20 Brushes: preset card size numbers stay clear of the tips (#4454) (#4485)
In the Brush Preset picker's cards the tip thumbnail grew to 44.8 pt in a
42 pt cell while the size number was anchored to the cell's bottom, so
the number of any medium or large brush (Hard Round's 30, Soft Round's
45) was drawn under the tip and couldn't be read.

card_tip_rect now keeps a band for the number at the bottom of the cell
and fits the tip above it, still scaled by the preset's thumb_scale so
big brushes read bigger than small ones. Compact cards (no number) are
unchanged.

Test: card_tips_never_cover_their_size_number checks every footer scale
that shows numbers, in all three card kinds.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:11:31 -04:00
Marco Arnulfo 2ca12e17b5 Direct Selection: arrow keys nudge the selected anchors (fixes #4486) (#4500) 2026-10-13 12:11:24 -04:00
FinnNkandClaude Opus 5.5 33728243fb perf(gpu): closing a document frees what the compositor holds for it (#4529) (#4772)
GpuCanvas::retain called forget_doc only for documents still in its
texture map. A document whose entry had been dropped earlier (texture
creation failed, a fresh render failed after uploading, or the canvas
outgrew its budget after Canvas Size) kept its resident pages, effect
maps and lower-stack composite after it was closed, until another
document pushed the budgets over; with no other GPU document, for good.

Compositor::retain_docs forgets every document the caller doesn't list
as open, whatever the texture map says, and retain calls it each time.
The open documents' ids, their preview ids and the adjustment proxies
are already in that list, so nothing in use is dropped.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:07:27 -04:00
deadmeuandClaude Opus 5.5 533b9a1c62 perf(raster): moving a layer no longer stores tiles of only the default pixel (#4844)
Surface::translated, under every layer move and the Move drag preview, allocated
every target tile that a moved source tile reached. For a move that isn't a whole
number of tiles, the transparent margins of a layer's edge tiles landed on target
tiles of their own, and those were stored though they hold only the default pixel
(256 KB each at 8-bit RGBA). #4414's shared uniform tiles were also stored when
their value was the default. Moving two 300 px blocks by (100, 56) left 10 of 18
stored tiles empty; nudging back and forth kept adding more.

A target that ends up holding only the default pixel is no longer stored, as
prune() would drop it. A copied tile is compared row by row with a row of the
default pixel after the copy (one memcmp per row; a tile with content stops at
its first row), and a shared uniform tile of the default value is skipped. Moves
by whole tiles still re-share the existing tiles unchanged.

Release, 24 cores, cargo xtask perf --quick (perf_scenarios, p50, HEAD vs this,
two runs each): move layer on 16-bit 15000x10000 + refresh 5.3 -> 3.9 ms; move
layer on 32-bit 20 layers + refresh 11.9 -> 5.9 ms; the other Move rows within
noise. translated() alone on 36 MP: full layer unchanged (27.0 -> 27.6 ms RGBA8);
40 sparse blocks 11.8 -> 10.6 ms RGBA8, 44.0 -> 39 ms RGBA16, 308 -> 190 tiles.

The new test covers RGBA8, RGBA16, RGBA32F and GRAY8 over a zero and a non-zero
default pixel, and both paths; it fails on the old code.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:07:20 -04:00
Timothy DalyandClaude Opus 5.5 f1233bb43a io: a tagged CMYK file's merged image reads through its own profile (#4948)
merged_composite showed a CMYK merged image through the built-in coated
profile, while the compositor reads the file's layers through the
embedded one. Every oracle comparison of a tagged CMYK file therefore
measured two different profiles. psd-tools' CMYK files were 88-100% off
even with nothing to adjust: colormodes/4x4_8bit_cmyk.psd, a plain
image, was 100% off.

It now converts inside the document's CMYK space (compose::cmyk_space).
The test reads the merged inks back and checks them against the embedded
and built-in conversions.

psd-tools, bad pixels (pass count unchanged at 213/309):
- invert_cmyk: 98% -> 15%
- levels_cmyk: 95% -> 14%
- curves_cmyk: 98% -> 37%
- threshold_cmyk: 65% -> 7%
- posterize_16bits_cmyk: 88% -> 13%
- 4x4_8bit_cmyk: 100% -> 6%

With #4945's ink compositing, invert_cmyk and
brightnesscontrast_legacy_cmyk pass (215/309).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:07:14 -04:00
Gert Pellin - GPE 7b0dbf182b PNG: a whole-ppi pHYs reads back whole (300 ppi stays 300) (#4857)
pHYs holds whole pixels per metre, so 300 ppi is stored as 11811 and
read back as 299.9994 (72 as 72.009). The ppi read is now the whole
number that rounds to the stored value when there is one, as Photoshop
reads it; other values stay as they are.

Fixes #4855
2026-10-13 12:07:08 -04:00
Nigel Ihza Firdausy 5320cecb6d fix(adjust): preserve midtone luminosity with multiple sliders (#4941) (#4990) 2026-10-13 12:07:03 -04:00
Dan HiornsandClaude Opus 5.5 87aed12d62 Brush import: Dual Brush spacing comes from its tip (#4809) (#4889)
Photoshop CC brush presets store the Dual Brush spacing the panel shows in
the dual tip (dualBrush > Brsh > Spcn), and also write a Spcn of 100 % on
the dualBrush object beside it. The importer read that outer value last,
so every dual brush came in at 100 % spacing (the reporter's 10 %, 18 %
and 24 % presets all did), which changes the stroke a lot.

The tip's spacing now wins; the outer value only stands in for a tip
without one.

Fixes part 1 of #4809

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:06:56 -04:00
F4LCON 1168a9dd73 Pen: a new path joins the selected path instead of replacing the Work Path (#4721) (#4730) 2026-10-13 12:03:43 -04:00
Nigel Ihza Firdausy 625141c2ad fix(ui): display uppercase hex values in the color picker (#4859)
* fix(ui): show uppercase RGB hex values in shared color picker

* test(text): expect uppercase hex in text style color picker

* fix(ui): uppercase displayed hex without changing color storage

* test(text): keep color serialization independent of picker display casing
2026-10-13 12:03:37 -04:00
F4LCON 5adba14cd6 Navigator: one zoom row as in Photoshop, the percentage, Zoom Out, the slider and Zoom In (#4840) (#4854) 2026-10-13 12:03:30 -04:00
Valentin KleinandClaude Opus 5.5 cca24dd0d8 Type options: the wheel over the font menu steps through the families (#3350) (#3524)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:03:23 -04:00
jonathanwxh-cellandjonathnwxh f20605219e Keep applied guide layouts visible after preview closes (#5084)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:03:16 -04:00
Nigel Ihza Firdausy 35710a94ad Fix: native gesture fallback dedupe (#5080)
* fix(input): avoid double-applying native pinch during legacy fallback

* test(input): cover native trackpad rotation and preference gating
2026-10-13 12:03:11 -04:00
Alon 77d3478df1 fix(gradient): options-bar Opacity updates the live gradient layer (#5012) (#5045) 2026-10-13 12:03:05 -04:00
Marco Arnulfo 997406a584 Layer Style applies an open Free Transform first, so its preview shows (fixes #4821) (#4835) 2026-10-13 12:02:58 -04:00
Marco Arnulfo 78b70df227 Canvas: the CPU canvas shows crisp pixels when zoomed in (fixes #4810) (#4825) 2026-10-13 12:02:51 -04:00
Marco Arnulfo 665cb581ad Smart Filters: Delete removes the selected filter, not its smart object (part of #4751) (#4764) 2026-10-13 12:02:44 -04:00
Ivan · VanTan eae87bacfa i18n(ru): translate dynamic selection, theme and measurement labels (#4838) 2026-10-13 12:02:38 -04:00
Sir Starch 2ec1c8c0aa Themes: default light theme to Pro Light to preserve the Pro layout (#5016) (#5029)
When appearance mode was toggled between dark and light with default
settings, PhotoCraft switched from Pro Medium (Pro layout) to Studio Light
(Studio layout), altering the UI and displaying a Fit button in the status
bar.

Now that the Photoshop Pro light brightness levels exist (#2661), default
the saved light theme to Pro Light so the Pro interface layout and status
bar are preserved when toggling appearance. Also map the "light" alias to
Pro Light and list the Pro options first under Light Theme preferences.

Fixes #5016.
2026-10-13 12:02:32 -04:00
Dan HiornsandClaude Opus 5.5 334d378f0f Toolbar: clicking a tool's button again closes its open flyout (#4932) (#4937)
A right-click (or long press) on a tool button opens the flyout of the
tools grouped with it. Any press outside the flyout closed it, but the
press-outside check leaves out the tool button itself, so clicking that
button again, with either mouse button, left the flyout open.

A click on the button of its open flyout now closes it, as in Photoshop:
a right-click just closes it, a left click closes it and picks the tool
as any click does. The release that ends the long press that opened the
flyout still keeps it open.

Fixes #4932

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:02:26 -04:00
F4LCON 3d54e8cd68 Alerts: Enter is OK, but not the Enter that raised the alert (#4872) (#4880) 2026-10-13 12:02:12 -04:00
F4LCON e50b25f45c Shapes: test that the options bar Fill swatch recolours the shape just drawn (#3997, #3999) (#4510)
In v0.7.0 the options bar's Fill swatch only previewed the foreground
colour, so a drawn shape's fill could not be changed from it while Stroke
could. Main fixed that in #3019 and #3370; this test covers the reported
steps (draw a rectangle, pick a colour from the Fill swatch): the fill
changes, the stroke does not, and it is one undo step.
2026-10-13 12:02:07 -04:00
Marco Arnulfo 78dea7c6e4 fix(ui): an open Free Transform box takes Zoom tool drags and clicks (part of #4799) (#4808) 2026-10-13 12:02:01 -04:00
Marco Arnulfo 9a4efabe22 A file dropped on the canvas applies the open Free Transform and is placed at once (fixes #4833) (#4849) 2026-10-13 12:01:54 -04:00
mroxsoandhighperfocused 26e003059b fix(ui): a save name without an extension gets the suggested one (#5039) (#5049)
Co-authored-by: highperfocused <[email protected]>
2026-10-13 12:01:47 -04:00
Michael HigginsandClaude Opus 5.5 6f9227efa3 Brush Settings: no section title above the options pane (#3477) (#5017)
* Brush Settings: no section title above the options pane (#3477)

The options pane repeated the selected section's name as a title line.
The selected row in the section list already names it, as in Photoshop,
so the title is gone and the options start at the top of the pane.

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

* i18n test: the brush section name is drawn once, without a heading (#3477)

dynamic_brush_sections_have_translations_and_draw_in_the_selected_language
still expected the translated section name twice: the list row and the
options pane heading this branch removes. It now expects the row alone.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:01:40 -04:00
Jyrki VesterinenandClaude Sonnet 5.5 1304765067 fix(canvas): Fit View opens small images at 100%, not at the display scale (#5089)
fit_view capped the fit at 1.0 in egui points and then multiplied by
pixels-per-point, so on a 150% display a small image opened at 150%. Since
View::zoom became physical pixels per document pixel (27263bb16, #1978) the
cap has to apply after the conversion: scale by ppp first, then min(1.0).

The test for fit_view asserted the old value (zoom 2.0 at ppp 2.0); it now
checks a small image opens at 1.0 for ppp 1.0, 1.5 and 2.0 and that a large
image still shrinks to fit.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 12:01:34 -04:00
Nigel Ihza Firdausy ccb33741f9 fix(psd): reserve imported layer IDs when allocating section dividers (#4885) 2026-10-13 11:56:55 -04:00
Nigel Ihza Firdausy 40c9c3c451 Validate merged PSD channel indices before decoding (#4994)
* fix(psd): validate merged channel index before decoding (#4981)

* test(psd): compare decoded channel bytes as vectors
2026-10-13 11:56:47 -04:00
mroxsoandhighperfocused 41e578b4d3 fix(io): a version 3 Photo Filter colour is XYZ relative to the D50 white (#4987)
Photoshop CS2 stores the phfl version 3 colour as XYZ divided by the D50
white point (X / 0.9642, Y, Z / 0.8249), so white is (32768, 32768, 32768).
We read those values as absolute D50 XYZ, giving every CS2 Photo Filter a
slightly wrong colour: the Yellow filter preset (24191, 25002, 3827) decoded
to #FFE03A instead of #F9E31C, and Warming Filter (85) (15548, 12029, 0) to
#F18600 instead of #EC8A00. Multiply by the white point before converting;
decoded this way, every version 3 colour in the reporter's CS2 files is
exactly a Photoshop preset (#4940).

Fixes #4940

Co-authored-by: highperfocused <[email protected]>
2026-10-13 11:56:40 -04:00
1735c81e67 fix(segment): stop push/relabel looping forever on unreachable nodes (#4869)
Remove Background (and Select Subject's full-resolution refinement) could
hang at "Finding subject" on some photos. One refinement tile's max-flow
never finished, and the job ignores Cancel there.

Push/relabel's admissibility test was `dist[i] == dist[j].saturating_add(1)`.
A global relabel gives nodes reachable from neither terminal the height
u32::MAX, and MAX.saturating_add(1) == MAX, so two such neighbours counted
as admissible for each other. When f32 rounding strands excess on them
(source capacities near 3e8 have an ulp of 32), they pushed it back and
forth forever. Use checked_add so an unreachable node is never a push
target; its excess stays put and the loop ends.

The regression test is a 6-node graph that looped before and now matches
the Boykov-Kolmogorov cut.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 11:56:34 -04:00
Marco Arnulfo 5c73f4fffa TIFF: save uncompressed by default, like Photoshop (fixes #4484) (#4491) 2026-10-13 11:56:27 -04:00
Gert Pellin - GPE cb6ccaafc4 Flat export: a Lab document with one layer isn't reported as flattened (#4853)
Lab skips the native single-layer path (the codecs have no Lab layout)
and goes through the sRGB composite, which always warned that the
layers were flattened. With a single plain layer nothing is flattened:
only the sRGB conversion is reported now.

Fixes #4852
2026-10-13 11:56:21 -04:00
Gert Pellin - GPE 88ed837a86 Save for Web: JPEG chroma is full (4:4:4) above quality 50, as in Photoshop (#4823)
Save for Web subsampled the chroma (4:2:0) up to quality 89. Photoshop
2025's Save for Web writes 4:2:0 up to quality 50 and 4:4:4 from 51
(measured on its output; its Save As keeps 4:2:0 at every quality, as
PhotoCraft's Save As already does).

Fixes #4822
2026-10-13 11:56:16 -04:00
Ufuk Efe fe04f6acaa docs: credit Ufuk Efe in people.toml (#3809) (#5056) 2026-10-13 11:46:50 -04:00
Fabio Lissi c7a34b04ec Scorecard: Smart Object corner skew and perspective is done (FILE-215-6, #426) (#5094)
#426 gave smart objects a projective placement (`SmartObject.perspective`,
with `transform` as its affine approximation): Distort and Perspective set
it, the layer re-renders from its source through the homography, Warp
composes with it, and the PSD placement reads and writes
`nonAffineTransform` / `Trnf`. The scorecard row still said "missing" with
the pre-#426 note, as did the ui-parity row and the #579 mention in gaps.md.
2026-10-13 11:46:42 -04:00
shmuel billerandskylar taylor-barrick 2d5a7497fd fix(format): harden directory-bundle cleanup
Protect Unix bundle cleanup with pinned directory handles and remove only recognized content-addressed files. Keep legitimate linked roots and write/search-only directories working, with regression coverage for GC races and permission failures.

Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-13 09:20:31 -06:00
Nicolas FalesyandClaude Opus 5.5 6f85054288 fix(clipboard): paste JPEG, BMP and other non-PNG images on Wayland (#2546) (#4752)
arboard 3.6 asks the clipboard for image/png only, so a screenshot tool or
clipboard manager that offers just image/jpeg, image/bmp or image/webp pasted
nothing ("the clipboard holds no image"). When arboard finds no image, read
the other image/* types through wl-clipboard-rs (already in Cargo.lock via
arboard's wayland-data-control) and decode them with photocraft-codecs.
Reads are capped at 1 GiB; TGA is trusted only when its type says so.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 03:06:34 -04:00
Nigel Ihza Firdausy c0e62673f8 fix(macos): default droplets to a writable user directory (#4746) 2026-10-13 03:06:27 -04:00
5c579ba81a Channels: Channel Options dialog to make alpha channels spot colours (#3761) (#3784)
* feat(channels): Channel Options dialog to make alpha channels spot colours (#3761)

Double-clicking an alpha channel's thumbnail in the Channels panel now opens
Photoshop's Channel Options dialog (double-clicking the name still renames it):
Name, Color Indicates Masked Areas / Selected Areas / Spot Color, the overlay
or ink colour (Color Picker) and Opacity, which becomes Solidity for spot
channels. A saved selection can now be turned into a spot channel with its own
ink for screen-print separations. The row context menu gains Channel Options…
and Quick Mask Options…, and `channel.options` without params from a menu
opens the dialog on the targeted channel. OK runs the existing engine command,
so agents can still call it directly with params.

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

* style: rustfmt module order in ui-egui lib.rs

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

* feat(channels): New Spot Channel dialog (#3761)

Channels › New Spot Channel… now opens Photoshop's dialog instead of adding
a channel straight away: Name (prefilled with the next free "Spot Color N"),
and Ink Characteristics: Color (opens the Color Picker) and Solidity. OK
creates the spot channel from the selection, as before, and remembers the ink
and solidity for next time. `channel.newSpot` with params still runs the
engine directly for agents, the CLI and MCP. The engine's `next_alpha_name`
helper is now public so the dialog's name matches the engine's default.

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

* i18n: keep main's Czech rows, add Vietnamese strings (#3761 review)

- cs.tsv: main has removed its duplicate copies of Tool, Stroke Path…,
  Clone Stamp, History Brush, Dodge, Burn and Sponge, so this branch keeps
  main's single rows; the only Czech change is the Channel Options / New Spot
  Channel block.
- vi.tsv: the Vietnamese catalog is complete since #2636, so it gets rows for
  Channel Color, Color Indicates:, Solidity: and Ink Characteristics.
- it.tsv: main now translates Masked Areas and Selected Areas, so this
  branch's duplicate rows are dropped.

Every catalog now only adds lines relative to main.

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

* Channel Options: Serbian, Hungarian and Turkish rows for the new strings

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-13 02:59:12 -04:00
04f0b0e86b Brush: the Mode dropdown shows in every theme, not only Pro (#4537)
* Brush: the Mode dropdown shows in every theme, not only Pro (fixes #4512)

* Options bar scroll test: wheel over Studio's bar clear of the Brush Mode dropdown

The Brush's new Mode dropdown takes the wheel to step through blend modes, so
the test's pointer at the bar's centre landed on it at 800 px in Studio.

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-13 02:52:14 -04:00
36112e35d4 Brush Settings: disable minimum inputs when Control is Off (#4775)
Keep configured minimum values across Control changes and cover disabled input, independent controls and reactivation with UI regressions.

Refs #3825 (minimum input state only).

Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
2026-10-13 02:49:44 -04:00
6cbf4bd24d fix(input): support precision trackpad pinch zoom on Linux (#4786)
* fix(input): translate Linux precision Ctrl-wheel pinch gestures

* fix(input): decode Wayland high-resolution point pinch events

* docs(input): describe precision pinch support on Windows and Linux

* 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-13 02:49:40 -04:00
Ivan · VanTan a999ce4a77 i18n(ru): fix counted status agreement and font weight labels (#4787) 2026-10-13 02:49:34 -04:00
Nigel Ihza Firdausy 00802b03de feat(new-document): add ISO B3 and B4 print presets (#4784)
* feat(new-document): add ISO B3 and B4 print presets

* test(new-document): compare dimensions without signed integer conversion
2026-10-13 02:49:27 -04:00
null 00000111 67a3db229a i18n(pt-br): fix 12 Brazilian Portuguese strings (#4796)
Typos, European Portuguese spellings, grammar slips, and terms that
disagree with the catalog's own wording elsewhere.
2026-10-13 02:49:23 -04:00
isaiahoadeoye-starandClaude Opus 5.5 d3b2dd1aef feat(print): Printer menu lists the system's printers (file.printers) (#4748)
The Print dialog's Printer field was free text, so you had to type the
spooler's exact queue name (e.g. HP_OfficeJet_3830_series). It's now a
dropdown, like Photoshop's: "Default printer (<name>)" first (value "",
as before), then every printer craft_print::printers() reports (CUPS
queues and driverless destinations, or Win32_Printer on Windows).

- engine: new query command `file.printers` → {printers:[{name, default}],
  default}, no document needed, not journaled (same shape as DesignCraft's).
- ui: the dialog asks once per opening and keeps the list in `__printers`,
  which never reaches file.print's params. Labels show `_` as spaces; the
  exact queue name is what's sent. A remembered printer the spooler no
  longer lists stays selectable.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:49:04 -04:00
6ddb0e0b0d Fix print fuzzing and Windows crop GPU test fixtures (#4726)
* fix(tests): bound print fuzzing and serialize Windows crop fixtures

* Crop tests: hold the shared GPU lock in every WARP-backed test; drop the print fuzzing change main already has (#4467)

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-13 02:48:36 -04:00
5b1b6b075e feat(export): offer ICO in Export As with dimension validation (#4744)
* feat(export): offer ICO in Export As with format-size guidance

* fix(export): use theme warning token for oversized icons

* i18n: translate the Export As ICO size warning

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-13 02:48:25 -04:00
Nigel Ihza FirdausyandBrandon Thomas cb95e17fc1 fix(type): preserve keyboard focus when searching for fonts (#4745)
* fix(type): leave keyboard input to focused font search

* test(type): focused font search cannot alter the edited type layer

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:48:13 -04:00
4fdf1a723b fix(batch): skip recorded Quick Export steps when writing batch output (#4747)
* fix(batch): ignore recorded Quick Export and use batch output

* fix(batch): ignore recorded Quick Export and use batch output

* 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-13 02:48:04 -04:00
29f3656fe6 perf(algo): filters read the selection once per tile when mixing it back (#4533) (#4754)
mix_selection looked up the selection one pixel at a time
(`sel.pixel(x, y)`, which allocates a Vec and walks the tile map) for
every output pixel of every tile of a filter applied through a
selection: about 24 million allocations and map lookups at 24 MP.

It now reads the selection over the tile once (`read_region`) and
indexes it. The result is bit-identical: the old per-pixel version is
the test oracle, with fully selected, unselected and partial pixels,
with and without alpha, on a tile crossing the selection's tiles.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:53 -04:00
f2a1af86e1 perf(camera-raw): convert both previews in parallel bands (#4682) (#4757)
* perf(camera-raw): convert the refined preview in parallel bands, not per row (#4682)

#3427 colour-managed the refined (zoomed-in) Camera Raw preview by calling
the display transform once per row. A row is below photocraft-cms's 16K-pixel
parallel threshold, so every edit ran the whole conversion on one core of
the UI thread, plus two allocations per row. Read and convert 64K-pixel bands
instead, in parallel off wasm (as channel_view does); output is identical.

2560x1600 visible crop, median of 15 runs (22-thread laptop, dev profile):
- sRGB document:      31.5 ms -> 3.9 ms
- Adobe RGB document: 125.0 ms -> 12.5 ms
- ProPhoto document:  117.5 ms -> 12.7 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

* perf(camera-raw): the main preview converts in parallel bands too, without a full copy

CameraRawDialog::image copied the whole proxy into a Buffer and colour-converted
it on one thread on every edit. It now goes through the same banded, parallel
display_image as the refined preview, reading bands straight from the slice.

Median of 21 runs (22-thread laptop, dev profile):
- 858x572 sRGB:        4.72 ms -> 1.00 ms
- 858x572 Adobe RGB:   7.39 ms -> 2.06 ms
- 1716x1144 sRGB:     18.58 ms -> 2.48 ms
- 1716x1144 Adobe RGB: 28.78 ms -> 6.88 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:42 -04:00
Vikram BhaskaranandBrandon Thomas 753b610105 Keep gradient stops closer than one LUT step on the CPU. (#4712)
The GPU ramp is 4096 entries, so stops at 0.4999 and 0.5 interpolated to gray while the CPU kept the step. Stops that share an entry now use the CPU composite. Fixes #3053.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:31 -04:00
Marco ArnulfoandBrandon Thomas 25e29e2583 Filters: ⌘Z steps the open dialog's settings instead of the document (part of #4697) (#4715)
Filter Gallery, Adaptive Wide Angle, Vanishing Point and every parameter dialog (Lens Correction, Image › Adjustments, …) now keep a history of their own settings: ⌘Z / ⇧⌘Z step through it, one drag is one step, and the document's History beneath is left alone. Camera Raw is left to #4136.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:18 -04:00
6af9e1d977 feat(ui): bidirectional hover highlighting between Layers panel and canvas (#4718) (#4719)
Co-authored-by: safvanedge <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:07 -04:00
c758ebffac perf(raster): mode and depth conversion keep shared tiles shared (#4531) (#4725)
Surface::convert / try_convert and map_color converted tile by
coordinate, and the ICC path (color_cmds::convert_surface) staged and
converted every coordinate's tile. A tile shared across the grid, such
as a filled background (one Arc for all of it), therefore came out as
one fresh tile per coordinate: a white 2048×1024 background went from
1 to 32 tile allocations, a 24 MP one to 384.

Each distinct tile is now converted once and the result shared by
every coordinate that shared the original. Pixels are unchanged; tests
compare against a per-pixel conversion and check the sharing for
convert, try_convert, map_color and Image › Mode › CMYK.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:54 -04:00
Marco ArnulfoandBrandon Thomas 7610116123 Layers panel: clicking a row's lock icon unlocks the layer (fixes #4723) (#4734)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:43 -04:00
10b936f159 perf(ui): Duotone and live-adjust display documents are built once per snapshot (#4527) (#4736)
canvas::display_doc runs 3–4 times a frame (canvas, Navigator, beyond-
canvas and texture paths). For a Duotone document it cloned the whole
document on every call to add the ink display layer, before any cache
check; while an adjustment slider is dragged it cloned it again on every
call to swap in the previewed settings.

memo_display keeps the built document per document id, keyed by the
snapshot (a Weak) and the preview key, so each is built once per edit
or setting and shared after that. The Channels panel borrows the masked
layer instead of cloning it every frame, and the adjustment Properties
copy the two flags they read instead of the whole layer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:32 -04:00
42b399881a Handle signed coordinate limits in the Offset filter (#4738)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:22 -04:00
95a9a6b29c Layers: Add vector mask makes the mask from the selected path and targets it (#4740)
* Layers: Add vector mask makes the mask from the selected path and targets it (fixes #4728)

* Pen menu test: the vector mask made from a path becomes the active path

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-13 02:45:53 -04:00
F4LCONandBrandon Thomas f4e3dc63b6 Properties: Align and Distribute with the Background in a multiple selection (#4608) (#4637)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:45:42 -04:00
015ad7083a Select › Sky: a white or grey backdrop that reaches the bottom is not sky (#4638)
* Select > Sky: a white or grey backdrop that reaches the bottom is not sky

The colour prior counts bright, nearly neutral colour as overcast sky,
so a seamless studio backdrop starting at the top edge flooded the
whole background (79 % of a product shot). A sky never reaches the
bottom of a photo; a backdrop does. A region that also covers most of
the bottom rows and is nearly neutral (mean saturation under 0.1) is
now taken for a backdrop and nothing is selected; blue sky reaching
the bottom, as in a photo taken upward, still is.

Fixes #4634

* Sky: read the bottom rows of the mask with get()

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-13 02:45:31 -04:00
2d190e7c57 perf(engine): Edit › Copy shares whole tiles instead of rebuilding them (#4516) (#4621)
lift read the whole copied area into one f32 buffer (16 bytes per RGBA
pixel, about 366 MiB at 24 MP), multiplied in the selection and wrote
everything back into new tiles, so the clip shared nothing with the
layer: 273 ms for a plain Copy of a 24 MP layer and 341 ms after Select
All.

It now goes tile by tile. A source tile the copy takes whole (inside the
canvas, no selection or a fully selected tile, already in the clip's
format) is shared copy-on-write; the other tiles go through the old
per-pixel path one at a time. Measured on the same layer: 17 ms and
42 ms. The clip is bit-identical: the old whole-area version is kept as
the test oracle across depths, with and without alpha, no selection,
Select All, a partial feathered selection and one off the tile grid,
and content past the canvas.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:45:20 -04:00
4062d40070 Color: Dot Gain 20%-compatible default working gray, as in Photoshop (#4610)
* feat(color): Dot Gain 20%-compatible default working gray, as in Photoshop

Photoshop's default working gray is Dot Gain 20%, and its RGB -> Grayscale
measured in Photoshop 27.11 (default Color Settings) is D50 luminance
encoded with gamma 1.732: sRGB 64 / 128 / 192 grey and pure red become
46 / 105 / 177 / 107. PhotoCraft defaulted to sGray, which kept the sRGB
value (64 / 128 / 192 / 130), up to 24 levels off.

Adds the built-in "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"
gray profile (id dot-gain-20-compat; our own fitted curve, not Adobe's
profile, the same gamma PhotoCraft already uses for gray adjustments) and
makes it the default working gray and the fallback for untagged gray
documents. sGray and Gray Gamma 2.2 stay selectable.

This changes a default: untagged gray documents now convert and display
as Photoshop shows them (midtones lighter than under sGray); documents with
an embedded profile are unchanged.

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

* Status bar test: the default working gray is now Dot Gain 20%-compatible

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

* .atn test: #202020 in the Dot Gain 20%-compatible working gray

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-13 02:45:09 -04:00
f2c52a92c9 Pen: a close-path pointer over the first anchor (#4596) (#4612)
* Pen: a close-path pointer over the first anchor (#4596)

* Canvas: the close-path pointer arm before the pen tools' own cursors

The pen tools' icon cursors (#4650) matched first and made this arm unreachable.

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-13 02:44:57 -04:00
f57db2b5bb feat(gpu): composite Blend If on the GPU (#4395) (#4541)
A layer with Blending Options › Blend If made the planner refuse the
whole document, so every refresh of it ran the CPU compositor: on the
#209 layered document an opacity edit took 140 ms instead of 6 ms.

Blend If is compose::apply_blend_if: the composite with the layer mixed
back towards the backdrop by a per-pixel weight from the layer's own
content with its masks ("This Layer"; an adjustment's result) and the
backdrop ("Underlying Layer"). The planner now keeps the backdrop,
composites the layer as before (channel restrictions included, as on
the CPU), renders its own content and emits one Lerp pass with the new
F_BLEND_IF flag and the ranges packed exactly in its parameters. Base
and clipped layers both go through it; Advanced Blending still falls
back to the CPU.

Hard points: compositing can leave an 8-bit level a few millionths
below itself (a level-20 backdrop at partial alpha reads 19.999998),
which put it on the wrong side of a hard point at that level on the
CPU, and the GPU's arithmetic differs there. Both now treat a value
within 1e-4 of a level as that level; 8/16-bit values off a level are
at least 1/257 away. On 16/32-bit documents the GPU reads layers as
RGBA16F, so a value within about a tenth of a level of a hard point can
still land on the other side, as with the GPU Threshold adjustment.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:11:23 -04:00
af21e1e21b fix(text): preserve hyphenated PSD font identity on reopen (#3878)
* fix(text): preserve hyphenated PSD font identity on reopen

* fix(io): preserve installed font families during PSD import

---------

Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:11:19 -04:00
Brandon ThomasandClaude Opus 5.5 ebffaa6e42 i18n: drop duplicate Japanese rows; Serbian for the mask-to-selection items (#4793)
#4651 re-added Japanese rows that #3893 and earlier work already had, so
bundled_catalogs_are_consistent failed on duplicates. #4605's mask thumbnail
menu landed without its three Serbian strings.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:04:35 -04:00
mjohnson7cdandBrandon Thomas e0f6ce4302 fix(ui): floating panel groups get an opaque title strip in Studio themes (#4509) (#4543)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:04:10 -04:00
e18e3c066f OpenRaster: never overwrite an .ora with less than the document has (#3806) (#3881)
* OpenRaster: never overwrite an .ora with less than the document has (#3806)

File › Save wrote any document back to its .ora, though OpenRaster
drops adjustment layers, layer styles, vector masks and clipping,
applies masks and fill opacity to the pixels and rasterises type and
shape layers; the save then marked the document saved, so the edits
were lost without asking.

An OpenRaster file now counts as the document's own file only while
it holds everything in it. photocraft_io::ora_losses lists what a save
would leave out, from the same checks the writer warns with but
without encoding anything, and file_cmds::holds says whether a format
keeps the whole document (PSD, PSB and .pcraft always; OpenRaster when
nothing is lost). When it doesn't:

- File › Save opens Save As on a PSD of the same name, with a notice
  listing what OpenRaster would drop, instead of overwriting the .ora;
- a save to an .ora path writes a copy, as Photoshop does for formats
  that can't keep everything: the document keeps its file and stays
  unsaved;
- automation (app.save, the headless doc.save) refuses a pathless save
  with the reasons and treats an .ora save as a copy.

An sRGB profile is no longer reported as lost, as OpenRaster readers
assume sRGB. A plain document still saves back to its .ora.

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

* i18n(vi): translate the OpenRaster save notice

vi.tsv is now a complete catalog, so the two new strings need
Vietnamese rows. Terms follow the catalog's own: Không thể for
can't, như for such as, Lưu bản sao for Save a Copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the OpenRaster save notice

main added complete Hungarian and Turkish catalogs after this branch
was cut, so every_tl_literal_is_translated needs their rows for the
two new strings. Wording follows each catalog: informal Hungarian
with "{name}" after a colon; formal Turkish with Biçim for format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the OpenRaster save notice

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated
needs the notice's two strings there too.

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-13 02:04:02 -04:00
mjohnson7cdandBrandon Thomas d3e33d89a9 fix(ui): click and hold on a toolbar button selects its tool (#4453) (#4544)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:51:23 -04:00
becc27ac1c CMYK: Invert, Posterize and Threshold on the inks; 16-bit Lab Invert keeps greys neutral (#3930)
* Lab and CMYK: Invert, Posterize and Threshold work on the document's channels

Photoshop applies these to each stored channel, not to RGB:
- Lab: Invert and Posterize work on L, a and b, and Threshold cuts on L
  to a neutral black or white.
- CMYK: Invert and Posterize work on each ink, black included, so
  paper inverts to 400% ink. Threshold gives paper or 100% black ink.

Adjustment layers now do the same:
- compose::adjust::apply_mode goes through the document's Lab or CMYK
  channels.
- The GPU falls back to the CPU for these, as it already does for
  Lab/CMYK Levels and Curves.
- The destructive Image > Adjustments path works on the stored samples
  directly, so it is exact.

Photoshop oracles (lab/cmyk 8 and 16 bit), bad pixels before -> after:
- lab threshold: PASS (was 12%)
- lab invert: 72-99% -> 1%
- lab posterize: 89% -> 0.1%
- cmyk invert: 77% -> 10%
- cmyk posterize: 71% -> 9%
- cmyk threshold: 37% -> 1.6%

Floor 133 -> 139 (137 already passed on main).

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

* CMYK: Invert, Posterize and Threshold on the inks; 16-bit Lab Invert keeps greys neutral

Builds on main's adjust::apply_in (#4038):
- apply_in's mode dispatch gains CMYK. Invert and Posterize work on each
  ink, black included, so paper inverts to 400% ink. Threshold gives
  paper or 100% black ink alone. cmyk_channel_adjustment says which
  adjustments do this.
- Lab Invert mirrors a and b around neutral at 16 bits; 8-bit bytes
  still become 255 - v. stored_channels holds both rules, and
  apply_lab uses it.
- Destructive Image > Adjustments on a Lab or CMYK layer works on its
  stored channels directly, so it is exact; a selection mixes the
  stored values.
- The GPU leaves CMYK Invert, Posterize and Threshold to the CPU.

Photoshop oracles, bad pixels:
- cmyk invert: 77% -> 10%
- cmyk posterize: 71% -> 9%
- cmyk threshold: 37% -> 1.6%
- lab16 invert: 52% -> 1.2%

The count stays at 145/258, which main already passes, so the floor
goes 133 -> 145.

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-13 01:38:52 -04:00
Scott C Hughesandskylar taylor-barrick 9ccd4aa557 fix(engine): preserve copied smart object source records
Carry only the linked-source records referenced by copied layers through Copy/Paste and save/reopen. Preserve raw records, avoid duplicate payloads on same-document paste, and reject ambiguous or incomplete selected identities before replacing the clipboard.

Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-12 23:38:45 -06:00
skylar taylor-barrick 728579aea5 Integrate current PhotoCraft UI while retaining native driver settings 2026-10-13 07:36:47 +02:00
cda46050f7 Background Eraser: sample and match against the pre-stroke pixels (#4552)
Dabs read the hotspot from pixels earlier dabs had already erased, so
Continuous skipped most dabs and Contiguous erased nothing from an
erased hotspot, leaving patches after one pass.

Fixes #1280

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-13 01:28:29 -04:00
skylar taylor-barrick f85eddf3cc Complete native printing translations and Serbian theme labels 2026-10-13 07:20:25 +02:00
Marco ArnulfoandBrandon Thomas 4eb8ca25bd Layers: right-clicking a layer-mask thumbnail opens the mask menu (fixes #4575) (#4605)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:15:13 -04:00
Marco ArnulfoandBrandon Thomas 93397b08c6 Fill keys: Alt/Cmd+Backspace recolour the text of a type layer (fixes #4580) (#4606)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:15:00 -04:00
F4LCONandBrandon Thomas ad5b11a120 Layers panel menus: highlight the item a press-drag is over (#4601) (#4607)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:49 -04:00
Marco ArnulfoandBrandon Thomas 9716b2b319 Gradient options bar: the swatch covers its checkerboard at half-pixel offsets (fixes #4577) (#4616)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:37 -04:00
cf804c4b27 feat(brush): Photoshop's measured round tip (#4617)
Photoshop 27.11's round tip, measured on single dabs (hardness 0-100 in
steps of 10 at 40 px, checked at 100, 9, 7, 5, 3, 2 and 1 px): a solid
core of radius r0*R, then a Gaussian shoulder exp(-((d - r0) / s)^2) of
width s*R, both from a table by hardness. A soft tip reaches well past its
nominal radius (hardness 0 is a Gaussian of sigma 0.47 R: 8 % at 1.05 R);
PhotoCraft's (1 - t^2)^4 ended at R and kept far less coverage (a 40 px
hardness-0 dab held 251 coverage units against Photoshop's 542). The edge
stays at least ~1 px wide (0.3 R on small tips, never under 1/4 px).
exp is (1 - u/64)^64 by squaring, so it is the same on every platform.

Dab rectangles, reaches and the retouch footprint grow to the tip's reach
(tip_reach); the brush cursor's 50 % ring (half_coverage_radius) follows the
new curve. Tests that hard-coded the old curve now check measured values;
tests that bounded a stroke by the nominal radius use the reach.

Measured against Photoshop (max levels / mean): 40 px dabs, hardness 0-70:
70-156 / 12-15 -> 2-6.5 / <0.7; 100 px dabs 70-177 -> 2-27; strokes
(probe 13) soft and medium 20-60 px 133-178 / up to 17 -> 4-26 / <=0.5;
spacing sweeps 134-221 -> 7-9; soft Flow build-up 85 / 7.5 -> 5 / 0.16;
small 1-7 px total error lower in 21 of 30 cases (medium hardness at
2-5 px a little higher: a follow-up).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:26 -04:00
bf5d01b00d Layers panel: shape layers show a shape badge on their thumbnail (#4584) (#4619)
A shape layer's thumbnail shows its pixels like any other layer, so nothing
in the row said it was a vector shape. It now carries a badge in the
bottom-right corner, in the same frame as the smart-object badge: a path
with an anchor point on each corner.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:14 -04:00
Jason BrashearandBrandon Thomas 781871ff93 Codecs: FITS keeps long CONTINUE strings intact on open and save (#4633)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:52 -04:00
9c3f96649f fix(transform): Photoshop's Bicubic for Free Transform and Warp, with a reduction prefilter (#4635)
Free Transform and Warp sampled with Catmull-Rom (Keys a = -0.5).
Photoshop's Bicubic is the Keys cubic at a = -0.75 (Image Size already
uses it): a 200 % step edge matches within 0.5 levels (Catmull-Rom was 10
off). When a Bicubic transform reduces, the source is now resized first by
the whole local scale with Image Size's widened Bicubic, and the warp
samples it at about 1:1; the old pre-reduce only kicked in below 50 % and
by powers of two, so 50-99 % reductions aliased. Bilinear still samples
directly (it matched Photoshop exactly at 50 %).

Measured against Photoshop 27.11 Free Transform (max levels / mean):
Bicubic 200 % 10 / 0.36 -> 1 / 0.01; 50 % 48-59 / 1.0-1.2 -> 6-8 /
0.02-0.05; 150 % mean 0.59 -> 0.17. Bilinear unchanged. Bicubic
Smoother / Sharper / Automatic (not offered by Transform yet) and rotated
edges are follow-ups.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:40 -04:00
Marco ArnulfoandBrandon Thomas 8da02bf540 Hue/Saturation: colour ranges apply in full to dull colours (part of #4089) (#4645)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:26 -04:00
f8cfc1131a Cursors: the pen tools show their icon, and Caps Lock swaps it for the crosshair (#4639) (#4650)
* Cursors: the pen tools show their icon, and Caps Lock swaps it for the crosshair (#4639)

The pen tools fell through to the canvas's default crosshair whatever
Preferences > Cursors > Other Cursors said, and Caps Lock did nothing.
In Photoshop the Standard cursor is the tool's icon, Precise is the
crosshair, and Caps Lock swaps between them.

The Pen, Freeform Pen, Curvature Pen, Add/Delete Anchor Point and
Convert Point tools now draw their toolbar icon with the nib (Convert
Point: its anchor) on the point. precise_other_cursor (the preference,
flipped while Caps Lock is on) decides it, and the Eyedropper, Color
Sampler and Paint Bucket, which already followed the preference, now
swap with Caps Lock too.

Test: caps_lock_swaps_the_other_cursors_between_their_icon_and_the_crosshair.

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

* Canvas: keep pipette_cursor's doc comment on pipette_cursor

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-13 01:13:16 -04:00
a63964dd63 Update Japanese translations in ja.tsv (#4651)
* Update Japanese translations in ja.tsv

I have added items that appear to have been omitted from the registration.

* i18n(ja): keep the rows this update dropped

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-13 01:13:03 -04:00
Marco ArnulfoandBrandon Thomas bb15340af6 Filter dialogs: the preview keeps square pixels (fixes #4640) (#4655)
The in-dialog preview clamped the texture UV to the image but still drew
it over the whole preview region. Whenever the region overhung the image
on one axis (a 100% view of a small document in the wide preview), that
axis was stretched to fill it. The preview now draws only the part of the
source rectangle that lies on the image, at its own place and scale in the
region, with the checkerboard under the image only.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:12:50 -04:00
Marco ArnulfoandBrandon Thomas 481b939fdf PSD save: keep the Lr16/Lr32 padding outside the block length (part of #4654) (#4663)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:12:39 -04:00
Dan HiornsandClaude Opus 5.5 caabe4b836 Screen color picker: every way out ends it (#4291) (#4636)
The picker covers every screen and stays on top, and a report on #4291
describes one that ignored Esc, right-click and left-click, so the
computer could only be freed from another device. In the picker's frame:

- Esc only worked once Windows gave the picker keyboard focus, which
  nothing asked for. The picker now takes focus on its first shown frame.
- A right-click did nothing; it now cancels.
- A left click was dropped when no snapshot pixel mapped to the pointer,
  and a press that moved before its release never counted as a click.
  Any left release over the picker now ends it: it picks the pixel under
  the pointer, or cancels when there's none.

The frame moved into screen_picker::frame so it can be tested.

Test: every_way_out_of_the_picker_ends_it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 01:12:15 -04:00
f769c3735d perf(engine): Fill, Stroke and Clear keep tiles the selection doesn't reach (#4517) (#4677)
blend_into takes every tile in the selection's bounds and blended each
one into a fresh copy. History holds the previous document, so
Arc::try_unwrap always failed and a tile with nothing selected was
deep-copied too, unchanged: a 4 px Edit › Stroke on Select All of a
20 MP layer stored +82.5 MB per step for a ring of edge tiles.

blend_tile skips every pixel whose weight (selection × opacity) is
zero, so such a tile comes out byte-identical: keep the tile itself.
Mask::empty sits beside Mask::full. Measured on the same layer: the
stroke step now stores +17.5 MB and a 4000×3000 ellipse fill +14.2 MB
instead of +26.0 MB. The new test fails on the old code.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:53 -04:00
0de20b5523 perf(ui): Channels thumbnails skip selection-only steps and reuse their textures (#4518) (#4678)
channel_thumbs was keyed by the document snapshot, so every step,
including a marquee or Deselect, recomposited the whole document for the
56 px thumbnails (`compose::thumbnail`, about 0.7 s on the #209 layered
20 MP document with effects) and loaded new textures for each.

A new snapshot that differs from the previous one only in its selection
keeps the thumbnails (`same_but_selection`: the old document with the
new selection compared to the new one; tiles compare by pointer first).
A real rebuild updates the existing textures with `set` instead of
allocating new ones. The new test fails on the old code: a marquee
and Deselect no longer upload anything, and a fill rebuilds into the
same textures.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:42 -04:00
233b6c63f0 perf(text): applying preferences no longer waits for the system font scan (#4519) (#4679)
Session::apply_prefs set the shared text engine's missing-glyph
protection through photocraft_text::shared(), which blocks until the
system font scan that launch starts in the background is done. It runs
on the UI thread at launch and on every preferences change, so the first
frame waited for the rest of the scan: 248–257 ms on this machine
(release, launch order replayed), more with many fonts installed.

set_shared_missing_glyph_protection applies it at once when the engine
is built, and otherwise from one helper thread once the scan finishes.
Both read the newest setting under the engine's lock, so a late change
is never overwritten by an older one. Measured the same way: 0.1 ms.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:31 -04:00
Nigel Ihza FirdausyandBrandon Thomas 5cff9ebed2 fix(clipboard): prompt to save new documents containing pasted pixels (#4683)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:20 -04:00
bb4e55ebb4 Checkboxes: a pressed box shows it, as in Photoshop (#4476) (#4553)
* Checkboxes: a pressed box shows it, as in Photoshop (#4476)

Pressing a checkbox, its label or a Brush Settings section row gave no
feedback until the release. While pressed, an unchecked box now darkens
a little and a checked one turns a little less opaque.

widgets::check_box paints the box for both the shared checkbox and the
Brush Settings section list (which drew its own). The section boxes now
get the field fill like every other checkbox.

Tests: section_boxes_show_a_pressed_state,
checkboxes_show_a_pressed_state_from_their_label.

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

* Checkboxes: checkbox-only Brush Settings rows show the pressed state too

main made the rows without settings (Wet Edges, Build-up, ...) one
checkbox across the row; a press anywhere on such a row presses its 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-13 01:11:18 -04:00
Marco ArnulfoandBrandon Thomas 45ce763ebb New Document: blank presets follow the picked orientation and physical unit (part of #4668) (#4686)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:09 -04:00
3a307a757e Play real Photoshop action sets: TrueGrit .atn mappings and fixes (#4356, #219) (#4698)
Third-party action sets that load since 3204231 now play. Four TrueGrit
Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine,
Transparency) go from 223 to 3 unmapped steps of 533 and play end to end.

- .atn import, 108 -> 112 events: Select and Mask (refineSelectionEdge),
  Filter Gallery Spatter (GEfc), Ripple, Crystallize; targeting the layer
  mask or composite channel, loading the targeted channel, Step
  Backward/Forward, removeFromSelection (it silently imported as a plain
  select), Pattern Fill layers (by name), nudges in points, Layer From
  Background, Grayscale/Bitmap mode objects, Image Size by resolution.
- Engine: layer.translate units pt; layer.select mode remove;
  select.loadSelection channel targeted; image.imageSize keepPrintSize;
  layer.new.layerFromBackground ifPresent/select; pattern.import data.
  New from Clipboard keeps the copied document's resolution (was 72 ppi).
- Filters: Spatter's Smoothness smooths the displacement (torn but
  attached edges, as Photoshop's); Crystallize fills a cell from its point
  instead of the average, so masks keep hard edges.
- UI: .pat opens/drops into the Patterns panel (no UI import existed).
- Docs: gaps G11, file-format and target-app parity.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:10:58 -04:00
a80fefce5c fix(ui): expose custom paper dimensions in Print Settings (#3893)
* fix(ui): expose custom paper dimensions in Print Settings

* fix(i18n): keep custom paper labels away from catalog tails

* Custom print paper: Serbian rows; drop the env-gated capture test

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-13 01:04:53 -04:00
F4LCONandBrandon Thomas 8c135457b4 Brushes: a New Brush Preset dialog for every way of making a preset (#4490) (#4563)
* Brush presets: remember what the New Brush Preset dialog captured (#4490)

* Brushes: a New Brush Preset dialog for every way of making a preset (#4490)

* Brushes: the New Brush Preset strings in the Serbian catalog (#4490)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:58:50 -04:00
a6e1beb1be Fix: Preview pixelation in Sharpen filters (#3936)
* Fix: Preview pixelation in Sharpen filters by using full resolution

Sharpen filters (Sharpen, Sharpen More, Sharpen Edges, Unsharp Mask, Smart Sharpen) now preview at full resolution instead of using a downsampled proxy. This fixes the pixelated appearance when enabling preview.

Sharpen algorithms work by detecting and enhancing edges. On a downsampled proxy image with fewer edges and details, the result appears pixelated or blocky. By previewing at full resolution (k=1), users now see an accurate preview that matches the final result.

Fixes #3929

* Fix: Preview issues in edge-detection filters (High Pass, Find Edges, Emboss)

High Pass, Find Edges, and Emboss filters now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews when enabling preview.

These filters work by detecting and enhancing edges:
- High Pass: extracts detail/edge information
- Find Edges: performs edge detection
- Emboss: creates 3D effect by detecting edges

On a downsampled proxy image with fewer edges and details, the result appears pixelated or blocky. By previewing at full resolution (k=1), users now see an accurate preview.

Similar to the Sharpen filter fix (#3929).

* Fix: Preview issues in morphological and motion blur filters

Median, Maximum, Minimum, and Motion Blur filters now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews.

**Median** - Similar to Dust & Scratches, removes noise by replacing pixels with median of neighborhood. Downsampling averages away the speckles it's designed to remove.

**Maximum/Minimum** - Morphological operations (dilate/erode) that expand or shrink features by one pixel. On a proxy, these features become fractional pixels, distorting the preview.

**Motion Blur** - Creates directional blur streaks. Pixel-sized distance parameter (1..2000 px) needs full resolution for accurate preview.

Like sharpen and edge-detection filters, these algorithms depend on pixel-level detail and need full resolution to preview accurately.

* Fix: Preview issues in despeckle and pixel-based blur filters

Despeckle, Box Blur, and Surface Blur now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews.

**Despeckle** - Removes one-pixel speckles/noise. Like Dust & Scratches, it works on per-pixel features that downsampling smooths away.

**Box Blur** - Creates rectangular blur artifacts with fixed pixel-sized radius (1..2000 px). On downsampled images, these rectangular patterns distort because the radius becomes a fraction of a pixel.

**Surface Blur** - Selective blur that preserves edges while smoothing flat areas. On downsampled proxy, edge preservation algorithm may not work correctly since edges have been blurred together.

All three algorithms depend on precise pixel-level operations and produce inaccurate results on downsampled images.

* Fix: Correct preview_factor for fixed-kernel sharpen and edge-detection filters

Remove parameterized filters (Unsharp Mask, Smart Sharpen, High Pass, Median, Maximum, Minimum, Motion Blur, Box Blur, Surface Blur) from FULL_RESOLUTION_PREVIEW.

These filters have pixel-sized parameters (radius, distance) that preview_factor already scales correctly. Forcing them to k=1 makes previews of large blurs on 24-36 MP documents unnecessarily slow without changing the result.

Keep only fixed-kernel filters that have no size parameter:
- Sharpen, Sharpen More, Sharpen Edges (one-pixel unsharp mask kernel)
- Find Edges (one-pixel edge detection kernel)
- Emboss (one-pixel edge-based emboss kernel)
- Despeckle (one-pixel speckle removal kernel)

These use fixed one-pixel kernels with no size parameter, so a downsampled proxy changes their look: edges average away, making sharpening and edge detection invisible. These must preview at full resolution (#3929).

Add tests to verify preview_factor returns 1 for these 6 filters.
Update doc comment to explain why each filter is in FULL_RESOLUTION_PREVIEW.

---------

Co-authored-by: AvrazAkraye <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:39:08 -04:00
Marco ArnulfoandBrandon Thomas adaebc084d Channels: clicking RGB hides the alpha channel overlay (fixes #4538) (#4564)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:37:42 -04:00
skylar taylor-barrick 06387a955c Apply CI formatter output for print guard 2026-10-13 06:36:33 +02:00
6b6a9234d1 feat(vector): add shape from selection with simplify tolerance (#214) (#3954)
* feat(vector): add shape from selection with simplify tolerance (#214)

- Add tolerance and simplifyTolerance parameter (clamped 0.5..10.0 px) to select.convertToShape.
- Wire select.convertToShape in filter_dialog::has_dialog to prompt for tolerance.
- Add tests for knot count tolerance scaling and selection requirement.

* 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-13 00:28:50 -04:00
935aa35a4a Add source layer, Scale and Offset to the Apply Image dialog (#4574)
The engine already took a source layer and Add/Subtract scale and offset,
but the dialog only offered the merged image and no way to set Scale or
Offset, so the frequency separation recipe (Subtract, Scale 2, Offset 128)
couldn't be done from the menu.

Fixes #3965

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-13 00:19:21 -04:00
Vikram BhaskaranandBrandon Thomas cb2cf3ccbd Show a Cancel mnemonic on the unsaved-changes prompt. (#4622)
Windows and Linux already marked Yes and No. Cancel had no letter, so the prompt now shows (C)ancel and C cancels. macOS still hides the letters and keeps the shortcut. (#4582)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:14:12 -04:00
Vikram BhaskaranandBrandon Thomas d825840bce Make the space between popup menu rows part of the rows. (#4624)
Context menus inherited a 6 pt item gap that belonged to neither button, so a drag across Merge Down or an adjustment list fell into dead space. Popup menus now stack their rows, and Pro rows use a square hover. (#4602)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:14:01 -04:00
Vikram BhaskaranandBrandon Thomas 53db0f5c1f Indent layer-effect rows under their layer. (#4627)
Effect eyes sat in the same column as layer eyes, so a long list read as extra layers. The eye, its hit target and the label now sit to the right of that column. (#4572)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:50 -04:00
Vikram BhaskaranandBrandon Thomas 24930b3188 Draw adjustment thumbnails as a half-filled circle. (#4628)
The contrast glyph put a smaller semicircle inside the ring, which read as concentric rings on a layer thumbnail. It is now one circle with a filled half. (#4561)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:39 -04:00
Vikram BhaskaranandBrandon Thomas 2fc225c782 Translate Turkish Amount as Miktar. (#4629)
Tutar is a sum of money. Filter and adjustment Amount strings now use Miktar, the word for a quantity. (#4350)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:28 -04:00
Vikram BhaskaranandBrandon Thomas 438490040b Keep the scrollbar thumb visible while the pointer is on it. (#4630)
The thin scrollbar faded its handle to the same opacity as the track, so scrolling a menu showed a bar with no position. The handle stays opaque and the resting bar is slightly wider. (#4611)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:16 -04:00
Vikram BhaskaranandBrandon Thomas 339397aabb Reject negative PSD style-sheet indexes and a non-finite dpi. (#4631)
A negative Parent, font or normal-sheet index was cast to a huge usize, which skipped the name fallback and could hide the text. Those indexes now have to fit in a usize, and an infinite dpi uses 72. (#4429)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:05 -04:00
rgoza1996andBrandon Thomas 9ade93b247 perf(select): reuse run-table buffer in Feather horizontal pass (#4632)
Feather r50 on 24MP was at ~81ms kernel / ~130ms scenario against the
100ms P27 budget. The cosine-series line() allocated a fresh
n-usize run table per row (thousands of ~48KB Vecs on a 24MP mask).
Reuse one buffer per worker thread via a thread-local; no arithmetic
touched, oracle tests (Photoshop-mapped + direct-Gaussian <=1/255)
prove identical output.

bench_24mp (24MP ellipse, best of 5, x86-64 release):
  feather r50: 80.8 -> 70.7ms (-12.5%)
  feather r250: 144.1 -> 133.6ms (-7.3%)
  feather r1000: 301.7 -> 287.7ms (-4.6%)
Full perf_scenarios P27 with patch: p50 119.3ms (was over by ~30ms,
now over by ~19ms); remaining gap is session/snapshot overhead,
follow-up.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:54 -04:00
F4LCONandBrandon Thomas 99fbd8e96b Move tool: the curved rotate arrow just outside its transform controls (#4658) (#4687)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:42 -04:00
Marco ArnulfoandBrandon Thomas 139f4cf01d Fit Image: the dialog is as narrow as its few fields (fixes #4673) (#4692)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:30 -04:00
f02ba1eadd Channels: drag a channel onto New, Delete or Load Selection (#4689) (#4693)
As in Photoshop, a Channels panel row can be dragged onto the footer's
buttons: New Channel duplicates it (the layer mask's row as
"<layer> Mask copy"), Delete deletes an alpha or spot channel, and Load
Channel as Selection loads it. The layer mask's row on Delete asks
"Apply mask to layer before removing?", like its Layers thumbnail.
Colour channels can't be deleted, so Delete ignores them. The dragged
name follows the pointer and the button under it is highlighted.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:17 -04:00
4fd08b5395 Free Transform: H and V skew fields in the options bar (#4699)
* Free Transform: H and V skew fields in the options bar

Photoshop's Free Transform options bar has H and V skew fields after the
angle; ours stopped at the angle (ui-parity: "Options bar H/V skew
fields | missing", gaps G5).

The bar now reads the box as Photoshop does: the frame scaled (W, H),
skewed (H, V) and rotated about the reference point (`Parts`). Typing a
skew leans the box about the reference point and keeps W, H and the
angle. A box can be read with any V skew, so the session keeps the typed
one (`TransformSession::skew_v`, serde default). Skews stop at ±89°.

A sheared box used to read as taller (H = 100/cos h %); it now reads as
an H skew with H at 100 %. Readouts without skew are unchanged, flips
included.

Docs: ui-parity row done, G5 and target-app-parity notes updated.

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

* i18n(sr): translate the Free Transform skew field tooltips

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-13 00:11:54 -04:00
1cfcc31518 Add timeline playback to Save for Web previews (#4703)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:11:40 -04:00
9cba6a66d8 Screen color picker: no DX12 crash, and the overlay can be left on Windows (#4443) (#4704)
* fix(picker): the screen color picker no longer crashes on DX12 or traps the overlay on Windows (#4443)

Two Windows-only faults in the Pick screen color window:

- Created always-on-top, the monitor-sized picker window makes the DX12
  backend fail swapchain creation (0x887A0001, DXGI_ERROR_INVALID_CALL);
  wgpu then panics in Surface::get_current_texture_view. The window is now
  created at the normal level and raised once its first frame is painted.
- As a deferred viewport the picker was painted on its own, and the main
  window stopped being redrawn while it was up on Windows (OpenGL). A click
  or Esc only stored an answer that the main window's tick() never took, so
  the overlay could not be left. It is now an immediate viewport, painted
  inside the main window's frame.

Verified on Windows 11 / Intel UHD 630: DX12 (default) opens, picks and
cancels; OpenGL picks and cancels; macOS still picks and cancels. Adds a
test that the picker viewport is not built always-on-top.

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

* Screen picker: raise the window level once per picker window, not every frame

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-13 00:11:29 -04:00
715f4fd397 Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3983)
* Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3344)

* Status bar: preserve keyboard editing and handle fullscreen edge crossings

* widgets: initialise the new FieldOpts 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-13 00:09:46 -04:00
skylar taylor-barrick 3454620f89 Format shared print-disable error 2026-10-13 06:04:03 +02:00
39f3660def Open: warn about type in fonts that aren't installed (#4559) (#4576)
Opening a file whose type uses a font that isn't installed said nothing
in the desktop app, so the text silently rendered in a fallback face.
The CLI and automation already reported it; Photoshop warns on open.

The open notice now lists each missing family, with the engine's
existing warning text (type_extra_cmds::missing_font_warnings), for both
the synchronous open and the background open the desktop app uses.

Tests: opening_type_in_a_missing_font_warns,
background_open_warns_about_missing_fonts.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:02:50 -04:00
a7919b24e1 Edit › Transform › Rotate and Skew no longer scale (#3941) (#3986)
* Make Edit Transform Rotate and Skew use their own handle drags.

Rotate and Scale both started Free Transform, so a corner drag scaled the box. Skew only remapped edge handles, so corners still scaled. Give Rotate its own mode that turns the box from handles, and skew a corner along its adjacent edge (#3941).

* Rotate mode: fix the Skew doc comment and 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-12 23:55:31 -04:00
skylar taylor-barrick 8f56cd5be5 Preserve explicit PDF export before disabled submission refusal 2026-10-13 05:54:57 +02:00
F4LCONandBrandon Thomas 321b7da12b Load Files into Stack: Photoshop's dialog with a Source Files list and the align option (#4671, #4696) (#4705)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:54:15 -04:00
skylar taylor-barrick f7a3262531 Merge current PhotoCraft and enforce print-disable guard on both transports 2026-10-13 05:52:52 +02:00
F4LCONandBrandon Thomas 25dd3b4794 Save for Web: no matte by default, as Photoshop's scripted export (#4115) (#4578)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:52:16 -04:00
Brandon ThomasandClaude Opus 5.5 68c624003a i18n(sr): translate the Soft Dark and Soft Light theme strings (#4727)
The Serbian catalog (#4436) and the Soft themes (#4378) landed together, so
sr.tsv lacked their four strings and the ui-egui i18n completeness tests failed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 23:50:26 -04:00
Nigel Ihza FirdausyandBrandon Thomas c8de7870d5 perf: reuse sampled composite across channel visibility toggles (#4080)
* perf: reuse sampled composite across channel visibility toggles

* test: borrow full composite for sampled-grid oracle

* test: compare the entire channel view after invalidation

* style: keep channel cache construction readable

* fix: format channel test and share band renderer

* fix: invalidate sampled channel composite after edits and previews

* refactor: keep channel rendering optimization isolated from compositor

* test: cover both partial-damage and full channel redraw paths

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:44:50 -04:00
Marco ArnulfoandBrandon Thomas 71968f0806 Free Transform: the live preview feathers a linked layer mask (part of #4560) (#4581)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:38:14 -04:00
Vikram BhaskaranandBrandon Thomas 13a16a695b Resize the Liquify brush with Alt+right-drag instead of painting. (#4707)
The preview treated every drag, including Alt plus the right button, as a stroke. That gesture now sets the diameter and density, and a plain left drag still paints. Fixes #4702.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:35:35 -04:00
Vikram BhaskaranandBrandon Thomas 964f9fb9d0 Sample every visible layer in Content-Aware Fill by default. (#4133)
* Sample every visible layer in Content-Aware Fill by default.

An empty layer above the photo used to report a successful fill and stay empty, because only the active layer was sampled. The result is still written to the active layer. sampleAllLayers can be set to false to keep the old behaviour.

Fixes #4026

* Sample other layers in Content-Aware Fill only when asked or the layer is empty.

Reuse the Remove tool's composite, which writes the active layer's format, and leave Photoshop's Sample All Layers checkbox off unless the window has no pixels on this layer. An explicit false still reads only this layer.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:35:21 -04:00
70537058eb Open Canon CR3 camera JPEG previews with container orientation (#4595)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:29:29 -04:00
adfeab1750 Composited CMYK documents now export as CMYK TIFF and JPEG with their profile (#2220) (#4199)
A CMYK document with several layers, masks or effects is flattened through the
RGB compositor. Its TIFF and JPEG exports were written as sRGB RGB, losing the
document's mode and ICC profile. The composite is now converted back through the
document's CMYK profile (relative colorimetric with black point compensation)
inside each parallel worker, written as four ink channels plus straight alpha at
the document depth, and tagged with that profile (the built-in coated CMYK
profile when the document is untagged). Invalid, non-CMYK or unusable profiles
are rejected. RGB-only formats keep the direct sRGB composite, and a single
plain raster layer keeps its native samples.

This fixes the output mode and tagging only: the RGB roundtrip does not
preserve original ink separations, black generation or CMYK-native blending.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:29:26 -04:00
Gert Pellin - GPEandBrandon Thomas 09db9f1424 Content-Aware Fill: no tinted blotch in a smooth gradient (#4708)
inpaint::fit fits each channel as gain · source + bias, the gain the
ratio of the patches' deviations within the level's range and the bias
clamped after it. On nearly flat patches that ratio is noise, so the
gain often sat at its limit; scaling about zero, it moved a bright mean
further than Default's (or High's) bias could bring back, each channel
by a different amount: a tinted blotch in a sky gradient (up to 14
levels at Default, 27 at High).

For nearly flat patches (deviations under about 5 levels) whose means
are already within the bias of each other, the gain is now kept where
the bias can still match the means. Textured patches, and flat ones of
clearly different colours (Content-Aware Move's Color, measured against
Photoshop), are fitted as before.

Fixes #4706

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:27:49 -04:00
876bb8e281 Check peak RAW development memory and bound AHD scratch (#4597)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:25:42 -04:00
23daf16f82 Align tighter camera JPEG references for RAW colour fitting (#4598)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:24:18 -04:00
Brandon ThomasandClaude Opus 5.5 72b499a28f Tile grid overlay: gate rayon to native so the web build compiles (#4694)
#4217 used rayon's par_iter in ui-egui unconditionally, but rayon is a
native-only dependency there, so cargo xtask wasm failed on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 23:20:45 -04:00
Fabio Lissi 70e182b08f panic_hunt: don't fuzz the print commands into a real printer (#4467)
* panic_hunt: don't fuzz the print commands into a real printer

file.print and file.printOneCopy spool to CUPS, so every run of the adversarial-params test
printed a blank page on the default printer. The hunt now skips those two ids (their params
are validated like everyone else's; spooling is not what the test is for).

* Guard the print spooler itself and the CI environment

- print_cmds: disable_printing() (a process-wide flag the panic hunt sets) and the
  PHOTOCRAFT_NO_PRINT environment variable make spool_pdf refuse before running lp; dryRun
  still renders and reports the command. Test.
- CI: PHOTOCRAFT_NO_PRINT=1 and LPDEST=photocraft-no-such-printer, so a regression fails
  loudly instead of printing. Documented in docs/development.md › Environment variables.
2026-10-12 23:13:42 -04:00
15aac8d7b6 Type: type on a path, glyphs follow the curve (#4365) (#4377)
* Type: type on a path, glyphs follow the curve (#4365)

Add TextShape::Path (path, start offset, flip). The text is laid out as one
point-text line and each glyph is placed rigidly on the flattened path by arc
length, turned to the tangent at its centre; glyphs past the end are hidden and
a degenerate path falls back to straight text. New commands type.attachToPath
(work path or shape layer subpath) and type.detachFromPath. TextShape is no
longer Copy. .pcraft round-trips the new variant.

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

* Automation policy: type.attachToPath/detachFromPath take document vector paths, not host files (#4365)

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

* docs: regenerate scorecard after merging 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-12 23:11:09 -04:00
e8af9d592f Themes: Soft Dark and Soft Light (#4368) (#4378)
* Themes: Soft Dark and Soft Light (#4368)

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

* docs: regenerate scorecard after merging 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-12 23:06:37 -04:00
skylar taylor-barrick d6fbac1720 Pin qualification fixes integrated with current print progress 2026-10-13 05:04:13 +02:00
f5f0f73f25 feat(ui): Linux caption buttons follow the desktop layout, style and icon theme (#4439)
* feat: linux caption buttons follow the desktop: layout, style and icon theme

The title bar's minimize/maximize/close buttons now match the desktop:
- Layout and side: GNOME button-layout via the settings portal (live),
  KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini.
- Style family (GNOME round, Breeze chevrons, Windows) follows the
  desktop, then the GTK theme.
- Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop
  lookup with Inherits and hicolor; unlisted theme directories such as
  symbolic/ui are searched lazily), rasterized with resvg at the real
  pixel size, recoloured and cached; missing icons fall back to the
  built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none.
- Theme and portal work runs on a worker thread (10-22 ms per theme).

* feat: linux caption buttons follow the desktop: layout, style and icon theme

The title bar's minimize/maximize/close buttons now match the desktop:
- Layout and side: GNOME button-layout via the settings portal (live),
  KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini.
- Style family (GNOME round, Breeze chevrons, Windows) follows the
  desktop, then the GTK theme.
- Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop
  lookup with Inherits and hicolor; unlisted theme directories such as
  symbolic/ui are searched lazily), rasterized with resvg at the real
  pixel size, recoloured and cached; missing icons fall back to the
  built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none.
- Theme and portal work runs on a worker thread (10-22 ms per theme).

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 23:01:08 -04:00
skylar taylor-barrick 70059ac1b4 Pin driver option retention qualification fixes 2026-10-13 04:56:32 +02:00
20539b5dc3 i18n(sr): add a Serbian (Latin, Ekavian) UI catalog (#4436)
* i18n(sr): add a Serbian (Latin, Ekavian) UI catalog

Adds sr.tsv with 4,022 rows: every key the i18n-coverage tool measures
plus the union of the dynamic labels the other catalogs translate. The
catalog is written in Latin script, Ekavian; every Serbian locale
(sr-RS, sr-Latn-*, sr_RS@latin and Cyrillic sr-Cyrl-*) resolves to it.
Plurals use the one/few/many rule (plural_russian) with three forms per
@plural entry.

Original translations from the plain meaning of the English labels; no
proprietary localisation resources were used (ATTRIBUTION.md and
LICENSE-translations.txt updated). Tests cover locale resolution,
contextual and id overrides, placeholders, and plurals across teens and
compound counts.

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

* Info.plist: declare the Serbian localization

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

---------

Co-authored-by: Dejan Port <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 22:50:20 -04:00
0a32b3593d Add Krita (.kra) import (#4388)
Open .kra and .krz with paint layers (8/16-bit RGBA and gray, any offset), nested
and pass-through groups, names, visibility, opacity, locks, blend modes and
transparency masks, plus the image's ICC profile and resolution. Vector, filter,
fill, file and clone layers and the other mask kinds are left out with a warning;
float, CMYK and Lab documents open as Krita's merged image (preview). Import only:
Save suggests .ora. Checked against 35 Krita 5.2.9-authored files.

Co-authored-by: Scott Campbell <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:41:38 -04:00
F4LCON 05e98e7ade Smart Filters: select a filter, drag or ⌥-drag it onto another smart object (#4271) (#4371) 2026-10-12 22:35:54 -04:00
2842973dc0 Compose: no overflow or unbounded allocation on hostile coordinates (#4427) (#4431)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 22:35:39 -04:00
skylar taylor-barrick 4de93daa91 Pin pre-submission annotation visibility guard 2026-10-13 04:25:32 +02:00
F4LCON b4aea45e19 Actions: record File › Save a Copy and play it back as a copy (#4373) (#4390) 2026-10-12 22:24:18 -04:00
Valentin KleinandClaude Opus 5.5 f586e7133e Compose: key the vector-mask cache by mask content, not a temporary's address (#4426) (#4428)
RenderVectorMasks keyed compiled masks by the address of the VectorMask it
was given. hiding_mask/effects_source pass temporary clones, whose addresses
get reused, so a later layer received an earlier layer's compiled mask.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:24:15 -04:00
F4LCON 1f76d839e3 SVG: a group clipped by one path opens as editable shapes with a vector mask (#4266) (#4425) 2026-10-12 22:24:11 -04:00
FredApps 78c61e6df5 Swatches: an RGB group with the exact primaries and secondaries (#4302) (#4409)
The built-in "Pure" group's hues are vivid but not pure (its green is
#28C850), so picking it for text looked dull next to Photoshop's #00FF00.
Add a Photoshop-style "RGB" group (#FF0000, #FFFF00, #00FF00, #00FFFF,
#0000FF, #FF00FF) right after Grays. Pure is kept unchanged so existing
documents, actions and saved swatches that use it keep their colours.
2026-10-12 22:24:07 -04:00
Valentin KleinandClaude Opus 5.5 5aa6e33adf Content-Aware Move: live preview of the result (#4385) (#4393)
* Content-Aware Move: live preview of the result while the pointer rests (#4385)

A cancellable, debounced background job shows the move/extend result during the drag and in the Transform On Drop box.

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:24:03 -04:00
Marco Arnulfo 88f84aab59 Raw: treat DNG samples piled up below the stated white level as clipped (fixes #4348) (#4389)
Some DNG writers state a white level the sensor never reaches (an iPhone
15 Pro Max Bayer DNG states 4095 and clips at 3567). Development clips
each channel at 1 after white balance, so red and blue reached 1 while
green stayed at 0.85 and blown highlights turned magenta. On decode, a
per-colour pile-up clearly below the white level is now raised to it, as
in storytold/lightcraft#586.
2026-10-12 22:23:59 -04:00
Valentin KleinandClaude Opus 5.5 20e8f949f9 Type: fall back to installed Indic fonts for document text (#4383) (#4386)
* Type: fall back to installed Indic fonts for document text (#4383)

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:55 -04:00
jonathanwxh-cellandjonathnwxh 4543ef35b9 Preserve signed HDR colors in float text rasterization (#4379)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-12 22:23:50 -04:00
Valentin KleinandClaude Opus 5.5 ee8aca562b Engine: reject unknown params for layer.hideLayers/showLayers via an opt-in registry check (#4336) (#4364)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:46 -04:00
f80e57fb8a Fix macOS system dialog localization (#4261)
* Fix macOS system dialog localization

* Info.plist: declare the Hungarian and Turkish localizations 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-12 22:16:46 -04:00
575f8eb2bc Allow toolbar tool shortcuts to be remapped (#4194)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:16:06 -04:00
cee010ef5a Replace Color: a canvas eyedropper picks the From colour (#4143) (#4247)
The dialog had no colour control at all: the engine defaulted the From
colour to the foreground and the spec's `color` param rendered nothing.
It now shows a Color row (swatch opens the Color Picker, seeded from the
foreground) plus a pipette that arms a canvas eyedropper: the next click
on the image samples the merged composite into the field, like the
Curves picker and Photoshop's Replace Color.

Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:03:15 -04:00
4d4103460f Toolbar: a press outside a tool flyout closes it (#4460) (#4480)
An open tool flyout closed only on a click outside it. Dragging on the
canvas (cropping, painting) isn't a click, so the flyout stayed open
over the work.

Any press that starts outside the flyout and its button now closes it,
as in Photoshop. A press on the flyout itself still keeps it open for
the row it picks on release, and Escape still closes it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 22:01:17 -04:00
1cb3eaa5d2 Free Transform: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) (#4218)
Committing Free Transform on a 300-550 MP layer ran the bicubic warp on the UI
thread: several seconds of a frozen window with no feedback. On a 20000x16000
layer the warp alone took about 6.6 s.

edit.transform is now a background job (edit_job): the warp reports progress per
tile and stops when cancelled. The transform session stays open while it runs,
showing the moving pixels without the box, so the layer doesn't jump back to its
old place until the result lands. The modal progress dialog shows after the
usual delay, and Esc or Cancel cancels the job. A copy or placed layer the
session made is folded into the step, or taken back, in its own document.
Undo and Redo are refused while the commit runs.

Tests and scripts run inline as before.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:57:47 -04:00
b1b44595b7 Toolbar: no tooltips over an open tool flyout (#4450) (#4469)
Holding a tool button to open its flyout showed that button's tooltip on
top of the flyout, and other toolbar buttons kept showing theirs while it
was open. The flyout is our own Area, so egui's rule that hides tooltips
while a popup is open didn't apply.

The toolbar's buttons (the tools, Quick Mask, Change Screen Mode) now skip
their tooltips while a tool flyout is open or the mouse button is held.
icons::button_with_icon_size_tip keeps the tooltip text as the button's
label when the tooltip is hidden.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:29 -04:00
875e17ea33 Save As: on Windows, picking another file type changes the extension (#4394) (#4407)
With File name extensions shown in Explorer, the Windows save dialog keeps
a visible extension when the user picks another type: image.psd saved as
PhotoCraft stayed image.psd. Given a bare name, the dialog adds the chosen
type's own extension (rfd sets the default extension, which Windows
updates on every type change).

On Windows the dialog now starts with the stem, and the suggested type
leads the type list with the suggested extension first (so .psb, .fit and
.jpeg stay what they were). Names with a second dot or an upper-case
extension keep the whole name. macOS and Linux are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:24 -04:00
c1e3ae9f16 Pen: Auto Add/Delete anchors on the active path (#4144) (#4540)
* Pen: Auto Add/Delete anchors on the active path (#4144)

With the Pen's new Auto Add/Delete option (options bar, on by default as
in Photoshop), a click on a segment of the active path adds an anchor
there and a click on one of its anchors deletes it, one History step
each, instead of starting a new path. The active path is the targeted
vector mask or the active shape layer's path, then the work path.

The end points of an open subpath, a path being drawn, and Shift, Alt
or Cmd/Ctrl held leave the click to the Pen. Hovering highlights the
segment or anchor and the cursor shows + or -, like the Add/Delete
Anchor Point tools, which share the same code (anchor_tools::apply).

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

* Pen: keep the close-drag test off Auto Add/Delete when it redraws over a finished path

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 21:56:19 -04:00
c96842f7d9 The wheel steps the options bar's Mode dropdown for Brush, Pencil and Gradient (#2061) (#4447)
The options bar's Mode dropdowns now use widgets::dropdown_wheel_hovered, the wheel stepping the
Layers panel's Blend Mode already has (#1747): down picks the next mode, up the previous one, one
per notch, stopping at the ends, over the button or the open list. The Brush and Pencil list keeps
Photoshop's Clear after Dissolve. Adds my contributors/people.toml entry.


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

Co-authored-by: megaDon101 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:14 -04:00
a9bb38c96e Clone Source: clip the overlay to a shaped brush tip (#4372) (#4376)
* Clone Source: clip the overlay to a shaped brush tip (#4372)

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

* docs: regenerate scorecard after merging 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-12 21:56:09 -04:00
Marco Arnulfo 2cb3b70d97 Crop tool: the drawing crosshair, not the move cursor, inside the untouched default frame (fixes #4461) (#4481) 2026-10-12 21:47:50 -04:00
F4LCON 1bc7d7baad Puppet Warp: ⌘Z / ⌘⇧Z undo and redo pin edits (#4440) (#4479) 2026-10-12 21:47:45 -04:00
Dan HiornsandClaude Opus 5.5 40a93b7dc4 Toolbar: click and hold on Change Screen Mode opens its modes (#4444) (#4464)
The button draws the flyout triangle, but its list was an egui context
menu, which opens on right-click (or a touch long-press) only. Clicking
and holding with the mouse did nothing until release, which then cycled
the screen mode.

A hold of 0.35 s, the same as the tool flyouts, now opens the list, and
the release that ends the hold neither cycles the mode nor closes the
list. A plain click still cycles (F), and right-click still opens it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:41 -04:00
Dan HiornsandClaude Opus 5.5 98ec507375 Paths: curves stay smooth when zoomed in (#4084) (#4445)
The path overlays sampled every Bezier segment at a fixed 16 points (24
for the Curvature Pen's preview) in document space, so zoomed in a curve
showed as visible straight chords with corners.

curve_points maps a segment's four points to the screen (the view is
affine, so the curve maps exactly) and picks the number of chords from
how much it bends there: the chord error stays within 0.3 px (the bound
the vector crate's flattening uses), capped at 1024 chords. A straight
segment, with handles retracted or along the line, is one chord. Used by
path_lines (work path, shape and vector mask outlines, Direct Selection,
the shape preview, Paths thumbnails), the Pen's path in progress and the
Curvature Pen's preview.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:36 -04:00
F4LCON 6c2934ba79 Recent files: a saved document is listed, as an opened one is (#4400) (#4411) 2026-10-12 21:47:32 -04:00
F4LCON 851916e0d7 Save As: offer ICO, GIF and BMP in the file type list (#4357) (#4405) 2026-10-12 21:47:27 -04:00
Tanish Manku 3f2ac1601e macOS: big brushes keep a native outline too, and big cursor bitmaps rasterise 7x faster (#1343) (#4401)
On macOS the brush outline fell back to the app-drawn outline past 255 points, and that
outline trails the pointer by 26 ms on a 120 Hz Mac (syncroot's measurement in #1343): a
1000 px brush at fit, or 300 px at 200 %. AppKit sets no size limit on an NSCursor image, so
macOS now takes the same 2048 cap as Windows. Linux keeps 255 for X servers and hardware
cursor planes.

A circle's bitmap evaluated every pixel of its square, though only a thin ring is painted:
a 1000 px radius took 41.6 ms per bitmap, a hitch on each zoom step or [ / ] resize (on
Windows too). Only the ring's two arcs per row, and the centre mark, are evaluated now, on
a transparent buffer: 6.2 ms (radius 500: 10.6 -> 1.7 ms), identical pixels. App-drawn
tip outlines likewise only write the pixels their segments splat.
2026-10-12 21:47:23 -04:00
F4LCON f5d64a66bd Color panel: the spectrum field in every theme (#4367) (#4399) 2026-10-12 21:47:18 -04:00
10ba166a82 feat(codecs): open DDS textures, uncompressed and BC1-BC3 (#1327) (#4255)
A read-only DirectDraw Surface codec on the model of PCX: the top mip
level of a 2D texture, uncompressed (channel masks, RGB or luminance,
8 to 32 bits) or BC1/BC2/BC3 (DXT1/3/5), with the legacy or the DX10
header. Cubemaps, arrays, volumes and other formats are refused with
an Unsupported error naming what was found; every header field is
treated as hostile (checked sizes, limits before allocating, no
indexing on file offsets).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:16 -04:00
FredApps b507e04f1f Menus keep clear of a panel that covers a maximized window (X11) (#3737) (#4380)
On X11 a window manager that doesn't reserve a bottom panel's space maximizes
PhotoCraft to the whole monitor, and the panel covers its bottom. The work-area
check (#315) trusted every maximized window to be inside the work area, so long
menus (Layer, View, Window, Edit at larger UI scales) ran under the panel with
their scroll arrow hidden. A maximized window whose position is known is now
checked like any other; only full screen, or maximized with no position
(Wayland, #3739), uses the whole window.
2026-10-12 21:47:14 -04:00
Valentin KleinandClaude Opus 5.5 f1855caf73 Type tool: spelling suggestions in the edit menu (#4370) (#4375)
* Type tool: spelling suggestions in the edit menu (#4370)

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

* Type spelling menu: cap suggestions at 3 so the menu stays short (#4370)

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:09 -04:00
Valentin KleinandClaude Opus 5.5 72ef526d8e Pen: show a close cue when hovering the first anchor (#4193) (#4361)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:04 -04:00
0002d861ee fix(io): read CS2 placed layers that carry only a PlLd block (#2465) (#4268)
Photoshop CS2 predates SoLd: its smart objects have a PlLd (plcL) block
alone, with the embedded file in a global lnkD block. Import only read
SoLd/SoLE, so such layers got an empty source id and Edit Contents said
"the smart object's contents are unavailable".

Parse PlLd from the Adobe PSD spec (unique id and the 8 corner doubles).
PlLd stores no source size, so the transform takes it from the embedded
file's header (linked::source_size_probe: PSD, TIFF, PNG and JPEG
headers, any other image decoded flat by photocraft-codecs). It never
imports the file as a document: a layered TIFF can carry PSD layer data
whose lnkD holds another file, so that would nest without bound. Without
the file, 100 % scale is assumed. source_geometry moves to linked.rs. Non-parallelogram corners give the projective placement as
for SoLd. An unreadable PlLd is reported as a warning.

The PlLd corners are the same placed quad as SoLd's Trnf, so they frame
the same source-space box (#3677, blocks::placed_frame): the bounding
box of a single-patch custom warp's 16 control points, which may spill
past the source, else the source rect. corners_affine now maps such a
frame onto the corners and is shared by parse_smart and plld_transform,
which is the inverse of smart_map::quad_points (what plld_bytes writes):
affine corners frame placed_frame(warp), projective ones the source
rect. A custom warp's box needs no source size, so a spilled mesh is
placed right even without the embedded file. corners_affine refuses
non-finite corners or an empty frame (identity, as before).

CS2 can warp a smart object, and PlLd keeps that warp after the corners
(u32 warp version, then a versioned warp descriptor). It is read with
the same code as the SoLd warp (warp_of), so a re-render from the now
available source (Free Transform, Image Size, mode changes) stays
warped. A stored warp that can't be read leaves the contents
unavailable, keeping the warped pixels, with a warning.

An edited PlLd-only layer (moved, transformed, warped, filtered or given
other contents) is saved like any other smart object, SoLd plus a
regenerated PlLd, with its current placement; an unedited one keeps its
record verbatim, as does one whose file isn't embedded (its id is never
read as a disk path). plld_edited compares against plld_transform with the
record's own warp, so it uses the same frame as import and as the
writer. A layer with both SoLE and PlLd is read from SoLE.

Tests: PlLd round trip and malformed input (truncation, bad magic,
oversized Pascal length, NaN) in blocks.rs; PlLd warps round trip
through plld_bytes (preset, custom mesh, identity, none) and unreadable
warps are reported as such; a PlLd whose custom mesh spills past the
source places like the equivalent SoLd, with and without the source
size; PlLd-only layers with a missing file, a broken record, a warp or
an unreadable warp; a spilled-mesh PlLd-only layer imports with the
SoLd layer's placement and an unedited one saves its PlLd verbatim (no
SoLd) and reopens in place; and engine tests that rewrite an export
into the CS2 layout (no SoLd, lnkD with version-2 liFD items) and check
transform, contents, re-render, Edit Contents, and that a warped smart
object re-renders warped.

source_size_probe has header tests for each format and malformed JPEG
marker walks.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:44:49 -04:00
e90dfdcf10 feat(ui): Photoshop's type formatting keys while editing text (#4239)
* feat(engine): relative leading, baseline shift and tracking steps in type.setStyle

`leadingBy`, `baselineShiftBy` and `trackingBy` move every character in the
range from its own value, so mixed values keep their differences. Auto
leading starts from the value layout uses (the paragraph's auto-leading
percentage of the character's size), results clamp to Photoshop's ranges,
and non-finite or oversized steps are refused as bad params. `leadingBy`
follows `metricsAsShown` like `leading`.

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

* feat(ui): Photoshop's type formatting keys while editing text

While a Type tool edit is active, Shift+Cmd+K/U/B/I and Shift+Cmd+/ toggle
All Caps, Underline, Faux Bold, Faux Italic and Strikethrough on the
selection (off when every selected character has it, else on);
Alt+Up/Down step leading by 2 pt and Shift+Alt+Up/Down baseline shift by
2 pt (10 with Cmd); Alt+Left/Right over a selection track it by 20 (100
with Cmd), a collapsed caret still kerning; Cmd+Alt+Shift+H toggles
Hyphenate for the paragraphs touched. Each press is one type.setStyle
history step, and the keys no longer fall through to Color Settings,
Desaturate, Auto Color or Inverse while typing; outside an edit those
shortcuts work as before.

A collapsed caret in non-empty text changes nothing: Photoshop styles the
next characters typed, which the document model can't hold apart from the
text. In empty text the style becomes the typing style.

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 21:41:23 -04:00
ccfc878a14 Camera Raw Filter: save and load settings as .xmp (#4279)
* Camera Raw Filter: save and load settings as .xmp (#4274) [WIP]

* Camera Raw Filter: Save Settings… and Load Settings… (.xmp) (#4274)

Adds Save Settings… and Load Settings… to the Camera Raw Filter dialog's
footer. They write and read Adobe Camera Raw settings files: the crs:
namespace on x:xmpmeta/rdf:RDF/rdf:Description, as attributes or elements.

photocraft_io::camera_raw_xmp maps every control the dialog has:
- Basic (Exposure2012 … Saturation, IncrementalTemperature/Tint)
- the parametric and point tone curves (ToneCurvePV2012[Red|Green|Blue])
- the HSL mixer
- Color Grading (including the SplitToning* names for the shadow and
  highlight wheels)
- sharpening and noise reduction
- grain
- post-crop vignetting

Loading applies only what the file holds over the current settings, as
Camera Raw does. Values are clamped to the slider ranges. Unsupported
settings (lens, calibration, geometry, defringe) are ignored, and the
status bar counts them when they aren't neutral. Malformed files fail
with a message and leave the settings unchanged. The control channel
takes {"ui":{"saveSettings"|"loadSettings":true}}.

Translations are added for all 17 catalogs.

Fixes #4274

* Add Turkish and Hungarian rows for the Camera Raw settings 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-12 21:34:54 -04:00
465a663540 feat(ui): developer tile grid overlay (ui.set tileGrid) (#4217)
Draw the active layer's storage tiles over the canvas: a faint
TILE_SIZE grid over the document, every stored tile filled and
labelled tx,ty, tiles with identical pixels in one colour per group
and unique tiles in blue. It shows how sparse a layer is, which tiles
copy-on-write shares, and which ones .pcraft saves once.

It has no menu item and isn't a View > Show item. The control channel
and MCP turn it on with ui.set {"tileGrid": true}; ui.inspect reports
it as view.tile_grid, and View > Extras doesn't hide it.

Grouping hashes each distinct tile allocation once, in parallel (or
serially while jobs hold the pool, #1554), cached per document
revision and active layer. A 36 MP layer of all-different tiles groups
in 3.9 ms (8-bit) and 7.5 ms (16-bit) on 24 cores in release; the
ignored tile_grouping_36mp test reproduces this.

Also documents tileGrid in docs/control-protocol.md and
docs/development.md, and adds my contributors/people.toml entry.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:31:32 -04:00
8157d1a851 Free Transform: refuse outputs over a pixel budget instead of building them (#4293)
A large scale (e.g. 200x on a thin column) made the warp fill every tile of
a result up to 2^21 px on a side, enough to exhaust memory. Free Transform
now prices each warp before building anything and refuses with an
allocation-style error ("...; the document was not changed") once a result
would exceed MAX_TRANSFORM_PIXELS (2^30, the same bound as Puppet Warp's
MAX_WARP_PIXELS). Only the result's size counts, so moving content far off
the canvas still works, and nothing is clipped.

The check covers the layer, a smart object's cache and filter mask, linked
masks, a lone mask/channel target and the selection outline, and runs over
the whole layer tree before any layer is changed. Transform Selection gets
the same check. The transform box now stays open when a commit is refused.

Part of #3773

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:12 -04:00
Valentin KleinandClaude Opus 5.5 dd258cccf9 Type: faux bold keeps antialiased edges at small sizes (#4130) (#4360)
* Type: faux bold keeps antialiased edges at small sizes (#4130)

Faux bold drew each glyph at 8 offsets into one signed-area coverage buffer, so edge pixels from the copies summed and saturated to full coverage (hard, pixelated stems, about 2.2x the ink at 12 px). Each offset now gets its own buffer, merged with max.

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

* Type: faux bold ring radius tuned so stems keep their weight (#4130)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:07 -04:00
Valentin KleinandClaude Opus 5.5 f6db7a8115 Smart Objects Warp: mesh knots span the contents, not the skewed bounding frame (#4113) (#4341)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:03 -04:00
FredApps 96f4256ced fix(type): the Type tool's canvas pick skips text in hidden groups (#4320) (#4332) 2026-10-12 21:26:59 -04:00
MrCloudy2andClaude Opus 5.5 b79d0a0bcf feat(gradients): Photoshop's interpolation method for Gradient Overlay, gradient Stroke and the Gradient tool (#4292)
layer.layerStyle.gradientOverlay, layer.layerStyle.stroke (gradient) and
paint.gradient take "method" (classic|perceptual|linear|smooth) and
"smoothness" (0..100, default 100). The stops are baked with the same
model PSD import uses (io::gradient_bake, now public as bake_gradient and
GradientMethod), so the compositors' linear interpolation reproduces
Photoshop's curve. Without a method the stops stay as they were.

Measured in Photoshop 27.11 (max level error vs main -> this change):
Gradient Overlay linear/reflected styles, any angle/scale/reverse 12 -> 0-1,
radial/diamond 16 -> 4-5; gradient Stroke 12 -> 2-4; Gradient tool
black -> white Classic 13-14 -> 2-3; red -> blue Classic 12 -> 1,
Perceptual 83 -> 2, Linear 64 -> 4, Smooth 83 -> 3. A 3-stop Classic
gradient improves only 14 -> 10 (the multi-stop smoothness spline is a
separate follow-up).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:26:53 -04:00
Nigel Ihza Firdausy ab5a6c5ed2 fix(layer-picker): include text bounds in right-click layer list (#4288) 2026-10-12 21:26:49 -04:00
1412ac26e2 Save for Web and Export As remember their settings across restarts (FILE-215-18) (#4184)
* engine: remember the last Save for Web settings in the preferences

* engine: test that a complete export remembers its settings for the next launch

* ui: seed the Save for Web dialog from the remembered preferences

* ui: Export As opens with the settings it was used with last

* scorecard: FILE-215-18 done, the export dialogs remember their settings

* docs: scorecard, File compatibility 7 done of 27

* 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-12 21:26:47 -04:00
Persi 37ee4f5a20 Simplify the raster stale-block filter for clippy's nonminimal_bool (#4314)
Clippy 1.96 flags the `retain` predicate that strips stale content blocks
from pixel layers on PSD export (`clippy::nonminimal_bool`), which breaks
`cargo clippy -- -D warnings` on current stable.

Rewrite it as a single negated disjunction. The four terms, their order and
the short-circuiting are unchanged, so the same blocks are kept and dropped.
2026-10-12 21:26:45 -04:00
97d06b240b feat(ui): add hamburger menu to the Brush Settings panel (#4272) (#4358)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:24:09 -04:00
AlonandClaude Opus 5.5 ab82871271 fix(compose): paint interior effects over the layer's blend mode (#3996) (#4231)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:57 -04:00
David MannandClaude Opus 5.5 95663451c0 Find Edges and Solarize: treat CMYK values as ink (#4222) (#4223)
CMYK samples are ink (1 = full ink), but both filters assumed light
polarity. Find Edges wrote 1 - edge, so flat CMYK areas became 100% ink
(black) instead of white; Solarize inverted channels above 50%, i.e. the
shadows. Both now use fxutil::subtractive(ctx), like Trace Contour: Find
Edges writes the edge strength as ink, Solarize inverts channels below
50% ink. Alpha stays untouched. RGB, Grayscale, Lab and other modes are
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:54 -04:00
David MannandClaude Opus 5.5 468d1c622a Bitmap mode: validate the halftone screen angle (#4214) (#4215)
Image > Mode > Bitmap (halftone) passed its angle through unchecked: 1e39
became inf as f32, the screen rotation turned it into NaN and an almost
white bitmap was recorded in history; 500 was accepted although the
params doc (and Photoshop's Halftone Screen dialog) says -180..180.

The angle must now be a finite number in -180..=180 and the frequency a
finite number (still clamped to 1..999 lines/inch); otherwise the command
returns BadParams before touching the document.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:49 -04:00
David MannandClaude Opus 5.5 8029b061af EXR: an unnamed part can be opened by the name the part list gives it (#4210) (#4211)
file.exrParts lists a part without a name attribute as "part <index>", but
file.openExrParts only matched parts whose name attribute equalled the
requested name, so "part/part 0" failed with "no flat part named part 0".
One helper now gives the display name for the listing, the lookup and the
layer name; a named part still wins over an unnamed one with the same
fallback name.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:45 -04:00
David MannandClaude Opus 5.5 a0e9ee213d Cryptomatte: decode the .blue/.alpha rank of every channel set (#4208) (#4209)
Each Cryptomatte RGBA set stores two (ID, coverage) ranks, (red, green)
and (blue, alpha), but only red/green were read, so every second rank
was lost and mattes got holes at edges and overlaps. Suffixes were also
matched case-sensitively, missing Blender's .R/.G/.B/.A.

Stream channels now carry a rank (0 red/green, 1 blue/alpha), suffixes
match in any case, and decode stores each channel at slot*2+rank
(bounds-checked), still sorted by coverage. The allocation check counts
four channels per set.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:41 -04:00
David MannandClaude Opus 5.5 72c8f58e11 Shapes: a rectangle with a negative size extends from its origin like the other shapes (#4203) (#4204)
rounded_rect took the absolute width/height without moving the origin, so
shape.create {"kind":"rect","rect":[50,10,-20,30]} covered x 50..70 while an
ellipse or polygon with the same rect covered 30..50. Normalise (x, w) and
(y, h) for negative sizes instead. The Shape tool drag already normalises its
rect, so interactive drawing is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:38 -04:00
David MannandClaude Opus 5.5 e4f15eecdf Vector masks: validate feather like pixel masks (#4201) (#4202)
layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became
inf (hard edge, null in info, .pcraft save refused) and any huge value was
accepted. It now takes the same finite 0..=1000 px range and error as
layer.layerMask.edit, checked before the edit so a bad value leaves the mask
and history untouched. The params doc says so.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:14 -04:00
JGFandClaude Opus 5.5 63a0e43e74 fix(type): key font menu caches on the shared engine's fonts (#4200)
The font menu's preview samples, family list and localized names were
cached on fonts::generation(), which any FontDb moves, and the previews
also on the served catalog's generation. Other engines registering fonts
(tests running in parallel) re-rendered every visible sample each pass,
and families covering Arabic asked for a repaint, so Harness::run never
settled: options_font_family_preserves_peer_faces_and_batches_layer_flags
and the font picker tests failed intermittently with "exceeded max_steps".

- photocraft-text: fonts::shared_generation(), moved only by the shared
  engine's FontDb (FontDb::mark_shared from new_shared).
- ui-egui: families(), localized_families() and the preview cache key on
  it; previews no longer key on the served catalog (a served family's
  sample appears when its fonts register, which moves the generation).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:17:10 -04:00
David MannandClaude Opus 5.5 56869d12e3 Layer Comps: the warning icon clears only its own comp (#4197) (#4198)
The warning triangle on a comp row sent layerComp.updateWarnings
{"clear":true}, which dropped the stale states of every comp. The
command now takes an optional "comp" (id or name) that limits both the
report and the clear to that comp; an unknown comp is an error and
changes nothing. Without "comp" it still acts on every comp (Clear All
Layer Comp Warnings). The row icon sends its own comp id.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:06 -04:00
David MannandClaude Opus 5.5 71827a614c Color Sampler: keep the CMYK readout in range on 32-bit documents (#4191) (#4192)
32-bit samples can lie outside 0..1. The RGB readout clamped them, but
device_cmyk did not, so a pixel at 1.5 read K:-50% and negative values
gave C/M/Y above 100%. Clamp each channel to 0..1 (NaN reads as 0) before
the conversion; this fixes both the Color Sampler and the Info panel.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:01 -04:00
David MannandClaude Opus 5.5 60c825513c Type: every text edit respects Lock All (#4188) (#4189)
#3132 refused type.edit on a Lock All layer, but type.insertText,
edit.checkSpelling "change", Paste Lorem Ipsum (and the other
type_extra_cmds::with_text edits) and Find and Replace Text "change"
on a layer locked after the find still changed its text and recorded
history. The check is now one pub(crate) helper,
type_cmds::check_text_unlocked, called by every one of these paths
with the same "...is locked" error as type.edit.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:16:56 -04:00
94ea94f889 feat(ui): add GRD import option to gradient map preset picker (#4337)
* feat(ui): add GRD import option to gradient preset picker

* Translate the GRD import strings into Vietnamese, Turkish and Hungarian

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 21:14:16 -04:00
858fbcf1ad Object Selection: hover preview of the object it would select (Object Finder) (#4087) (#4095)
* WIP: Object Selection hover preview (#4087)

* Object Selection: Object Finder hover preview and click-to-select (#4087)

Hovering with Object Selection highlights the object a click selects,
computed off the UI thread after a short rest and cached per document
pixels; a click commits it via select.object {point} honouring
add/subtract/intersect; dragging keeps the rectangle mode. Adds an
'Object Finder' options-bar toggle (default on, persisted) with
translations in every catalog.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:06:58 -04:00
908c47021f Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298) (#4308)
* Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298)

An adjustment layer has no pixels, but once its icon thumbnail was clicked (or it was
reached with Alt+[ / Alt+]) the Layers panel no longer targeted its mask, so Edit > Fill
(Shift+F5) and the fill keys were greyed out, and the Paint Bucket and brushes failed with
"not a pixel layer". As in Photoshop, painting and filling an adjustment layer now always
edit its mask; the panel's target (brackets, trash, Delete) is unchanged.

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

* Keep the web read_dropped doc comment on its function

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 21:05:59 -04:00
1874214f79 fix(ui): keep brush settings section lock icons permanently visible (#4278) (#4300)
* fix(ui): keep brush settings section lock icons permanently visible (#4278)

* test(ui): verify brush settings locks stay visible and toggle on click (#4278)

* Format the lock-icon 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-12 21:00:58 -04:00
Korg1ch 0d6a83e3ce fix(ui): add Brushes button at top of Brush Settings control list (#4265) (#4354)
* fix(ui): add Brushes button at top of Brush Settings control list (#4265)

* test(ui): verify Brushes button at top of Brush Settings opens Brushes panel (#4265)
2026-10-12 20:56:18 -04:00
skylartaylor d9bc5a8cd5 Use shared native print transport and keep page composition in PhotoCraft 2026-10-13 02:56:11 +02:00
Korg1ch 4122bc6918 fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172) (#4335)
* fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172)

* test(ui): verify Gradient tool icon rasterizes a left-to-right ramp (#4172)
2026-10-12 20:56:09 -04:00
Korg1ch ec61df6f06 fix(ui): make clickable tab, checkbox, and radio labels non-selectable (#3972) (#4331)
* fix(ui): make clickable tab and radio labels non-selectable with pointing hand cursor (#3972)

* test(ui): verify New Document preset tabs show pointing hand cursor instead of text caret (#3972)
2026-10-12 20:55:47 -04:00
Dan HiornsandClaude Opus 5.5 68a31b5a12 Free Transform: unlinking W and H frees the handles too (#4174) (#4322)
The options bar's Maintain Aspect Ratio link only affected typed W/H values; dragging a
corner or edge handle still scaled proportionally, so unlinking seemed to do nothing. As
in Photoshop CC 2019 and later, the link now also sets the drag default: unlinked, corner
and edge drags stretch freely and Shift keeps the proportions. Linked (the default) and
the legacy preference are unchanged. The link state moves from egui temp data into
UiState (transform_unlinked), next to the transform session.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:55:38 -04:00
Lessa 315c9cc0bc Give numeric type controls translated accessible names (#4310)
* Give numeric type controls translated accessible names

* Find the size and leading row by accessible names in layout tests
2026-10-12 20:55:28 -04:00
Dan HiornsandClaude Opus 5.5 e777690ac6 Properties: the Studio-layout themes show it for every layer (#4246) (#4299)
In the Studio-layout themes (Studio, Studio Light, Breeze, Adwaita,
Solarized) Properties is a floating card over the canvas. The card
returned early unless the active layer was an adjustment or fill layer
or a mask was targeted, so for a pixel layer, the Background, type or a
group nothing was drawn, although Properties was on (the rail button
showed it). The Pro theme's docked panel shows all of those.

The card now renders the docked panel's body (properties_body), so both
show the same content: the Document properties for the Background or no
selection, layer, adjustment and mask properties. Its title bar, hide
button, position and drag are unchanged.

Tests: floating_properties_card_shows_document_properties_for_the_background
and floating_properties_card_shows_pixel_layer_properties (Studio and
Breeze Dark) failed before the fix.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:54:20 -04:00
Dan HiornsandClaude Opus 5.5 21b018c8a9 Brush Settings: Brush Tip Shape lines up with the other section names (#4282) (#4294)
The section list indented every row without an enable box by the box's
width, except the first, so Brush Tip Shape started in the checkbox
column while Smoothing, the other box-less row, lined up with the names.
Photoshop indents Brush Tip Shape too. Every name now starts in the
same column.

Test: section_names_line_up_whether_or_not_they_have_a_box checks the
painted x of every section name (Brush Tip Shape was 20 px left before).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:54:11 -04:00
Nigel Ihza Firdausy 94b6042289 fix(linux): follow GNOME's light default when the portal has no preference (#4289) 2026-10-12 20:53:58 -04:00
6f41b324a6 Canvas: a tool drag past the edge auto-scrolls the view (#4183) (#4257)
* Canvas: a tool drag past the edge auto-scrolls the view (#4183)

Dragging a marquee, lasso, stroke or crop off the visible part of a zoomed
image now scrolls toward the pointer, as in Photoshop, and stops at the
scroll limits. An axis where the image fits does not scroll, and an
overscrolled view is not pulled back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VFAjTZQw6MTyhfpQqjAfoW

* Fix rotated canvas auto-scroll limits

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: jonathnwxh <[email protected]>
2026-10-12 20:53:49 -04:00
Marco Arnulfo 8145bea70f Control: a transport that gives up knows whether the command was dispatched (#3662) (#4256)
* Control: a timeout says whether the command ran (#3662)

A request still queued when its deadline passes is dropped before
dispatch, but it answered with the same text as the transport giving up
on a command that may still apply. The two now read differently: "timeout:
not run ... safe to re-send" and "timeout: no reply in 60s ... may still
apply", both keeping the `timeout` prefix machines match on.

* Control: a transport that gives up knows whether the command was dispatched (#3662)

The transport and the UI thread share the reply deadline, and the
transport's wait runs out first, so the UI thread's "not run" reply
almost never reached a client. A request now carries a dispatch flag
settled once between them: the UI thread claims it before running, the
transport abandons it when it stops waiting. The transport then replies
"not run" for a request that will never run, and "may still apply"
only for one that was dispatched; a request given up on is never run.
2026-10-12 20:53:33 -04:00
.CooCoo 3e3a948c0b Tests: GPU startup marker tests wait while another test's child process holds the lock (#4251)
returned_non_graphics_errors_do_not_trigger_backend_recovery failed at
random in full `cargo test` runs: "gpu-error: ...", left: false,
right: true. Its gpu-error case drops the sentinel, which leaves the
marker file in place, and calls `Sentinel::begin` again for the same
directory. Tests in monitor_profile.rs spawn processes in the same
binary. A child process started while the marker is open gets a copy
of its file descriptor, and the marker stays locked through that copy
until the child's exec closes it. When `begin` runs in that window, it
returns `Previous::Busy` instead of the crashed marker, and the
assertion fails.

sentinel_detects_a_crash_and_clears_on_success has retried `begin` for
this since 3b7870be (#289). The retry moves into a test helper,
`begin_when_unlocked`, which also runs where
returned_non_graphics_errors_do_not_trigger_backend_recovery and
a_failed_cpu_start_does_not_pin_later_starts start again after
dropping or finishing their sentinel. In
a_failed_cpu_start_does_not_pin_later_starts the CPU plan finishes the
sentinel, which removes the marker, so the helper's first `begin`
finds the lock free; the helper covers that test's branch for a plan
that keeps the marker.

A new test, begin_when_unlocked_waits_for_a_briefly_held_lock, takes
the marker's lock through a second handle after the sentinel is
dropped, releases it from a thread after 50 ms, and checks that the
helper returns the crashed marker. It fails when the helper calls
`begin` only once.
2026-10-12 20:53:09 -04:00
e627b01169 feat: resize Save for Web previews and custom palette editing for Gif export (#4129)
* feat: resize Save for Web previews and added palette editing for Gif/PNG8

* Save for Web: add Turkish and Hungarian rows for the preview navigation hint

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 20:50:00 -04:00
Fabio Lissi 137d7c2df1 Tests: set the Move tool's Auto-Select target to Layer in the layout_perf clicks (#4056) (#4548)
#4093 changed the Auto-Select default to Group, as in Photoshop. The two
layout_perf tests that click layers inside groups with Auto-Select on
expected the layer under the pointer, so they now select the group and
fail on main. They set the target to Layer explicitly, which is what
they test.
2026-10-12 20:44:01 -04:00
David MannandClaude Opus 5.5 2f0979f61f Global Light: reject angles beyond f32 range instead of storing NaN (#4181) (#4182)
layer.layerStyle.globalLight cast its params straight to f32, so an angle
of 1e39 became inf and rem_euclid turned it into NaN, which was stored,
recorded as a history step and fed to every effect using global light.
Angle and altitude are now read as f64 and rejected with BadParams when
non-finite or beyond f32 range, before the edit. In-range wrapping and the
altitude clamp are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:38:05 -04:00
David MannandClaude Opus 5.5 0fe109f7b2 Contours (.shc): keep long names valid and readable (#4177) (#4179)
The writer capped names with encode_utf16().take(1024), which could end
on a lone high surrogate; it now uses io::utf16_units, like .aco/.ase,
so the cap falls on a character boundary.

The reader's "count left out the null" check looked at the last unit
kept, not the last unit read. For a name of 1024+ units the counted null
is read but not kept, so a following 0x0000 (an empty contour's point
count) was swallowed and the stream shifted. It now checks the last unit
actually read.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:38:01 -04:00
David MannandClaude Opus 5.5 43c7584d5d Warp: move only the selected part of a linked mask (#4175) (#4176)
With a selection, Edit > Transform > Warp warped only the selected pixels
but the whole linked layer mask, so the unselected mask no longer lined up
with the unselected pixels. Warp now limits the linked mask to the selection
through transform_cmds::move_gray_selected, as Free Transform and
Perspective Warp already do (#3083); layers that move whole still take their
whole mask along. Adds a regression test driven by edit.transform.warp.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:56 -04:00
David MannandClaude Opus 5.5 e4bfa7907f Video layers: edits keep the timeline work area (#4170) (#4171)
Every video-layer edit ran `work_end = work_end.max(duration)`, so
Show Altered Video, Restore Frame, Interpret Footage and the other frame
edits reset a narrowed work area to the full duration. A new
`grow_duration` helper, used by every video edit and by New Video Layer
from File, grows the timeline and moves the work-area end only when it
reached the old end; a narrowed work area stays where the user put it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:52 -04:00
David MannandClaude Opus 5.5 a256f1c579 Paint Bucket and Gradient: validate opacity like the Magic Eraser (#4167) (#4168)
paint.bucket and paint.gradient read `opacity` as f(p,"opacity",100)/100
with no range check, so 200 became a coverage of 2 and overshot the blend
(#808080 on white painted near-black). Both now use the Magic Eraser's
param reader: the value is clamped to 1..100, and a non-numeric opacity is
BadParams before any edit or history step.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:47 -04:00
David MannandClaude Opus 5.5 f4b73f42ed Automation: reject an invalid document index instead of using the active document (#4160) (#4161)
Root cause: the JSON-lines server's `index_of` read `index` with `as_u64`, so a
string, negative, fractional or boolean index became None, which means "the
active document". `doc.close {"index":"0"}` therefore closed the active
(possibly unsaved) document instead of failing. The MCP server types the param
as Option<usize> and already rejects these values.

Fix: `index_of` returns Result<Option<usize>, AutomationError>. A missing or
null index still means the active document; a non-negative integer is used;
anything else is a bad request ("`index` must be a non-negative integer").
All doc.* call sites propagate the error with `?`.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:40 -04:00
David MannandClaude Opus 5.5 a8a30aed33 Layers panel: show the lock icon for every lock (#4158) (#4159)
Root cause: the layer row's reserved width and indicators, and the Studio
themes' lock button, treated a layer as locked only for transparency,
position or all. A "Lock image pixels" or artboard lock left the row
without its lock icon, and the Studio button drew "unlocked" although
clicking it unlocked the layer (simple_lock_toggle checked all five).

Fix: add Locks::any() in photocraft-doc and use it in all four places,
so every lock shows the icon, as in Photoshop. Tests cover each lock on
the row (reserved width and Indicator::Lock) and the Studio button state.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:34 -04:00
David MannandClaude Opus 5.5 cb3aef5753 Count tool: Marker Size and Label Size fields take effect (#4156) (#4124)
Root cause: the options bar sent markerSize/labelSize as f32 JSON floats
(8.0) and count.setGroup read them with as_u64, which is None for floats,
so the command returned Ok and changed nothing.

Fix: count.setGroup accepts whole numbers sent as floats (UIs, agents and
actions send them), rejects fractions, negatives and non-numbers with
BadParams, and clamps in u64/f64 so huge values no longer wrap through
`as u32`. The options bar now sends integers.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:29 -04:00
David MannandClaude Opus 5.5 991ebe16d5 Liquify, Puppet Warp and Perspective Warp: refuse locked smart objects (#4155) (#4123)
Root cause: run_on_layer in distort_cmds.rs added the smart filter and
returned for a smart object before it called check_locks, so a smart
object with Lock All or Lock Pixels (its own or a parent group's) took
the distortion and a history step.

Fix: check the pixel and full locks (not position) before recording the
smart filter, as regular filters do, and use the filters' error wording.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:24 -04:00
David MannandClaude Opus 5.5 1014c37eaa Find and Replace Text: backward Change/Find moves past the replaced text (#4154) (#4122)
After Replace Text the find cursor collapsed to the end of the replacement.
A backward search takes the last match ending at or before the cursor, so
when the replacement still matched (e.g. "cat" -> "Cat", case-insensitive)
it found the replaced text again and never reached earlier matches.

Collapse the cursor at the start of the replacement when searching backward
(the end when forward), mirroring how forward search skips it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:20 -04:00
David MannandClaude Opus 5.5 426747d7d2 Auto-Select: honour layer mask density when picking layers (#4153) (#4121)
layer.pickAt (Auto-Select, the canvas right-click layer list and the Move
tool's rollover highlight) read the raw layer-mask pixel, while the
compositor uses LayerMask::value, which applies density (1 - d*(1 - v)).
A hide-all mask at density 0 shows the layer in full, yet the click went
through to the layer below.

Read the mask through LayerMask::value, as the compositor does (it also
returns 1 for a disabled mask). Regression test covers the click and the
list form.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:15 -04:00
David MannandClaude Opus 5.5 b0451df33b Compose: the effect cache follows Transparency Shapes Layer and Blend If (#4119)
Root cause: layer_identity, the effect-map cache key, hashed pixels, masks,
fill opacity and effects but not layer.advanced or layer.blend_if. Turning
Transparency Shapes Layer off (which makes the whole layer the effect shape)
or changing a styled group child's Blend If (which changes the composite the
group's shape comes from) left the key and region unchanged, so stale maps
were reused: a stroke kept rimming a hole until the cache was purged.

Fix: hash layer.advanced and layer.blend_if in layer_identity (recursing into
group children). Regression tests compare a warm render after each edit with
a cold render after purge_effect_cache.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:11 -04:00
David MannandClaude Opus 5.5 b87180e14e Swatches: reject colour values beyond f32 range so presets survive a restart (#4150) (#4118)
Root cause: SwatchColor::from_param checked that each value was finite as
an f64, then cast it to f32. A value like 1e39 became inf, and an HSB hue
then wrapped (rem_euclid) to NaN. swatches.add stored Hsb([NaN, ..]), the
prefs file wrote it as null, and on the next launch load_presets_json
failed to parse the whole presets blob, so every user gradient, style,
shape, tool/document preset and swatch fell back to the defaults.

Fix: reject values whose magnitude exceeds f32::MAX with BadParams, for
every colour model.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:07 -04:00
David MannandClaude Opus 5.5 b1b3dff8f1 PSD: a zero-length annotation entry no longer hangs the import (#4149) (#4117)
Root cause: parse_entries sets the cursor to start + len after each Anno
entry. An entry length below 8 (its own u32 plus the type) left the cursor
on the same entry, so a count of 0xFFFFFFFF re-read it about 4.3e9 times,
pushing an entry each time until allocation failed and the process
aborted (not a panic, so the import guard could not catch it).

Fix: reject an entry length below 8, so the block is reported unreadable
and kept verbatim (the #944 behaviour); every entry now advances at least
8 bytes. Regression test covers lengths 0/4/7 for sndA and txtA entries
plus the hostile 16-byte block.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:02 -04:00
0fbba911ba Quick Selection: cut window sizes in parallel, cancel the larger ones once one is kept (#4062)
Releasing a Quick Selection stroke on a 24 MP photo froze the canvas for
0.2-0.5 s: the growing-window loop cut a window, found the selection
touching its side, doubled the margin and started over, and on real photos
every stroke needed 2-4 sizes.

The window sizes are independent, so the next 4 are now cut at once (rayon)
and the first in growing order that stays clear of its sides is kept: the
same result as one after another. Once one is kept, larger windows of the
batch stop through the cancel hook grid_cut_impl / maxflow_with already
have, now also polled before the graph is built, before the max-flow and
between the coarse and fine levels of cut_levels. wasm32 keeps the
sequential loop.

36 strokes on a 6000x4000 photo: masks byte-identical to main, 21.9 s ->
13.9 s (-37%); small 30 px clicks +8 ms (setup of larger windows).

Co-authored-by: Mohammed <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:29:06 -04:00
96f4d68db7 feat(ui): spring-loaded tool letters (#2672) (#4238)
In Photoshop, holding a tool's letter gives a temporary tool: releasing the
key returns to the tool you had. A tool letter still switches tools when
pressed; if the key then stays down for 300 ms or more, or the canvas is
used while it's down, the switch was temporary and the release gives the
previous tool back with its options. A quick tap stays a permanent switch.

- Shift+letter (cycling the group) and Alt+letter never spring; nor do
  letters while typing in a field, editing type or under a dialog.
- The current group's own letter cycles the group, as before.
- A drag in progress when the key comes up finishes with the temporary
  tool; the previous tool returns once the button is up.
- The newest held letter wins; the original tool returns when all are up.
- A tool picked another way while a letter is down (the toolbar, a menu,
  automation) stands.
- Key repeats of tool letters are dropped instead of cycling the group.
- Z is one case: hold Z to zoom, add Alt to zoom out.

The state is UiState::spring, readable over the control channel and never
restored from settings. Tests that pressed tool letters without releasing
them now release them, as a keyboard does. docs/ui-parity.md and
docs/gaps.md mark #2672 done.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:23:56 -04:00
7716d609e3 Affinity: rectangles keep per-corner radii and straight corners (#1606) (#4055)
Unlocked rectangle corners now each use their own radius and type (top left,
top right, bottom right, bottom left) instead of all following the first one,
and straight corners are cut across instead of rounded. Same fix as
VectorCraft's copy of the reader (storytold/vectorcraft#1362).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:20:44 -04:00
003f9b4a18 Affinity import: pie shapes with huge angles no longer hang the reader (#4054)
* Affinity import: pie shapes with huge angles no longer hang the reader

The reader raised the end angle of a pie shape (`ShPi`) by `TAU` in a
loop until the end angle reached the start angle. Both angles come from
the file. The loop ran about `(AngS - AngE) / TAU` times, and it never
ended once adding `TAU` no longer changed the end angle: with
`AngS` = 1e308 and `AngE` = -1e308, reading the document did not return.

The reader now computes the pie's angles in constant time. The start
angle is `AngS` modulo `TAU`. The sweep is `AngE - AngS` capped at one
turn, or its remainder modulo `TAU` when `AngE` is below `AngS`. When
`AngE` is below `AngS` and that difference overflows to negative
infinity, the sweep comes from the remainders of the two angles.

The change and its two tests come from Riccardo Balloch's commit
79c5d673 in storytold/vectorcraft, which fixed the same loop in
VectorCraft's copy of this reader. Here the tests read the document on
a thread of their own and fail when reading takes more than 20 seconds.

Co-authored-by: Riccardo Balloch <[email protected]>

* Contributors: add dotCooCoo (Robert Lee)

---------

Co-authored-by: Riccardo Balloch <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:16:56 -04:00
d3d735c6c7 feat(ui): the shaped brush cursor turns and flips with the view (#4233)
#3467 traces a shaped tip's outline but scales it by the screen radius
alone, so on a rotated or flipped canvas (R, View › Flip Horizontal) the
cursor kept the document's orientation while the stroke did not.

The outline now goes on screen through the view's own axes:
ViewXform::map_vec maps a document vector (pixel aspect, rotation, flip,
zoom), and brush_cursor::tip_axes gives one tip radius along the
document's x and y. A plain view gives the same points as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:16:49 -04:00
9712f5e7d5 fix(selection): reject incomplete magic wand pixel buffers (#4046)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:14:41 -04:00
0761234c2f fix(algo): reject oversized Magic Eraser masks without overflow (#4043)
* fix(algo): reject oversized Magic Eraser masks before multiplying dimensions

* Format the oversized Magic Eraser mask 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-12 20:14:33 -04:00
2338492b12 Type: Space inserts a space while editing type (#4224)
* Type: Space inserts a space while editing type

Space was grabbing the Hand tool during canvas type edit even though
Photoshop keeps Space typing a space. Skip temporary-tool holds when
ui.text_edit is active.

* Credit @Hevarh1 with sync_github_name in About

* Format

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 20:11:42 -04:00
Nigel Ihza Firdausy 799f28167f fix(selection): avoid panics when sampling incomplete region masks
Use checked mask indexing so incomplete or extreme regions return zero coverage instead of panicking or wrapping.

Validated with the full algo suite, independent native and Wasm regressions, and green PR checks.
2026-10-12 18:10:27 -06:00
ef937adf98 Refresh Liquify's zoomed preview when a stroke is undone. (#4135)
* Refresh Liquify's zoomed preview when a stroke is undone.

Above 100% zoom the dialog shows a source-resolution crop. Undo rebuilt the proxy and left that crop untouched, so the stroke stayed on screen until the next zoom.

Refs #4100

* Format

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 20:06:24 -04:00
dbadd21a3e Stylus: a pen held still is not a press-and-hold right click (#4106)
* Stylus: a pen held still is not a press-and-hold right click

On Windows a pen reaches egui as touch events, and egui takes any touch
contact held still past max_click_duration (0.8 s) for a touch screen's
press-and-hold: a secondary click (context menu) that also drops the drag.
Holding the pen still for a second opened a context menu.

PenContacts tells a pen contact from a finger in raw_input_hook (only a
pen hovers, and a pen reports pressure) and turns the press-and-hold
timeout off while one is down, through the frame it lifts in, so a long
still press releases as a tap. Fingers keep press-and-hold.

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

* 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-12 20:06:11 -04:00
xsvm 776b9dddd0 Clone Stamp and History Brush: releasing a stroke commits the live pixels (#4019)
Releasing a Clone Stamp or History Brush stroke rendered the whole
stroke again, although the live stroke had already drawn exactly those
pixels. The canvas now offers the live stroke to its commit as the Brush
does (#2131), through the same LiveCommit: a commit with the same
command, document revision, params and points takes the live surface
and reports fromLive; anything else renders as before.

LiveRetouch renders only the position and pressure of its points, which
is all the commands parse, and the offer is refused when the brush, layer
or clone source changed during the stroke (the revision doesn't cover
them). The Healing Brush, which blends on release, is left out.
2026-10-12 20:05:09 -04:00
13657f35f2 Docs: refresh target-app-parity and file-format-parity against main (#4417) (#4418)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:04:19 -04:00
af9316b97e Docs: refresh gaps.md against main (#4412) (#4413)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:03:29 -04:00
skylartaylor 3702555aa9 Make native RGB color policy explicit and preserve embedded profiles 2026-10-13 02:02:13 +02:00
4a53fad835 feat(codecs): add XPM2 plain-text image import support (#4264)
* feat(codecs): import XPM3 images with transparency (#3692)

* Correct XPM test expectation for green palette name

* feat(codecs): decode XPM2 plain-text image files

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:01:35 -04:00
5981a02e64 feat(codecs): support full X11 rgb.txt color names in XPM (#4263)
* feat(codecs): import XPM3 images with transparency (#3692)

* Correct XPM test expectation for green palette name

* feat(codecs): resolve full X11 rgb.txt color names in XPM

* XPM: fix Format::ALL length after DPX, reject '+' in hex colours, fix non-ASCII byte-string test, 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-12 19:59:36 -04:00
David MannandClaude Opus 5.5 9970cf7214 Brush resize HUD: horizontal is size and vertical is hardness in a rotated view (#4228) (#4229)
The Control+Alt / Alt+right-drag HUD measured the drag in document axes and
only corrected for View > Flip Horizontal, so at 90 degrees a horizontal drag
changed the hardness and at 180 degrees dragging right shrank the brush.
Map the anchor and pointer to screen with the active ViewXform and divide by
its zoom: identical numbers at rotation 0 (flipped or not), screen-relative
when rotated, as in Photoshop. Without a view the old behaviour remains.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:59:11 -04:00
David MannandClaude Opus 5.5 f52549571f Rotate View: the image follows the pointer in a flipped view (#4226) (#4227)
ViewXform::to_screen mirrors x after rotating, so with View > Flip
Horizontal on a larger rotation turns the image counter-clockwise on
screen. The Rotate View drag and the trackpad rotate added their screen
delta unchanged, so the image spun against the pointer. A small helper,
rotate_view::screen_delta, negates the delta in a flipped view and serves
both paths; Shift still snaps the final angle.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:57 -04:00
David MannandClaude Opus 5.5 4566f5f55f Shape tools: the click-to-open-dialog tolerance is the same on HiDPI displays (#4205) (#4207)
finish_gesture passed the device-pixel zoom (current_zoom) to
shape_dialog::is_click, whose 3-point slop is in egui points, so at
pixels_per_point 2 the tolerance halved and a slightly jittery click drew
a tiny shape instead of opening Create Rectangle/Ellipse/... Pass
point_zoom(), as the marquee already does.

Regression test: a 2 pt jitter at ppp 2 and 100 % opens the dialog and
adds no layer.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:43 -04:00
David MannandClaude Opus 5.5 f60202879d Exposure: the slider keeps fractional stops (#4164) (#4165)
The shared adjustment slider rows rounded to 0.01 for ranges of 10 or
less and to whole numbers otherwise, so Exposure (-20..20) snapped to
whole stops: 0.5 became 1. Each row can now carry its own decimals:
Exposure keeps 2 and Offset 4, as in Photoshop, and their fields show
that many places. All other rows (Gamma, Posterize, Threshold,
Brightness/Contrast, Vibrance, Hue/Saturation ranges, Photo Filter
density) keep the old rule.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:30 -04:00
David MannandClaude Opus 5.5 5ce608e5f5 Image Size and Canvas Size: no silent resize of large documents in physical units (#4162) (#4163)
The Width/Height fields capped non-pixel units at 30000. egui's DragValue
clamps the shown value to its range when drawn, and number_edit reports that
as an edit, so a 40000 px document at 72 ppi shown in points opened as
30000 pt and OK resized (Image Size) or cropped (Canvas Size) it with no
edit. The bound is now the engine's 300000 px limit converted into the
current unit, never below 30000.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:16 -04:00
Vikram Bhaskaran bfa63f77b4 Snap 1 px pencil clicks to the pixel the aliased tip paints. (#4140)
A shift-line between two clicks inside the same pixels changed with the sub-pixel position. Pencil and pencil-mode Eraser clicks now use the same grid centre as the dab.

Fixes #4071
2026-10-12 19:58:03 -04:00
Vikram Bhaskaran 4709af046b Draw the Free Transform rotate arrow instead of hiding the pointer. (#4139)
The rotate zone requested CursorIcon::None, which removes the system cursor. Crop already draws a turn arrow for that case. Free Transform now uses the same arrow.

Fixes #4050
2026-10-12 19:57:49 -04:00
Vikram Bhaskaran 9fd20de408 Keep Liquify undo and redo working while a numeric field is focused. (#4134)
Ctrl+Z was ignored whenever a slider's text edit had focus, so a stroke could not be undone from the keyboard. Other Liquify keys still stay out of that field. The bracket keys also step the brush by at least one pixel, so a 2 px brush can reach 1 px.

Fixes #4027
2026-10-12 19:57:34 -04:00
David MannandClaude Opus 5.5 d323d872b2 Layers panel: double-clicking an effect row opens that layer's Layer Style (#4157) (#4131)
Root cause: the effect rows' double-click handler called
layer_style::open, which always reads the active layer. Double-clicking
an effect row of a layer that wasn't active opened (and on OK edited)
the active layer's style instead.

Fix: new layer_style::open_for_layer selects the row's layer with
layer.select first, as the row's right-click menu does, then opens the
dialog. Regression test covers two layers with only the inactive one
carrying an effect.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:56:50 -04:00
David MannandClaude Opus 5.5 b26bab69ee Layer Style: OK keeps an Inner Glow's Center source (#4152) (#4120)
The dialog's values_of() started each Inner Glow from the factory
defaults ("source": "edge") and never loaded the effect's own source,
so a Center-source glow (from a command or a PSD) showed Edge, previewed
as Edge and became Edge on OK, since the engine's overlay applies the
"source" key it receives.

values_of() now loads the source for Inner Glow. Regression test:
inner_glow_center_source_survives_ok.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:56:47 -04:00
SckraTandClaude 1e9f1ae486 perf(engine): read selection coverage per row when mixing edits back (#4424)
Destructive adjustments (pixels::adjusted) and the RGBA filter helper (adjust_cmds
process_surface) each had the same loop mixing the edited pixels back over the original by
selection coverage, looking the selection's tile up for every pixel (sample_channel, a
BTreeMap lookup per pixel). They now share pixels::mix_through_selection, which reads the
coverage one row at a time with sample_row_strided: one tile lookup per tile per row, and a
row-sized buffer.

Results are unchanged: a new test compares the helper with the per-pixel sample_channel mix,
bit for bit, for 8-bit, 8-bit-with-alpha and 32-bit selections over 8- and 16-bit layers,
across tile edges and untouched (default) selection tiles.


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

Co-authored-by: Claude <[email protected]>
2026-10-12 19:55:21 -04:00
SckraTandClaude e683108613 perf(compose): read PHOTOCRAFT_FX_NOCACHE once (#4423)
effect_maps looked the environment variable up (taking the process environment lock) for
every effect-map lookup, i.e. per styled layer per render tile. It is read once, like
PHOTOCRAFT_FX_TRACE in the GPU compositor.


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

Co-authored-by: Claude <[email protected]>
2026-10-12 19:55:13 -04:00
SckraTandClaude 3c469bfd05 perf(engine): move layers without a second document clone (#4422)
move_layers cloned the document on every layer.translate, Align and Distribute only to read
the pre-move state while type and shapes re-render. Session::edit already clones the
document for the edit, and the callers hold the starting state (an Arc), so they pass it in.

The clone copies the layer tree and every surface's tile map (pixel tiles themselves are
shared), so its cost grows with the layer and tile count: a nudge in a document with many
layers or very large layers goes from two such clones per command to one.


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

Co-authored-by: Claude <[email protected]>
2026-10-12 19:55:05 -04:00
SckraTandClaude be43d58f1a perf(compose): stop sweeping the bounds cache on every call once it is large (#4415)
compose::bounds caches per-tile content bounds and drops entries of dead tiles when it holds
more than 65,536 of them. Once more than 65,536 tiles were alive (a large document plus its
history), every call (every render tile, every GPU plan) walked the whole map again and kept
it all. The sweep now runs past a high-water mark: twice the entries that survived the last
sweep, and at least 65,536.

The per-tile scan moves to raster as Tile::content_bounds, used by both
Surface::content_bounds and the cache instead of two copies of the loop; an empty pixel
yields no content instead of a chunks_exact(0) panic.


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

Co-authored-by: Claude <[email protected]>
2026-10-12 19:54:57 -04:00
SckraTandClaude 97448b27f3 perf(raster): moved fills keep sharing one copy-on-write tile (#4414)
Surface::translated copied every target tile of a move that isn't a whole number of tiles,
so a solid fill (one shared tile) became one unique tile per coordinate after its first
nudge: each later move, undo and refresh then rescanned and re-uploaded all of them, and
history kept them all.

A target tile whose window reads only tiles holding one single pixel value (each source
tile is scanned once per call; one tile is kept per value) now shares that tile. Fully
covered targets also skip the default-pixel pre-fill they are about to overwrite.

12 MP RGBA fill nudged by +-(10, 7) twenty times (release, 4-core container): unique tiles
192 -> 76; RGBA16 move p50 55 -> 21 ms; RGBA8 unchanged within noise. Pixels are unchanged
(the test compares every depth with the copied pixels, and checks that a shared but
non-uniform tile is still copied).


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

Co-authored-by: Claude <[email protected]>
2026-10-12 19:54:50 -04:00
Valentin KleinandClaude Opus 5.5 dc8a3f0b35 Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) (#4352)
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)

The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

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

* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)

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

* Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:54:16 -04:00
skylartaylor 4a730912c0 Preserve PDF text and CMYK paths and reject native layout mismatches 2026-10-13 01:53:16 +02:00
skylartaylor 9f7f8b7a7b Check that driver copy fields remain active after validation 2026-10-13 01:51:12 +02:00
skylartaylor 3c4e1536a0 Merge current PhotoCraft and preserve asynchronous native printing 2026-10-13 01:48:54 +02:00
Valentin KleinandClaude Opus 5.5 46740ad4bc Brush: CMYK foreground paints the picked inks into a CMYK document (#4344) (#4347)
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)

The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

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

* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:47:14 -04:00
f48a72cbe3 Codecs: open and save DPX frames (#1049) (#4104)
Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image
element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16
bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit
packing 0 or 1. 10- and 12-bit code values open as U16
round(c * 65535 / (2^b - 1)) and come back exactly when written at their
depth. No colour transform: the transfer characteristic and colorimetric
specification are reported as import notes; extra image elements are
reported; the orientation is applied on import.

Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits
(EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC;
`--dpx-bits` in the CLI), following the document depth by default. Gray
with alpha is written as luma. Creator, project, copyright, creation
time, transfer, colorimetric, frame position, frame rate and time code
travel as DPX:* text metadata and are written back.

Unsupported descriptors, signed or run-length-encoded data and other bit
depths return CodecError::Unsupported; malformed and truncated files
return errors.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:44:04 -04:00
Valentin KleinandClaude Opus 5.5 3ba0e9419c Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) (#4339)
The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:39:55 -04:00
7366a0b429 Italian: native-speaker review of the UI catalog (#4290)
* Italian: native-speaker review of the UI catalog

Fix mistranslations: Trap (Trapping, was overprint), HDR Toning (Mappatura tonale, was chemical toning), Display Text (Mostra testo), Vine Color, Override Action "Save" Commands. Improve phrasing: Bevel & Emboss, East Asian / Middle Eastern Features, linked smart objects, Conditional Mode Change (metodo, as in Image > Mode), bit depth labels. Record the review and new glossary terms in the localization docs. Credit lucAmbr0 in people.toml.

* Tests: allow Italian's compact 8/16 bit labels, which match the source

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 19:38:55 -04:00
Bihacsy LászlóandBrandon Thomas 992d945da7 i18n(hu): informal address in the rows added on merge (#4334)
* i18n(hu): informal address in the rows added on merge

Seven of the 23 rows translated when #3831 was merged addressed the
user with the formal verb forms; the catalog uses the informal second
person throughout. One of them also glued a hyphen to the {key}
placeholder; it now reads "{key}+kattintás: …" like the other hints.

* i18n(hu): translate the Enable animations preference

The Interface › Enable animations preference (#3273) reached main after
#3831; preference_labels_are_translated needs a Hungarian row for it.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:32:00 -04:00
Marco Arnulfo a1fbfd9a6c Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (#4333)
* Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (fixes #4303)

* Filters on a type layer: Liquify, Filter Gallery, Camera Raw, Adaptive Wide Angle, Vanishing Point and Last Filter ask too (fixes #4303)
2026-10-12 19:30:58 -04:00
9f98df582a Stylus: a light Windows pen tap is pressure 0, not a full-pressure click (#4072)
winit's Windows pointer path reports pen pressure 0 as no force, so a tap
lighter than the pen's first pressure level arrived as a force-less touch.
Stylus::update ignored those, saw no pen, and the tap painted at 1 like a
mouse click.

Track touch contacts: a touch move outside a contact (hovering) or any
reported force marks a pen, and a pen contact with no force yet is pressure
0. A finger on a screen without force sensing still paints at 1. A contact
that starts in the frame another lifted now keeps its force too.

Also credit @Furash in contributors/people.toml.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:27:14 -04:00
David MannandClaude Opus 5.5 6c0dcf428b Color Table: keep the transparent index inside the palette (#4186) (#4187)
Replacing the colours could shrink the table while the transparent index
still pointed past its end, so the palette PNG export refused the table.
A stale index is now dropped (pixels end up as with "transparent": null),
and an explicit invalid index (out of range, negative, non-integer) is a
BadParams error before any edit instead of silently keeping the old one.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:24:17 -04:00
61c52c08db Brush Settings: toggle switch rows without losing smoothing settings (#4463)
Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from
anywhere on their row while keeping the selected controls open. Keep
lock clicks independent and expose each switch as one accessible checkbox.

Give Smoothing a default-on enable flag so disabling it preserves the
amount and modes. Old brush JSON stays compatible; toolbar amount edits
and explicit stroke overrides enable it, and ABR imports retain disabled
smoothing values.

Cover row, checkbox, lock and keyboard gestures, old section selections,
command replay, presets, legacy JSON and 8/16/32-bit rendering.

Fixes #4283

Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:23:17 -04:00
d303bf9b83 Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip (#4105)
* Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip

Brush tips wider than 6 screen pixels always drew the centre crosshair, so
Preferences > Cursors > Show Crosshair in Brush Tip could add it but never
remove it. Photoshop shows it only when that preference is on. The
Background Eraser keeps its sampling hotspot, and Clone Stamp / Healing
Brush still show it while Option is held.

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

* rustfmt canvas.rs

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 19:22:05 -04:00
ae72084a9c Resolve every pattern name before a batch delete removes any of them. (#4132)
* Resolve every pattern name before a batch delete removes any of them.

A missing name used to delete the earlier presets and then return an error. The pattern library is not on the document undo stack, so that partial delete could not be undone.

Fixes #4103

* Pattern batch delete: reject non-string entries and resolve against the library only

A document-only pattern resolved through pattern_cmds::resolve, so the batch
deleted earlier entries before pattern.delete refused it. Non-string array
members were silently dropped (ported from #4127).

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 19:19:23 -04:00
AlonandClaude Opus 5.5 9d37e1518b fix(move): no trail when dragging a shape past the canvas edge (#3946) (#4069)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:17:36 -04:00
Nigel Ihza Firdausy 5c4ad593de fix(ui): reject rotation angles outside f32 range (#4044) 2026-10-12 19:17:31 -04:00
Nigel Ihza Firdausy fd4ba52f79 fix(ui): cancel unfinished polygon on lasso tool switch (#4021) 2026-10-12 19:17:25 -04:00
67c90e307f Quantize the colour field when Only Web Colors is on. (#4138)
* Quantize the colour field when Only Web Colors is on.

The picked colour already snapped to the web-safe ramp, but the field and slider stayed continuous, so the restriction was invisible.

Fixes #4107

* Only Web Colors: draw the field and slider in flat web-safe bands

The coarse interpolated mesh blended between snapped corners, so it showed a
smooth low-resolution gradient rather than bands. Draw flat cells sampled at
their centres instead, and test that every cell is a single web-safe colour.

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 19:12:59 -04:00
Fabio Lissi 90c42f5555 Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470)
- image.imageSize takes percent (both sides; an explicit side wins).
- actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions
  on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the
  active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller.
- .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs,
  imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108.
- Panel labels conditional steps as 'If cond → then / else'.
2026-10-12 19:12:35 -04:00
3f0a633056 Make channel filter previews match the committed edit (#3979)
* Fix selected-channel filter previews and reduced Quick Mask proxies

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

* Keep the new dialog preview aligned with the selected channel

* Bound full-resolution dialog preview textures to the device limit

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:05:49 -04:00
e2a35b04fd Windows: keep full screen and maximized apart; never restore full screen (#4396)
* Windows: keep full screen and maximized apart; never restore full screen

winit on Windows keeps the maximized style on a window that goes full
screen from maximized. The shell then draws the taskbar black instead of
hiding it, and on the way back the window stays maximized at the
monitor's full size, over the taskbar (#3550, #4018, #4230). Un-maximize
before entering full screen and maximize again after leaving it.

eframe also restores full screen from ui.ron, as full screen and
maximized at once; drop that flag in the window builder hook, so
PhotoCraft starts in Standard Screen Mode as its own state says.

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

* contributors: add aweussom

* view_cmds: mark the winit#4756 workaround and how to retire it

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

* rustfmt view_cmds.rs

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 19:05:37 -04:00
Fabio Lissi 13f5c104f6 Map the remaining frequent Photoshop action events (#219) (#4465)
- edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame
  centre), as a Photoshop action records a Free Transform; image.applyImage takes a source
  layer by name.
- .atn events: transform (layer → translate or relative transform; selection →
  transformSelection), applyImageEvent, export (Save for Web, with the txtu path record
  decoded and written back; kept verbatim without a destination), newPlacedLayer /
  placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close,
  delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections →
  select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events.
- Export tries every event listing a command id (Quick Mask on → set, off → clearEvent).
- Docs: event counts in the parity docs.
2026-10-12 19:02:15 -04:00
FredApps 7015f2916d View: Esc leaves full screen modes (part of #4116) (#4145)
Esc returned to Standard only from Full Screen Mode, and was consumed in
the UI pass before tools, dialogs and popups could see it. Handle it in
the shortcut chain instead, after everything that owns Esc, for both
full screen modes, via view.screenMode.standard.
2026-10-12 19:01:24 -04:00
bbfaefd5f0 File Handling: wire Maximize PSD and PSB File Compatibility (#204) (#3955)
The preference was stored but read nowhere, so Edit › Preferences hid it.
Never now writes Photoshop's white placeholder instead of the merged image
and records has_real_merged_data = false in resource 1057, without
rendering the composite: smaller files and faster saves. Always and Ask
When Saving keep the real merged image, as Image Previews already does
with Ask. A file without layer records always keeps its real merged image,
and layered TIFF still renders the composite it is made of.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:00:03 -04:00
4ff2dc33ca Fix multi-layer Delete and Move drags on a locked Background (#4109)
* Fix multi-layer Delete and Move drags on a locked Background.

Delete was removing only the active layer's targeted mask when several layers were selected. A Move drag that starts on the locked Background now draws the layer marquee instead of showing the lock dialog.

* Drop the multi-layer Delete hunk: #4078 fixes #4025

This PR keeps only the Move-on-locked-Background marquee fix (#4058).

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

* Move: a click on a locked layer still selects it before the marquee

Keeps hidden_layer_tests::auto_select_skips_hidden_layers green: Auto-Select
picks the visible Background under the pointer, and a drag's marquee replaces
that selection on release.

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 18:56:58 -04:00
Marco Arnulfo cd36d1ea0d Delete with several layers selected deletes them, not the active layer's targeted mask (#4025) (#4078) 2026-10-12 18:52:43 -04:00
Fabio Lissi 9862dfbb09 Actions panel: set folders, dialog toggle, Insert Stop (#219) (#4456)
* Actions panel: set folders, dialog toggle column, Insert Stop (#219)

- Engine actions_edit_cmds: actions.insert (any command at a position; Insert Stop… uses it
  with actions.stopMessage), actions.dialog (the per-step dialog flag), actions.assignSet.
  Flag sets follow inserted steps; nothing edits during playback.
- Panel: loose actions first, then each set as a collapsible folder row with its count
  (ui.actions.collapsedSets); dropping an action among another set's rows moves it there;
  Save Set… on a folder's context menu; a dialog toggle column next to the check marks;
  Insert Stop… in the panel menu with a small message/Allow Continue dialog.
- Shell playback pauses on a dialog-toggled filter or adjustment step: the command's dialog
  opens seeded with the recorded values; OK runs the dialog's values and resumes from the next
  step, Cancel stops. Agents play headlessly. The play result reports paused: {step, id, dialog}.
- Docs: control protocol, scorecard AUTO-219-2 done, gaps G11.

* Translate the Actions panel's set, dialog-toggle and Insert Stop strings

* Resume a paused action only after the dialog's background job lands

A filter confirmed from a dialog runs as a background job in the desktop app; the resumed
steps met a locked document and failed. Wait for the job first (test covers background jobs).
2026-10-12 18:51:53 -04:00
Marco Arnulfo 40427f25dc Properties: a new adjustment or fill layer brings Properties to the front (#4028) (#4079) 2026-10-12 18:47:59 -04:00
zenjabbaandzenjabba 95dbb01ad9 fix(gpu): preflight compositor uniform buffer limits (#3931)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 18:44:09 -04:00
mroxsoandhighperfocused 23abc4302b fix(ui): default the Move tool Auto-Select target to Group (#4056) (#4093)
Photoshop ships with Auto-Select on and its target set to Group;
PhotoCraft defaulted to Layer. ToolOptions::default and the serde
default now return "group"; saved preferences keep their stored value.

Co-authored-by: highperfocused <[email protected]>
2026-10-12 18:43:21 -04:00
049a92c288 feat(engine): recolor vector shape layer fill with Alt+Backspace and Cmd+Backspace (#3925)
* feat(shape): recolor vector shape fill via Alt/Cmd+Backspace fill keys

Photoshop fill keys (Alt+Backspace for foreground and Cmd+Backspace for background, as well as their Shift variants) now recolor the active vector shape layer's solid fill when targeting a shape layer, matching Photoshop 1:1 instead of erroring out with 'active layer is a Shape layer, not a pixel layer'. Channel and mask targets continue to be routed to edit.fill. Includes undo support, locked layer checks, and regression tests.

* Fill keys: keep Fill from History's own enablement after merging main; rustfmt

edit.fillHistory joined the shared fill-key IDS on main and shares their
enabled check, which now also accepts shape layers. Give Fill from History
edit.fill's check so it isn't enabled where it can only fail, and limit the
locked-shape test to the four colour keys.

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 18:40:05 -04:00
Fabio Lissi 3204231d7d Load and save Photoshop Actions (.atn) (#4356, #219) (#4434)
* Load and save Photoshop Actions (.atn) (#219)

- photocraft-psd: `atn` parser and writer for version-16 action sets, exact round trip,
  truncation/garbage safe, fuzz target and `atn_dump` example. Descriptor fix: `indx` and
  `Idnt` reference items carry a class before the number (real action files failed to parse).
- photocraft-engine: `atn_cmds` maps 92 Photoshop events to commands and back (filters,
  adjustments per channel, image/canvas/crop/trim/rotate/flip/mode, make/set/select/delete/
  hide/show/move/duplicate, fill/stroke, selection, clipboard, purge, profiles, play, stop).
  Other steps load as `actions.photoshopStep` (descriptor kept, written back by save, fails at
  playback by name); tool selections load switched off. Commands `actions.load`,
  `actions.save`, `actions.stopMessage`, `actions.photoshopStep`. `Action` gains `dialogs`
  and `set`. `layer` params accept a layer name, so loaded steps replay in any document.
- UI: Actions panel menu › Load Actions… / Save Actions…; Photoshop steps labelled.
- CLI: `batch --actions set.atn`. Automation policy covers the new path params.
- Docs: file-format parity (.atn R/W), gaps G11, target-app parity, scorecard AUTO-219-2
  partial, development guide.

* Translate the Actions panel's Load/Save strings in every catalog

The eight new panel strings (Load/Save Actions…, the loaded/saved counts, the kept-step label)
were missing from all 19 catalogs; the languages that were complete are back at 100%.

* Show the Actions entries in the default dock's panel menu

The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so
Load Actions… and Save Actions… were missing there.
2026-10-12 18:39:57 -04:00
zenjabbaandzenjabba dd992c7e57 fix(color): validate RGB working profiles atomically (#3924)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 18:34:59 -04:00
7f8b5816ca Use whole group bounds for transforms with active selections (#3970)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 18:30:20 -04:00
xsvm 63fd5ec7b6 Selection: lasso polygons scan only the edges that cross each row (#3959)
* Selection: lasso polygons scan only the edges that cross each row

The polygon fill behind Lasso, Polygonal Lasso and Magnetic Lasso tested
every edge on every sub-row of the whole document, O(height x 4 x points),
on one thread. It now keeps an active edge list over the edges sorted by
their top and fills bands of 64 rows in parallel. Crossings use the same
formula and spans accumulate in the same sorted order, so the mask is bit
identical; a test compares it with the previous fill.

* Selection: the lasso fill bands go through par_rows; edges are a named struct
2026-10-12 18:26:39 -04:00
Gert Pellin - GPE d32d3f41bb Save As: a saved .pcraft manifest reports the file it was saved under (#3312) (#3571)
photocraft_io::named_for_file(doc, name) serialises the document under the
file it is being written to, so a .pcraft manifest carries the identity the
save is about to adopt rather than the previous one — the same rule every
other format follows on import, where document.name comes from the file it
was read from. A bare extension names no file and changes nothing.

Applied once, in the .pcraft branch of export_stages, which export and
export_with both funnel through: the desktop Save (services.rs:242),
Export (services.rs:384), the web build (web.rs:355) and the engine file
command (file_cmds.rs:295) are covered by one site, and the previews
rendered before the bytes describe the same document as the manifest.

Scope per review: the automation funnels (files::save / save_bytes) already
landed in #3319 with their own named_for_output, so this carries no
crates/automation change; its scenarios are covered by
crates/automation/tests/native_save_name.rs on main.
2026-10-12 18:19:22 -04:00
50b97605fb Convert grayscale profiles before untagged exports (#3978)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 18:13:21 -04:00
ddf6696dce Pencil: no 8-bit dither on aliased strokes, one flat colour at partial opacity (#3990)
The 8-bit stroke dither (render.rs `dither8`) ran on every stroke, including
aliased ones. An aliased stroke paints each pixel by the same amount, so it
has no gradient to band; dithering only scattered it over neighbouring
levels. A 1 px Pencil line of 219/41/61 at 30% opacity over white came out
as four colours (244/191/197, 245/191/197, 244/190/196, ...), and a
foreground between 8-bit levels did the same at 100%. Photoshop's Pencil
paints one value, and pixel art relies on it: a tolerance-0 Magic Wand or
Paint Bucket and Indexed Color palettes trip on the grain.

Composite now skips the dither when the brush is aliased (Pencil, Eraser
Pencil and Block modes). Soft brushes keep it.

Test: an_aliased_stroke_at_partial_opacity_is_one_flat_colour (fails
before the fix at pixel 5). photocraft-paint 96/96, photocraft-engine
1529/1529, ui-egui pencil 13/13, clippy -D warnings and fmt clean.

Co-authored-by: cloudalister <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:10:29 -04:00
buivanvu94 e753a71851 feat(automation): allow Crop and Straighten Photos through automation (#1052) (#3917) 2026-10-12 18:03:32 -04:00
5dac5c6d61 Tests: allow-list language-invariant bit-depth labels, add missing tr/hu 'Enable animations' (#4359) (#4362)
* Tests: language-invariant bit-depth labels are allow-listed; add missing tr/hu 'Enable animations' (#4359)

Turkish writes '8 bit'/'16 bit' exactly like the English source, so the
translated-differs assertion needs an explicit allow-list (the entry must
still exist). hu and tr had no 'Enable animations' preference label.

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

* Merge main; drop the Enable animations rows main already has

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 17:33:30 -04:00
skylar taylor-barrick b3222ee8e7 Merge pull request #3872 from skylartaylor/codex/photocraft-craft-print
Use craft-print for system printing
2026-10-12 15:00:09 -06:00
skylar taylor-barrick 91b08488c8 Validate paired purge availability in MCP resource tests 2026-10-12 20:55:44 +02:00
skylar taylor-barrick 7b0317ff41 Merge PhotoCraft 0.8.2 and stabilize background-job tests 2026-10-12 20:21:10 +02:00
skylar taylor-barrick a301e31864 Merge current main fixes and validate craft-print rollout 2026-10-12 19:39:05 +02:00
Claude d43db3e153 Release 0.8.2: web build back under the wasm size gate
0.8.1's Web job failed: photocraft-web's wasm was 25,587,059 bytes, over
packaging/web/package.sh's 24 MiB gate. #4253 merged with #4147 brings
it to 24,161,781.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 12:57:00 -04:00
Claude 7f9c4394c1 Merge #4253: wasm back under the 24 MiB gate
Combined with #4147's opt-level "z" list. Conflict in Cargo.toml's
wasm-release profile: kept #4147's photocraft-psd = "z" and dropped
#4253's "s" entry for it (one table per package). Measured with trunk
--release: main 184c226a is 25,235,857 bytes (over the 25,165,824 gate);
with this merge, 24,161,781.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 12:56:54 -04:00
Brandon ThomasandClaude Opus 5.5 184c226a76 Fix main: wasm size cap, Windows Export As test, filter preview window (scope and a crash), CLI duplicate font warnings (#4147)
* test(ui-egui): Export As artboards test accepts the Windows path separator

The test added in #3909 compared the second file's path with a literal
'/', but the exporter joins the sibling name with the platform separator,
so Windows CI saw '/out\boards-2-Capsule.jpg' and failed.

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

* fix(ui): only Filter menu dialogs get the in-dialog preview window

#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.

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

* build(web): opt-level z for large cold crates in the wasm release profile

The browser wasm sat at the 24 MiB gate. The document model, file
parsers (psd, format, affinity), serde_json, usvg and naga are off the
per-frame and per-pixel paths; opt-level "z" for them takes the
release wasm from 25,186,233 to 24,829,840 bytes (-356 KB) with no
behaviour change.

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

* fix(cli): run prints each missing-font warning once

Since #3630 the headless open and save also report missing fonts, so
`run <file> --out` and `run --cmd ... --out` printed the same warning
two or three times and missing_typeface_is_reported_by_run_info_and_convert
failed on main. Warnings from the open and the save now go through the
same already-reported set as the per-command ones.

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

* fix(ui): the filter preview window never uploads a texture past the GPU limit

With low-resolution previews off, the preview window from #3877 uploaded
the whole document as one texture. egui panics on a texture bigger than
max_texture_side, so opening a filter dialog on a large image crashed
(full_res_preview::filter_dialog_previews_at_full_resolution_when_low_res_is_off).
The texture is now reduced to fit; the view maps through UV fractions,
so it still lines up.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:48:28 -04:00
Brandon ThomasandClaude Opus 5.5 2db6b7d8f9 Release 0.8.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:11:02 -04:00
Brandon ThomasandClaude Opus 5.5 3d6050b931 Release workflow: pass the Azure signing secrets so Windows builds are signed (#3794)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:08:34 -04:00
9e623e7a45 i18n: add Hungarian (hu) UI translation (#3831)
* i18n(cs): drop duplicate catalog entries

The Czech catalog listed Tool, Clone Stamp, History Brush, Dodge, Burn,
Sponge and Stroke Path… twice, which fails `cargo xtask i18n-coverage`
and the bundled_catalogs_are_consistent test. Keep one row each; for
Stroke Path… keep the later row, the one the loader already used.

* test(crop): pin the UI language to English in crop_default_mode

The test queries widgets by their English labels ("Ratio", "Swaps height
and width") but its harness followed the system locale, so it failed on a
machine whose locale has a registered catalog. Set interface.language to
"en" in the harness, as paint_bucket_all_layers and export_as_scaling do.

* i18n: add Hungarian (hu) UI translation

A complete Hungarian catalog (hu.tsv, 3908 entries: 3829 plain, 50
contextual, 21 plural, 8 command-id overrides) registered with the
complete-menu languages; hu-HU locales resolve to it.

Plural rule: plural_none. A Hungarian count takes the singular noun
("3 réteg"), so every @plural entry has one form.

Clean room: written from the plain meaning of the English labels with
ordinary Hungarian image-editing vocabulary; no proprietary localisation
resource, string table or product UI was used.

Also listed in the coverage test, LICENSE-translations.txt,
ATTRIBUTION.md and docs/localization.md, with tag, system-locale and
plural tests.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:07:17 -04:00
b3309e27d7 i18n(tr): add Turkish UI translation catalog (#3809)
Add Turkish (tr) translation catalog covering 100% of measured UI strings (1,727/1,727 keys, 3,731 catalog entries).

- Register Turkish language in crates/ui-egui/src/i18n/mod.rs with complete_menus: true and plural_none
- Add tr.tsv translation catalog
- Add negotiation and coverage tests in system.rs, mod.rs and i18n_coverage.rs
- Add MIT OR Apache-2.0 notice in LICENSE-translations.txt
- Update docs/localization-parity.md

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:01:46 -04:00
+3 67e4b8a499 feat: Alt+Cancel → Reset for opt-in command dialogs (#3871)
* Update .gitignore and add UI Dialogs documentation

- Added .specstory, .private, and .vscode to .gitignore to exclude additional files from version control.
- Introduced a new documentation file, ui-dialogs.md, detailing the architecture, rules, and usage of modal and modeless dialogs in the egui shell, mirroring Photoshop's functionality.

* Enhance UI Dialogs documentation in ui-dialogs.md

- Expanded the section on command routing and dialog types, clarifying the roles of various dialog kinds and their associated modules.
- Improved descriptions of routing markers and private keys, ensuring better understanding of the dialog architecture and usage.
- Updated the menu command entry details to reflect changes in module organization and functionality.

* Implement Alt+Cancel (Reset) functionality for various UI dialogs

- Introduced a new module `dialog_alt_reset` to handle the Alt+Cancel behavior, allowing users to reset dialog fields to their original values while keeping the dialog open.
- Updated multiple dialog modules to support this feature, including adjustments for color pickers, sizing, and guide layouts.
- Added tests to ensure that the reset functionality correctly restores original values and maintains dialog state.
- Enhanced documentation to reflect the new Alt+Reset behavior and its implementation details across different dialog types.

* Update manual UI testing command to include build timing summary

- Modified the `manual-ui-testing` command in `.cargo/config.toml` to include the `--timings` flag, enabling the generation of a build timing summary after compilation and linking.
- Refactored the `reset_fields` function in `filter_dialog.rs` for improved readability by consolidating the retrieval of the `spec` variable into a single line, enhancing code clarity.

* 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]>

* 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]>

* Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267)

* 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]>

* 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]>

* 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]>

* 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.

* 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]>

* 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]>

* 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]>

* fix(format): save the target file name in native bundle manifests (#3312) (#3319)

* 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]>

* 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]>

* 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]>

* 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]>

* 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.

* 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]>

---------

Co-authored-by: Valentin Klein <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Gert Pellin - GPE <[email protected]>
Co-authored-by: switch87 <[email protected]>
Co-authored-by: RaccoonPie-314 <[email protected]>
Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Sir Starch <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
Co-authored-by: myangaa1 <[email protected]>
Co-authored-by: KnottyDyes <[email protected]>
2026-10-12 10:52:19 -04:00
zenjabbaandzenjabba 9163afbdcf Verify PngSuite archives and cached corpus files (#3973)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 10:45:05 -04:00
f4fa53140c ops: count selection masks in history pixel budget (#3614)
* ops: count selection masks in history pixel budget

* test: expect unclipped HDR levels in float documents

* web: optimize engine code for the wasm size budget

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:43:07 -04:00
Korg1ch dc35ce7630 fix(linux): avoid duplicate AppImage desktop launcher (#3623) (#3641) 2026-10-12 10:35:47 -04:00
Valentin KleinandClaude Opus 5.5 a8b52f1dbf Codecs: enable JPEG and CCITT fax compression for TIFF (#3712) (#3727)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:34:45 -04:00
Valentin KleinandClaude Opus 5.5 019a391f40 Codecs: decode only the requested EXR part; Cryptomatte uses the cumulative cap (#3721) (#3722)
read_layer decoded every part and picked one; use a first-valid-layer reader
that declines all other parts. Cryptomatte now goes through read_meta and
read_layer. Enable exr/rayon on native targets.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:33:21 -04:00
skylar taylor-barrick 27fecdab44 Translate print queue guidance in new Vietnamese catalog 2026-10-12 16:22:43 +02:00
null 00000111 0b935da882 HEIF: move the heic-rs pin from 0.1.1 to 0.1.4 (#3637)
heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing:
four in release builds (a uuid box too small for its extended type, a
block starting right of the picture, a conformance window larger than the
picture, and a reference row below the picture) and two with overflow
checks on (a PCM bit depth above the sample bit depth, a slice QP out of
range). The API, MSRV and output are unchanged.

Both pins move together: crates/heif and the codecs fuzz target, which
calls heic-rs directly. guarded() stays as the backstop it was meant to be.
2026-10-12 10:22:11 -04:00
null 00000111 423421a879 Fuzz: ignore the same build leftovers in every fuzz crate (#3585)
crates/raw/fuzz had no .gitignore, crates/psd/fuzz didn't ignore
Cargo.lock and crates/format/fuzz didn't ignore coverage/, so running
those fuzz targets left untracked files in the checkout. All five fuzz
crates now ignore target/, corpus/, artifacts/, coverage/ and Cargo.lock,
as codecs and affinity already did.
2026-10-12 10:19:06 -04:00
skylar taylor-barrick 5fe4d09478 Merge current main while preserving craft-print transport contracts 2026-10-12 16:17:51 +02:00
73a56d7603 Format: overflow-safe blob decode and bounds-checked ZIP reads (#3486) (#3501)
- store: `max_blob_bytes as u64 + 1` overflowed for usize::MAX (debug
  panic; release take(0), so every blob failed). The blob read and the
  tile decode now share decompress_bounded(), which saturates.
- zip: ZipReader::read sliced `data_offset..data_offset + compressed`
  unchecked although Entry is pub + Clone; a foreign entry is now a
  Corrupt error.
- store: Collect::tile reuses tile_le(); Object::content() gives the
  stored bytes once (existing_object_is_valid no longer builds them twice).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 10:08:20 -04:00
fd21e73411 Format: check pattern sizes on load (#3478) (#3484)
* Format: check pattern sizes on load (#3478)

A .pcraft pattern's width/height came straight from the manifest, so a
1 KB file could declare a 65535x65535 pattern without tiles; painting it
allocated width*height*16 bytes (abort) and the engine's u32 size math
overflowed.

- format: reject patterns 0 px on a side (Corrupt) or above the PSD
  reader's 30 000 px edge (LimitExceeded).
- doc: Pattern::pixel_count / is_paintable with MAX_PAINT_PIXELS (2^26,
  a 1 GiB f32 RGBA working copy).
- compose/gpu: patterns too large to paint paint nothing instead of
  allocating (Tile::new_in, pattern_rgba, PreparedPatterns, GPU plan).
- engine: brush.texturePattern refuses them; average_color uses u64 math
  and a bounded corner.

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

* gpu: skip unpaintable patterns in the Bevel texture too

The Bevel & Emboss texture element uploaded the pattern through
pattern_rgba, which now returns no pixels for a pattern too large to
paint; filter it like the other GPU pattern lookups.

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 10:06:16 -04:00
JGFandClaude Opus 5.5 5a0e2e2658 Web: wasm back under the 24 MiB gate with ~1 MiB headroom (25.38 -> 24.17 MB)
The "web build and wasm size gate" job failed on main and every PR: the
release `_bg.wasm` measured 25,379,677 bytes at 8d49ecf5, over the
25,165,824-byte limit in packaging/web/package.sh.

- egui's default fonts (Ubuntu Light, Hack, Noto Emoji, emoji-icon-font)
  were embedded uncompressed (1.4 MB) by egui's `default_fonts` feature.
  The app now builds egui without it; ui-egui's build.rs deflates the
  same files from the `epaint_default_fonts` crate (a build-dependency,
  so no font files are committed) and `egui_fonts.rs` rebuilds egui's
  exact default stack from them on first use. Test builds keep the
  feature as the oracle: the rebuilt definitions equal egui's byte for
  byte.
- Translation catalogs: every catalog repeated the same English sources.
  build.rs now stores the `context<TAB>source` keys once and each catalog
  as references plus translations, losslessly (`text()` still returns
  each .tsv byte for byte; malformed lines are kept as they are).
- wasm-release profile: opt-level "z" for PhotoCraft's own crates.
  Dependencies stay at "s" (at "z" the wasm grew 0.4 MB, measured), the
  file-decoding crates (codecs, psd, raw, heif, vector) stay at "s" and
  the pixel crates at 3, so decoding and filters keep their speed.

Measured with `trunk build --release` (trunk 0.21.14, wasm-opt v123 -Oz):
  upstream main 8d49ecf5      25,379,677 bytes
  this change                 24,167,186 bytes (-1,212,491)
  gate                        25,165,824 bytes (998,638 headroom)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:59:25 -04:00
HEOJUNFOandClaude Opus 5.5 bb17dd555c Color: convert input-only embedded profiles to the working space on open (#3876) (#3900)
An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class
profile with only AToB tables. Preserve kept it as the document's space, so
every composite into the document failed with "RGB profile without
matrix/TRC or BToA tags". Such a profile can't be a destination, so open
converts from it to the working space whatever the policy (Off still
discards it) and the report says inputOnly.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:57:11 -04:00
a58b6f3d9e Mix filtered selection edges with premultiplied alpha (#3895)
* Mix filtered selection edges with premultiplied alpha

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

* Update stylize kernel comparison for alpha-aware selection mixing

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

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:52:59 -04:00
8072636e0f Count video footage in history memory budgets (#3894)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:46:59 -04:00
FredApps 094c4f30e4 CMS tests: validate profile-specific CMYK black endpoints (#3890)
Retain the relative paper-white requirement and round-trip checks while validating finite, bounded endpoints and darker full-K ink.

Add an encoded and reparsed AToB1 regression with an independently calculated 100/87/74/61/48 lightness ramp, plus invalid endpoint cases.

Signed-off-by: FredApps <[email protected]>
2026-10-12 09:43:34 -04:00
7958dc63fb Layers: Merge Group for a selected group (#3832) (#3888)
* Layers: Merge Group for a selected group (#3832)

With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the
Layers panel's right-click menu offers Merge Group instead of Merge Down, as in
Photoshop. The new layer.mergeGroup command flattens the group's visible contents
into one pixel layer in its place, named after the group, keeping its opacity, blend
mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran
Merge Down and merged the whole group into the layer below it.

* i18n: Vietnamese row for Merge Group

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 09:42:58 -04:00
4010630c98 Preserve surviving channel targets and visibility on deletion (#3887)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:32:37 -04:00
76fa06dbd5 Merged and stamped layers keep their content bounds in CMYK and Lab (#3886)
Merging or stamping converts the straight-RGBA composite into the
document's colour model. Transparent black converts to K = 100% in CMYK
and neutral a/b in Lab, so those pixels no longer equal the surface's
default pixel, prune() keeps them, and the layer's content bounds span
everything that was rendered: the whole canvas for Stamp Visible, Stamp
Down and Copy Merged, and for Merge Layers, Merge Visible and Merge Down
whenever an input may draw anywhere (a fill layer, even a masked one).

Fully transparent pixels are now written as the default pixel in both
conversion paths: photocraft_compose::flatten_to_surface (stamps, Copy
Merged) and engine pixels::composite_layers (merges). Only formats with
alpha are affected; over a Background everything stays opaque.

Tests: engine merge_transparent_bounds (Merge Layers, Merge Visible over
a transparent document, with a masked fill layer and onto a Background,
Stamp Visible, Stamp Down, undo) in RGB/Gray at 8/16/32 bit and CMYK/Lab
at 8/16 bit, and a compose unit test for flatten_to_surface. The masked
fill layer test fails on main (merged layer = full canvas in CMYK).

Co-authored-by: dgliwa <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 09:28:37 -04:00
73ed08ddbe PSD type: read styles from the run's sheets and place text at its Txt2 frame (#3795, #1317) (#3819)
* PSD type: resolve paragraph and character styles from the run's own sheets (#3795)

apply_engine_data filled a run's missing attributes only from the Normal
sheets (TheNormalParagraphSheet / TheNormalStyleSheet). A run that takes
its alignment or size from a named sheet (by Parent index or Name), or
from its paragraph's DefaultStyleSheet, was read with Normal's values, so
a centred paragraph style came back Left and starting an edit re-laid
the text half a line to the side.

Resolve run -> named sheet -> paragraph's default character style ->
Normal, ignoring missing or out-of-range references. On export, runs
whose sheet reference is rewritten also get the attributes they
inherited, so they keep their look whatever they now point to.

* PSD type: place text at its Txt2 frame and keep box lines whose descent overhangs (#1317)

Recent Photoshop keeps part of a type layer's position in its Txt2 frame: a
frame-to-text-space matrix and, for point text, the anchor, with EngineData's
PointBase left at the origin and BoxBounds in the frame's space. Import ignored
them, so box text landed far off the canvas once an edit started (the text
'disappeared') and point text moved up a line and off its alignment anchor.
Apply the frame when its origin lands in the TySh bounds (and not already
without it, so a TySh PhotoCraft rewrote is not moved twice).

Photoshop also keeps a box line whose baseline is inside the box while its
descenders hang below it; layout dropped such lines (and some single-line boxes
rendered nothing).

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:28:31 -04:00
FinnNkandClaude Opus 5.5 8d49ecf50d feat: Photoshop's tool keys (arrows turn the brush, Shift-click new type, Space+F canvas colour) (#3725)
* feat: Left and Right arrows turn the brush tip

With a painting tool, Photoshop's Left and Right arrow keys turn the brush
tip 1 degree (15 with Shift); Right turns it clockwise. tools.rotateBrush
turns the session brush's angle by "degrees", wrapped into -180..180, and
move_mods::arrow_keys gives it the plain and Shift arrows only for the
painting tools, when no Move-tool, transform or selection nudge applies
and no type is being edited. Alt-arrows still kern type; Ctrl-arrows still
nudge the layer.

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

* feat(ui): Shift-click with the Type tool starts new text over existing type

In Photoshop a click on existing type with the Type tool edits it, and a
Shift-click there starts a new type layer instead, so text can be placed
over other text. pointer_down now leaves a Shift-click unconsumed when no
type is being edited, so it starts new text; inside the text being edited,
Shift-click still extends the selection.

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

* feat(ui): Space+F cycles the canvas colour

Photoshop's Space+F steps the pasteboard (canvas) colour through Default,
Black, Dark Gray, Medium Gray, Light Gray and Custom, and Shift+Space+F
steps back, round and round. While the Hand key is held, F does this
instead of Cycle Screen Mode; the choice is saved like picking it in the
pasteboard menu (interface.canvasColor). docs/ui-parity.md lists the new
tool keys.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:17:43 -04:00
aa4d4859b4 fix(io): preserve indexed BMP color tables (#3802) (#3869)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:13:38 -04:00
3f72158f0a fix(psd): write kept 8B64 blocks as 8BIM in a PSD (#3743)
A PSD re-saved from a PSB kept the source's global FMsk and cinf blocks with
their 8B64 signature, and Photoshop 27.11 refuses such a file as "not
compatible with this version". 8B64 exists only in PSB, so a kept block is
written as 8BIM when the output is a PSD; a PSB keeps 8B64. Measured by
bisecting the 3 refused corpus re-exports in Photoshop: each opened once the
8B64 blocks were gone.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 09:05:22 -04:00
81f9745033 fix(smart-object): keep pixels when rasterizing smart object layer (#3873)
Resolve an issue where rasterizing a smart object layer resulted in an empty raster surface if the cache was not yet populated. Now refreshes the smart layer snapshot if needed, extracts the rendered surface, converts to the document pixel format, and cleans up PSD placed-layer blocks (SoLd, SoLE, PlLd). Includes a regression test suite covering solid fills, new layers, shape layers, and transformed smart objects.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:03:40 -04:00
cbcdeb2549 Layer styles: Photoshop's measured contour presets; Satin's contour lookup (#3803)
* fix(effects): Photoshop's measured contour presets; Satin reads contours from the far end

The built-in contour presets were hand-drawn guesses and none matched
Photoshop. They are now Photoshop 27.11's own curves, measured by running
a Satin with each preset against a Linear one (38 levels per curve,
thinned to within 1 %, straight segments).

The measurement also showed how Satin applies a contour: |c(1 - v) - c(1)|
(Cone and Cone - Inverted Satins are identical; a Gaussian Satin is 0.36
at 0.5 while the Gaussian on a glow is 0.64). New effects::satin_lut, used
by the CPU and GPU satin paths.

Probe results vs Photoshop (max level error): Satin with every preset
<= 3 (Cove - Shallow 9), was up to 128; Outer Glow Gaussian 38 -> 2,
Ring - Double 121 -> 15, Cone 48-52 -> 4-7; bevel Contour Cone at 100 %
range 27 -> 12. Corpus oracles unchanged (their stored curves are
symmetric). Gloss Contour is still off: that is the bevel's gloss model,
not the presets.

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

* Layer style UI test: the Contour Editor on the measured Cone

The measured Cone preset has 18 points, all inner ones corners, so the
editor test moves point 1 between its neighbours and checks the corner
flags by position instead of the old three-point shape.

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 08:52:57 -04:00
bbe77354e0 Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, measured (#3815)
* Camera Raw: Whites, Blacks, Shadows and Highlights closer to Photoshop's

Measured as a black box on Photoshop 25.4's Camera Raw Filter.

Whites and Blacks are global curves of the grey level: the measured rows
per slider value, Whites first, after Shadows and Highlights. The
clipping directions adapt to the image's black and white points
(Whites up to 2.7 times stronger on a dark image, Blacks at least 1.2).
On ten public-domain photos the mean error against Photoshop drops from
4.2-36.8 to 0.9-8.9 levels of 255.

Shadows and Highlights are a local Laplacian filter (Paris et al. 2011,
fast form of Aubry et al. 2014) on the log luminance, the method Adobe
credits for process version 2012. Shadows raises pixels darker than
their surroundings by an amount set by their own and their
surroundings' tone, so big edges keep no halo, and keeps the 99th
percentile; Highlights pulls pixels brighter than their surroundings
toward them and keeps the 1st. The strengths are fitted to the photos
(mean error 2.8-9.4 levels, was 3.7-14.3); Photoshop's exact operator
was not recovered.

The tone stages decode light with the document's curve: filter.cameraRaw
stores encodingGamma (1.8 for ProPhoto, where raws open).

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

* Camera Raw Shadows/Highlights: no contour lines on smooth gradients

Twelve reference values over a raw's whole range left more than a stop
between them, and the remapping changes within half a stop: coefficients
interpolated across that gap drew contour lines in out-of-focus areas
(Shadows +100 on a Nikon D4 raw). References are now an eighth of a stop
apart over the image's tones (the deepest 0.1 % share the lowest), on a
copy at most 1600 px across whose change comes back to full size by a
guided upsampling, so a 16-megapixel image takes about 2 s per slider and
the preview matches the result.

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

* Camera Raw on raw opens: Shadows/Highlights in linear light, D4 look and black point as Photoshop

Measured black-box on 29 Nikon D4 NEFs opened through Camera Raw in Photoshop 25.4 (sliders at
+-100 and +50, exposure +-1) against our own linear development of the same files.

- Shadows and Highlights of the open-time dialog now act while developing, in linear light before
  the tone curve, as Camera Raw does for raw files (exposure -1 gives the same gains). The log2
  gain is slider/100 * C(B - anchor): B is a guided-filter base of the log2 luminance on a copy
  1232 px on its long side, the anchor is image-adaptive (Shadows: log2 power mean p=1.5 of the
  linear luminance; Highlights: mean log2 luminance). New module photocraft_raw::tone; the
  preview undoes the camera curve to show the same (photocraft_raw::retone).
- The D4 profile copies Camera Raw's default rendering: look matrix folded into the
  ForwardMatrix, per-channel curve, and an adaptive black point (0.33 x the 0.1th percentile of
  the luminance, Sensor::black_point).

Against Photoshop on the 29 frames (1/4 size, log2 Y): default rendering median difference
0.29 -> 0.04 stop; correlation of the slider's effect Shadows +100 0.96, -100 0.91, Highlights
-100 0.87, +100 0.85 (the previous filter model: 0.68 and 0.55 on DSG_3464).

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

* docs: the open-time Camera Raw re-develop covers Shadows and Highlights

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

* Camera Raw raw-stage Shadows: keep shadow contrast as Photoshop does

The user saw Photoshop's shadows sink deeper with more contrast. Measured on the 29 D4 NEFs:
with Shadows +100 our shadow detail had 0.79 of Photoshop's local contrast and shadows sat 0.38
stop lighter. Two causes:

- The black point now comes before the Shadows / Highlights gain, as in Camera Raw (lifting
  first let the lift wash out the black point's deep-shadow contrast).
- The guided upsampling's epsilon goes from 0.01 to 1 stop^2: the change follows strong edges
  but stays smooth inside texture (with 0.01 it followed every detail and flattened it). Near
  strong edges the effect still matches Photoshop as well as elsewhere.

Shadows +100: shadow detail 0.79 -> 0.91, shadow bias +0.38 -> +0.16 stop; effect correlation
Shadows -100 0.906 -> 0.946, Highlights -100 0.870 -> 0.886, others unchanged.

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

* Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, as Photoshop

Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (descriptor
keys Cl12, CrTx, Dhze; each at ±100 and +50, and +100 at exposure −1, 348 opens). All three act
before the tone curve: the change at exposure −1 equals the one at 0 (correlation 0.98–0.99).

- Clarity: log2 luminance split into bands between guided-filter bases (radii 2, 8, 32, 128 on
  the 1232 px work copy), each band scaled by a measured gain that follows its base's tone, plus
  a tone term; carried to full size with the Shadows / Highlights change.
- Texture: Gaussian bands σ 0.5…16 on a half-size copy, each through its measured response
  (small detail raised more than strong edges, flat areas unchanged), guided-upsampled.
- Dehaze: He, Sun and Tang's dark channel prior on linear ProPhoto (dark channel in Display P3,
  airlight from the brightest 0.1 % of it, guided-smoothed transmission), w 0.8 at +100;
  −100 a uniform veil. The black point stays the default rendering's.

Against Photoshop, PhotoCraft's develop vs Photoshop's open on all 29 frames (median effect
correlation, log2 luminance at 1/4 size, Texture at 1/2): Clarity +100 0.81, −100 0.85;
Texture +100 0.85, −100 0.88; Dehaze +100 0.61 (0.78 in gamma 2.2: the crushed shadows
differ), −100 0.96. Shadows / Highlights unchanged (0.964 / 0.886).

The open-time Camera Raw dialog re-develops for them and its preview shows them (retone).

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-12 08:52:45 -04:00
6c7328b709 Workspace: wire Auto-Collapse Iconic Panels (#204) (#3880)
* ui: a panel expanded from its collapsed strip auto-collapses on an outside click

* ui: test the Auto-Collapse Iconic Panels peek open and outside close

* engine: show Auto-Collapse Iconic Panels in Preferences, it is wired

* docs: scorecard, settings that do nothing 30 -> 29 of 154

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:52:32 -04:00
Diego Chagas de22bca760 Remove tool: Create a new layer, and Remove after each stroke off (#1750) (#3808)
paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo
step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above
the target, only where the hole is, leaving the target as it was (with Sample All Layers it
fills from the composite). The options bar shows Photoshop's three options; with Remove after
each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and
Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them.
2026-10-12 08:51:56 -04:00
Jakub 286ff73533 perf: edits, undo and redo recomposite only where added or removed layers draw (#3847)
Session::edit reported damage only for edits that changed no pixels (same_pixels) and left
every other edit to recomposite the whole document unless the command reported its own
damage. Duplicate and paste do (#2883, #2901), but Fill, Invert, filters, Delete Layer and
the undo/redo of a duplicate, a paste or a delete all recomposited everything.

- Session::edit now reports step_damage(before, after), the same bound undo and redo use.
- step_damage bounds layers only one side has in both directions, so a removed layer counts
  where it drew like an added one; the bounded_additions flag and insert_damage go, and
  added_bounds becomes one_sided_bounds. A layer that is (or was) the clipping base of
  clipped layers above it, an adjustment, a video layer or one excluding channels still
  recomposites everything (#3090).
- The subject-mask cache is dropped only when the step can have changed pixels.

perf_scenarios on the layered 20 MP document, edits on a 1 MP layer (p50, RTX 3090, Vulkan):
delete layer 9.1 -> 1.4 ms, undo a duplicate 9.3 -> 1.6 ms, fill a 500 px square
17.4 -> 5.5 ms, Invert 16.7 -> 9.2 ms. Duplicate and paste are unchanged (already bounded).

Tests: duplicate_paste_and_delete_report_where_the_layer_draws checks that each step, its
undo and its redo report the same bounded damage and that no pixel outside it changes.
new_layers_that_change_clipping_recomposite_everything (renamed) now expects bounded damage
for a painted or empty new layer and None for a new clipping base or an adjustment; deleting
and restoring a layer reports its bounds.
2026-10-12 08:47:05 -04:00
Jakub 05c4cd685e perf: the Navigator redraws only the area an edit changed (#3845)
With the Navigator open, every edit re-rendered the whole document into a new thumbnail on the
UI thread: a full-resolution CPU composite, 303 ms after an opacity change and 486 ms after a
brush stroke on a 20 MP layered document.

The navigator cache now records the document revision of its image. When the document is
exactly one edit ahead, with the same preview key, a bounded last_damage and the same thumbnail
size, only the damage (grown by the layers' effect reach) is rendered with
render_reduced_damage and written into the texture with set_partial; an edit with empty damage
(a rename, a selection, a new empty layer) costs nothing. Anything else renders the whole
thumbnail as before. compose gains thumbnail_size, which thumbnail_buffer now uses, so both
paths agree on the size.

On the 20 MP document: 303 -> 36 ms after an opacity change, 486 -> 171 ms after a stroke
(RTX 3090 machine; the navigator renders on the CPU).

Test: navigator_damage_updates_match_a_full_thumbnail patches a thumbnail after a translate,
an opacity change, a stroke, a blend mode change and a new layer on a document with a drop
shadow, and compares it with a full thumbnail; it fails without the effect-reach inflation.
2026-10-12 08:46:53 -04:00
Jakub 3e428e7302 perf(gpu): a duplicated layer's texture starts as a GPU copy of its source's (#3848)
A duplicate shares its source's tiles (Arc), but its resident texture was new, so the
compositor converted and uploaded every tile again: duplicating a 20 MP photo layer uploaded
330 tiles. When a new resident's surface shares tiles with another resident of the same role,
region, texel kind, format and default pixel, its texture now starts as a copy of that one
(Stats.tiles_shared counts the tiles taken) and the usual per-tile pass fixes up whatever
differs. Layer textures gain COPY_SRC.

The copy is recorded in the frame's encoder, after whatever brought the donor up to date this
frame. Queued writes land before the encoder runs, so both textures are marked `ordered` and
their writes go through the encoder from then on; otherwise a write to either in the same
refresh would be overwritten by the copy or leak into it. A resident whose region only moved
still keeps its own texels (keep_overlap) first.

Duplicating a 20 MP photo layer in perf_scenarios: 25.1 -> 6.3 ms, 330 tile uploads -> 0
(RTX 3090, Vulkan). Paste is unchanged: the pasted layer has new tiles.

Test: duplicates_copy_their_source_texture covers a plain duplicate (no uploads), a
duplicate and its source each painted with one tile in the same refresh, and a duplicate
missing a tile, all against the CPU composite. Without the `ordered` routing the second case
is off by 167.6/255.
2026-10-12 08:46:28 -04:00
Jakub 9ff6c57e03 GPU composite: submit every 1000 copies and render passes, not only between page cells (#3844)
The compositor submitted only between 2048x2048 page cells, so one cell of a deep stack
recorded every pass into a single command buffer: 700 full-size layers make 1403 render
passes, and wgpu-core opens two hal command buffers per pass. Metal allows 4096 command
buffers per queue, so a few thousand passes in one cell can lose the device.

run_cell now counts copies and render passes and, when a refresh may submit in between,
submits the batch after FLUSH_OPS (1000) of them, waiting for the previous batch as the
page-cell flush does. Chunk textures, uniforms and bind groups outlive the submit and the
queue keeps the order, so a chunk continues in the next command buffer. A device lost in
such a submit ends the refresh with an error instead of recording more work.

Not measurably slower on Vulkan (RTX 3090): perf_scenarios rows are within noise.

Test: many_layers_submit_inside_one_chunk (700 noise layers on a 64x48 document) needs
flushes > 0 inside one chunk and matches the CPU composite; main records all 1403 passes
without a flush.
2026-10-12 08:46:16 -04:00
Scott C Hughes 3cc0d48410 fix(engine): Flatten All Layer Effects keeps layer and vector masks (#3804) (#3835)
Flatten All Layer Effects baked the layer (including its masks) into the pixels and then cleared mask and vector_mask, destroying unrelated mask pixels and their enabled/disabled state permanently after save. Bake the effect visuals with the masks left off the render and keep the original mask and vector_mask objects, so each mask still applies once to the baked raster and the command stays a visual no-op.

Tests: cargo test -p photocraft-engine --lib file_cmds; cargo test -p photocraft-engine --lib; cargo clippy -p photocraft-engine --all-targets (with the two pre-existing lints allowed); cargo test -p photocraft-engine --test panic_hunt -- --ignored
2026-10-12 08:46:04 -04:00
MrCloudy2andClaude Opus 5.5 04d2874e38 fix(image): 32 Bits/Channel holds linear light, as in Photoshop (#3792)
PhotoCraft treats 32-bit documents as linear light (Transfer::for_document,
Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only
rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop
stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB
chart, every sample = the sRGB-to-linear value). The conversion now decodes
colour samples through the document's tone curve after widening to 32 bits,
and encodes them before narrowing (so no linear value is quantised to 8 or
16 bits). Alpha, masks and alpha channels keep their values. RGB and
Grayscale only (the 32-bit modes).

With it, Photoshop's 32-bit results match exactly for the conversion itself,
Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80
levels off in sRGB terms).

Adds Surface::map_color (colour samples only, including the default pixel)
and makes Transfer::decode/encode public.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:45:48 -04:00
Desayuno64 8af15cf72a Shift Edge moves by a fraction of a pixel so the slider does not snap. (#3800)
100% is still max(radius, 4) pixels. Amounts between whole pixels blend the two neighbouring morphs.
2026-10-12 08:45:25 -04:00
MrCloudy2andClaude Opus 5.5 7890b277a0 fix(adjust): absolute Selective Color scales by the room the ink has (#3793)
Photoshop's absolute mode scales each range's ink change by how much the
ink can still move: adding ink by min(1, 2 x (1 - ink)), removing it by
min(1, 2 x ink). PhotoCraft added the full percentage and clipped, so
Neutrals Cyan +50 darkened a grey 64 to 0 instead of 32. Measured in
Photoshop 27.11 over every range x ink at +-50 (72 cases): all within 1
level now (8 of 72 within 3 before). CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:45:09 -04:00
Reynold Hu dcc9f86aa5 Workspace: wire Enable Narrow Options Bar (#204) (#3870)
* engine: expose workspace.enableNarrowOptionsBar through a Workspace helper

* ui: the options bar honors the narrow options bar preference

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:43:48 -04:00
fc02c91ffb io: read version 3 Photo Filter colour as XYZ, not Lab (#3790)
Photoshop CS2 writes `phfl` version 3 with "4 bytes each for XYZ color".
The parser read those values as Lab x100, so real files (L = 241.91)
fell back to Unsupported. Read them as D50 XYZ with 1.0 = 32768 (the
ICC 16-bit PCSXYZ encoding; the scale is inferred from a real file, the
spec does not give it). Writing stays version 2 RGB.

Fixes #3777

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:43:41 -04:00
Reynold Hu b0554b8178 Type: wire missingGlyphProtection (script fallback toggle) (#3863)
* text: missing glyph protection can turn the script fallback stack off (#1909)

* engine: apply type.missingGlyphProtection to the live text engine

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:34:22 -04:00
25c0ec78d5 Keep CMYK ink values when flattening and merging (#3855)
Flatten Image, Merge Visible and Merge Down now composite a CMYK stack of
plain Normal raster layers in its ink values, so a lone Background flattens
bit-exact and 16-bit depth is kept. Stacks that still need the RGB
compositor separate back through the document's own CMYK profile instead of
the built-in one, and Merge Down's scratch document keeps the profile.

Fixes #3778

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:33:50 -04:00
David MannandClaude Opus 5.5 6231325aea Brushes: drag a brush folder into another folder (#2014) (#3851)
Photoshop's Brushes panel nests folders by dragging one onto another; ours
could only reorder a folder among its siblings.

- brush.presets.moveGroup takes `into: {group?, folder?}`: the group or
  nested folder (with everything in it, presets in order) moves inside that
  folder of that group (`folder` [] = directly in the group), or with no
  `group` out to the top level as a group of its own. `before` names a
  sibling folder (or top-level group) there to land before. Moving a folder
  into itself or one of its own folders, a name clash at the destination,
  a result deeper than MAX_FOLDER_DEPTH, the ungrouped presets, `index`
  with `into`, and bad shapes are errors that leave the library unchanged.
  The preset store follows the move (a group moved into another one loses
  its group file; a folder moved out gets one).
- Brushes panel and Brush Preset picker: a group or folder header dropped
  on another header goes inside it; on the header's upper third it lands
  before that folder, reordering siblings or moving a nested folder next to
  it (out to the top level before a group). Every drop is one moveGroup
  command (`folder_drop` maps the drop).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 08:33:28 -04:00
bacc886a6d Preserve signed HDR colors during surface transforms (#3857)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 08:32:45 -04:00
6037ade5ed Fix straight-path anchor insertion at the hit position (#3856)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 08:32:15 -04:00
Scott C Hughes 2aa1f8d2c6 feat(engine): add history.undo and history.redo aliases (#3675) (#3786)
A `history.*` namespace exists (history.deleteState, history.newDocument) but not history.undo, so a client or agent guessing `history.undo` gets a hard `unknown command`. Register history.undo and history.redo as aliases of edit.undo and edit.redo, with no menu entry and no shortcut; the Edit menu items keep Cmd+Z / Cmd+Shift+Z.

Tests: cargo test -p photocraft-engine --lib history_cmds; cargo test -p photocraft-engine --test panic_hunt -- --ignored; cargo xtask parity.
2026-10-12 07:49:26 -04:00
MrCloudy2andClaude Opus 5.5 c5da1a0d5f Hue/Saturation: Photoshop's saturation curve, lightness order and Colorize lightness (#3781)
* fix(adjust): Hue/Saturation saturation, lightness order and Colorize as in Photoshop

Measured in Photoshop 27.11:
- Raising Saturation divides it by (1 - s): +50 doubles it. PhotoCraft
  multiplied by (1 + s), up to 26 levels off.
- Master Lightness applies first; hue and saturation then work on the
  result. Fixes the corpus file adjustments/rgb8/hue-saturation (Hue
  +40, Saturation +25, Lightness -10): 17 levels / 11.8 % bad -> 2 / 0 %.
- Colorize builds the colour at the lightened lightness, so black with
  Lightness +30 is a 30 % lightness colour, not grey (was 78 off).
- A colour range's positive Lightness lightens towards the pixel's
  brightest channel, not towards white (Blues +50 was 71 off).

All 18 Hue/Saturation probe cases are within 4 levels. Corpus: the
Colorize files go to 1-3 levels (psd-tools huesaturation_colorize_rgb
120 -> 3). CPU and GPU paths.

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

* fix(adjust): a range's negative Lightness darkens towards the darkest channel

Measured in Photoshop 27.11 (Blues -50 / -100, Reds -50): (102, 128, 204)
at Blues -50 becomes (102, 115, 154), towards the pixel's darkest channel,
not towards black. Mirrors the positive rule (towards the brightest).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:49:16 -04:00
MrCloudy2andClaude Opus 5.5 836fd52089 fix(adjust): Black & White tints like the Color blend mode, as Photoshop does (#3780)
Photoshop's Black & White tint keeps the grey's luminosity and takes the
tint's hue and saturation (SetLum + ClipColor, luma 0.30/0.59/0.11), the
same as the Color blend mode. PhotoCraft mixed half the grey with twice
the grey times the tint, up to 141 levels off. Measured in Photoshop
27.11 on 9 cases (4 tint colours over a grey ramp and hue sweeps): all
within 3 levels now. Corpus: photoshop adjustments/rgb8 and rgb16
black-white-tint go from 69 levels max / 77 % bad pixels to 1-3 / <=0.5 %.
CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:49:05 -04:00
UnemarsandClaude Opus 5.5 e35671ffa9 Shadows/Highlights: Photoshop's, measured (#3765)
Image › Adjustments › Shadows/Highlights follows Photoshop 25.4, measured as a
black box: per-channel screen curves (1 - (1 - v)^q blended over q = 1, 2, 4,
8), the Tone mask as a Beta distribution function of an 8-bit neighbourhood
brightness, an edge-aware (guided bilateral) neighbourhood, the luma of the
result with the colour differences scaled by Color, and the Midtone S-curve.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:55 -04:00
DennisandClaude Opus 5.5 e4e3f00f48 Camera raw colour fit: read a Sony Adobe RGB preview as Adobe RGB, not sRGB (#3760)
The per-file colour fit (`fit_preview_look`) converted the embedded JPEG to sRGB only when the JPEG
carried an ICC profile, and otherwise read it as sRGB. A Sony body set to Adobe RGB writes no profile
into its previews; it states the colour space only in the raw's EXIF (ColorSpace Uncalibrated with
InteropIndex "R03", the DCF option file for Adobe RGB). The fit then targeted a desaturated,
hue-shifted reference, and the developed raw came out that way.

`photocraft_raw::embedded_preview_color_space` reads that EXIF (sRGB, Adobe RGB or unstated; never for a
DNG, whose previews are the converter's), and `preview_profile` uses it when the preview has no ICC
profile of its own. A preview's own profile still wins. LightCraft reads the same tags for the same
reason (storytold/lightcraft#778).

Measured on 19 private Sony ILCE-1M2 ARWs, all shot in Adobe RGB, against the camera's JPEG read as
Adobe RGB (median CIE76 ΔE at 240 px): full-frame 400 mm shots against blue sky 5.6-7.0 → 1.5-2.4,
their mean a* offset +4.3…+4.9 → +0.7…+1.1; every file improved, mean of the medians 4.9 → 2.2.

Tests: `exif_colour_space_of_the_previews` (sRGB, Adobe RGB; R98, a missing interop IFD, other values,
a DNG and junk are unstated) and `a_preview_without_a_profile_takes_the_raws_exif_colour_space` (fails
without the EXIF fallback; the preview's own ICC profile wins). The hostile-input harness calls the new
function.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:45 -04:00
David MannandClaude Opus 5.5 81bff2aaa4 Brushes: Photoshop's texture Height/Subtract modes, Brightness and Contrast (#3340) (#3749)
Imported brushes whose Texture uses Height or Linear Height at a low depth
(common in Photoshop brush packs) painted nothing: the modes were modelled
backwards (paint only on the brightest texels, more of it with more depth).
Measured against Photoshop with single dabs over a 0..255 ramp pattern:

- Height: paint reaches 12 x depth x coverage into the pattern and fills
  what lies below it (dark valleys first; a brighter pattern takes paint
  away, which is why the reporter had to flip Brightness's sign).
  Linear Height grades the shallow end by the pattern instead.
- Subtract takes depth x pattern away (white removes the most paint);
  Color Dodge/Burn scale the pattern toward their neutral with depth.
  These now live in texture_combine; the Dual Brush keeps mask_combine.
- Brightness shifts the pattern by value/255 (not value/150); positive
  Contrast steepens by 1/(1-c/100) after the shift, negative flattens by
  1+c/100 before it. Texture::level is shared by the renderer and the
  Brush Settings swatch.
- ABR import mapped textureContrast with /50 for both signs; positive
  values are now /100, matching the model and the Brush Settings field.

Built-in Subtract presets get Invert so they look as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:35 -04:00
David MannandClaude Opus 5.5 b071929ecb Type: editing text keeps its ligatures and OpenType alternates (#3617) (#3747)
Photoshop keeps Stylistic Sets, Contextual Alternates, Swash, Fractions, Ordinals, Titling and
Stylistic Alternates and Ornaments only in a PSD's Txt2, not in EngineData. Import now reads them
into the runs' font features, so the layout drawn while the type is edited keeps Photoshop's
glyphs instead of falling back to the default ones.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:24 -04:00
MrCloudy2andClaude Opus 5.5 56c5fd5797 fix(effects): Satin at distance 0 draws as 1 px, as Photoshop does (#3744)
Photoshop 27.11 draws Satin Distance 0 as a 1 px offset (an edge band of
2/size), not as two copies that cancel out. PhotoCraft drew nothing. Measured
on a #808080 square with a Linear contour at sizes 5, 10 and 20; the other 12
cases already matched within 1 level. Applied to the CPU and GPU paths and
to the effect reach.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:14 -04:00
224cebbd8a fix(automation): warn when text falls back from a missing font (#3391) (#3630)
* fix(automation): warn when text falls back from a missing font (#3391)

* 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-12 07:48:03 -04:00
JGFandClaude Opus 5.5 edc1b1e774 View: remember Extras, Show and Snap To across restarts (#3762) (#3787)
* View: remember Extras, Show and Snap To across restarts (#3762)

View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and
the View › Snap To targets reset to their defaults on every launch. They
are now saved in Preferences (`viewExtras`) as they change and restored at
startup, as Photoshop keeps them between sessions.

Only items that differ from their defaults are stored, so a default
changed in a later release still reaches users who never touched it. A
hand-edited file can't break them: unknown keys and non-boolean values
keep their defaults.

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

* View: rustfmt the view-extras restart test (#3762)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:47:33 -04:00
53d814f643 feat: Photoshop's layer and selection keys (Ctrl+, toggle, Ctrl+\, /, Ctrl+Alt+2, Ctrl+Alt+Backspace, Tab rename, Alt-click thumbnail) (#3710)
* feat(engine): Ctrl+, toggles a layer's visibility and / its transparency lock

Photoshop's Ctrl+, (Cmd+, on macOS) hides the selected layers and, once
they are all hidden, shows them again: its Layer menu item then reads
Show Layers. layer.hideLayers now does the same, so the key toggles: it
shows them only when every targeted layer is hidden.

The / key toggles Lock Transparent Pixels, as the Layers panel's first
lock button does (layer.toggleLockTransparency). With several layers
selected, any unlocked one means lock them all; the Background, which
can't take this lock, is left out. Each press is one undoable step.

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

* feat(engine): Ctrl+\ targets the layer mask and Ctrl+Alt+2 loads luminosity

Photoshop's Ctrl+\ (Cmd+\) targets the active layer's mask, as clicking
its thumbnail does, beside Ctrl+2 for the composite
(channel.target.layerMask; greyed when the layer has no mask). Its
Ctrl+Alt+2 (Cmd+Option+2) loads the image's luminosity as a selection,
the Ctrl-click on the composite channel (select.loadLuminosity).

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

* feat(engine): Ctrl+Alt+Backspace fills from history

Photoshop's Ctrl+Alt+Backspace (Cmd+Option+Delete) fills the selection, or
the layer, from the History Brush's source state. edit.fillHistory is
edit.fill with contents "history": the oldest state still held, unless
"state" names another. It joins the other fill keys, so it honours the
Channels panel target and appears with them in Keyboard Shortcuts.

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

* feat(ui): Tab renames the next layer; Alt-click a thumbnail fits the layer

While a layer name is being edited, Tab commits it and starts renaming the
layer below, and Shift+Tab the layer above, as in Photoshop; the
Background, which isn't renamed in place, ends it. Alt-clicking a layer's
thumbnail selects the layer and fits its content on screen (View › Fit
Layer(s) on Screen).

Key tests cover Ctrl+,, /, Ctrl+\, Ctrl+Alt+2 and Ctrl+Alt+Backspace
through the shortcut dispatcher. docs/ui-parity.md lists the new keys.

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

* fix(ui): an explicit channel target drives the Layers panel's mask target

After Ctrl+\ targeted the layer mask, Ctrl+2 moved the Channels target back
to the composite, but the Layers panel kept the mask targeted (its own
flag, which only ever followed the engine towards the mask), so painting
still went to the mask. The same happened after clicking the mask
thumbnail. Any channel.target command (Ctrl+2, Ctrl+\, Ctrl+3…) now sets
the panel's mask and vector-mask targets from the engine's target.

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

* Use Document::is_background after #4036 removed extra_cmds::is_background

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 07:32:23 -04:00
3dd5fc1cdc automation: say why a command is disabled in engine.commands (#3740)
engine.commands, command.list and the MCP command_list tool now give each
row a disabledReason (null when enabled, else the failed precondition) and
needsDocument (true when the command is off because no document is open),
so an agent can tell "open a document first" from "not runnable". enabled
keeps its meaning. Both listings share one row builder in the engine.

Fixes #3664

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:57 -04:00
Valentin KleinandClaude Opus 5.5 6751a46003 Codecs: integer fast paths for Image::convert (#3717) (#3729)
U8/U16 depth changes and pure channel-copy layout changes (alpha add/drop, gray to RGB) skip the two intermediate f32 buffers. Output is bit-identical to the f32 route.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:45 -04:00
89312de597 Answer a repeat auth with "already authenticated" (#3723)
A second `auth` frame on an authenticated TCP connection fell through to
the method dispatcher and came back as an unknown method, in both the
headless serve server and the desktop control server. A shared
`security::repeat_auth_reply` now answers it with `already authenticated`
(keeping the request id) and the connection keeps serving.

Fixes #3668

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:32 -04:00
Nicolas Falesy 0c2ca488ae fix(text): scale faux bold dilation with glyph scaling (#3705) (#3719)
When drawing faux-bold glyphs, the offset dilation was composed as
`Xform(ox * r, oy * r).mul(&glyph)`. Because `Xform::mul(o)` applies `o`
first (`self ∘ o`), the translation offset was added after the glyph's
scaling transform, leaving faux bold dilation unscaled by horizontal
and vertical font scaling.

Applying the glyph transform to the offset transform (`glyph.mul(&offset)`)
ensures dilation is performed in font-local coordinates and scaled
proportionally with anisotropic glyph scaling, matching Photoshop.
2026-10-12 07:13:06 -04:00
e54adc250c feat(codecs): decode legacy PCX images and palettes (#3692) (#3701)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:55 -04:00
Streetblock 38236f90c2 perf(compose): allow empty vector masks in preview proxies (#3716) 2026-10-12 07:12:47 -04:00
Streetblock e156a9ed85 perf(compose): select Normal RGB kernel once per buffer (#3715) 2026-10-12 07:12:34 -04:00
Valentin KleinandClaude Opus 5.5 476f983876 Codecs: bound the TIFF IFD walk by a file-size entry budget (#3683) (#3711)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:13 -04:00
Valentin KleinandClaude Opus 5.5 7893a6e04d PSD: avoid cloning layer data when padding and presize the output buffer (#3687) (#3707)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:00 -04:00
Valentin KleinandClaude Opus 5.5 21f1012ce2 Codecs: refuse implausible PNG/WebP headers before allocating (#3691) (#3706)
A tiny file declaring a huge image reserved the full output buffer before any
data was checked. Add Limits::check_plausible (compression-ratio guard) and a
fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default
max_alloc to 1 GiB.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:11:41 -04:00
Brandon ThomasandClaude Opus 5.5 b564f2ca9c fix(layers): identify the Background by structure, never by its English name (#4036)
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
  in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
  "Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
  no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
  lock the Background's position like every other place that creates one.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:04:33 -04:00
Brandon ThomasandClaude Opus 5.5 6547ca0b84 fix(psd): Multichannel alpha channels stay alpha; a save keeps resources and global blocks (#4031)
- DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black
  spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged.
- Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths,
  EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while
  they match the channels), except the ones that only describe layers, plus the document-level
  tagged blocks (4-byte padded). Path writing and global-block padding are shared with the
  layered writer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:02:10 -04:00
4705fc5980 feat(io): open Krita .kra composites as editable raster documents (#3692) (#3700)
* feat(io): open Krita .kra composites as editable raster documents (#3692)

* 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-12 07:01:20 -04:00
0dbc288667 fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias (#3672) (#3678)
* fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias

warp_triangles (Edit > Transform > Warp, smart objects placed through a warp, Puppet, Lens and
Wide Angle corrections) took one Catmull-Rom sample per output pixel with no minification
filter. A large smart object placed small through a warp (a 4200x4800 design at ~1/3-1/4 size
in a shirt mockup) came out with crinkled, doubled or missing grid lines where Photoshop's render
is smooth. The affine path (transform::warp_surface) already pre-reduces below 50% for exactly
this reason ("bicubic alone aliases below ~50%"); the mesh path had no equivalent.

Each triangle now carries its inverse-map Jacobian, computed analytically from its vertices.
Where it shrinks the source, an output pixel averages nx x ny bilinear subsamples over its
source footprint, with about one subsample per source pixel per axis (anisotropic, capped at 8).
Averaging happens in premultiplied space, before alpha is divided out. Triangles that keep or
enlarge the source still take the exact single sample, so identity and unscaled warps are
bit-for-bit unchanged. The per-tile source read grows by the subsamples' actual spread instead
of a fixed pad. Per-pixel filtering is used rather than a global pre-reduce because a mesh
warp's scale varies across the surface: one reduction factor over-blurs the parts that stay
large or under-filters the parts that shrink most.

Measured with an earlier cut of this approach (square bicubic subsampling) against Photoshop
exports of real mockups (RMSE of the placed design): 1.78% -> 0.81%
(White shirt), 0.49% -> 0.29% (Pepper). A mockup without a warp is unchanged.

Test: a_shrinking_mesh_warp_is_prefiltered (8/16/32-bit). A 1 px grid every 4 px placed at
1/5 through an Arc must stay within 0.25 of its 9/16 area average. Before: 0.5625 off (solid
black pixels). After: 0.20 off, which is the limit of a ~5 px box over a 4 px period.

Perf (30 MP RGBA8 source, place_source with Arc 10, release, M-series, best of 7):
  1/3 size: 55 ms -> 87 ms
  1/1 size: 240 ms -> 239 ms (no subsampling: identical path)

Co-authored-by: Claude Opus 5.5 <[email protected]>

* Use as_chunks for the opaque-pixel scan (clippy 1.99)

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 06:49:25 -04:00
e4298f554b Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653) (#3696)
* Free Transform: layer styles render with the transformed layer (#3653), work in progress

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

* Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653)

The live preview drew the moving pixels as a bare texture on a mesh, so Stroke,
Drop Shadow and every other effect vanished while the box was open (and for
type, shapes, groups and smart objects the style was baked into the texture,
cut off at the layer's bounds and stretched with the box).

A styled layer is now composited for the preview: the moving texels are
resampled through the box and the style is laid over them by the CPU
compositor's composite_with_effects, the code that draws the layer once the
transform is applied. A stroke keeps its width when the box is enlarged and a
shadow keeps its direction when it is turned, as after the commit.

The composite is made at screen resolution (never finer than the document),
with the style's sizes scaled to match, within a pixel budget from a measured
cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest.
It is rebuilt only when the box, the zoom or the layer changes.

Still plain: the selected part of a styled layer, and Warp mode.

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

* Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style)

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

* Format the type warp texel read

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

* Free Transform: Content-Aware Scale's preview has no styled composite

Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this
branch started; give it the new field so the merge 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-12 06:37:15 -04:00
304720b1a0 Gradient Map: edit color stops on the ramp (#3982)
* Gradient Map: edit color stops on the ramp (#2958)

* Gradient Map: keep stop removal scoped to the editor

* Gradient Map: fit selected stop controls in narrow Properties panels

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:30:14 -04:00
Scott MacdonaldandClaude Opus 5.5 590bec7998 feat(io): import Photoshop Displace smart filters with their embedded map (#3680)
A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in
the modelled filter list, so it became psd.unsupportedFilter and rendered
as a pass-through. Mockup PSDs that bend a replaceable design over fabric
(a design smart object with Displace then Gaussian Blur) rendered flat.

Photoshop stores the displacement map inside the filter: `DspD` (raw data)
holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes
(horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM
(StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an
alias to the author's map file, usually unavailable).

The filter now maps to filter.distort.displace. As for Camera Raw, the
whole item (with the map) is kept as hex in `__displacePsd`, so it
re-renders, survives .pcraft and is written back to PSD with only the
editable settings and blending replaced. params_for resolves the embedded
map, so the smart filter re-render applies it, stretched over the document
canvas (the bounds smart filters already pass). Another mode, a missing
or truncated map, or unknown enum values keep the item opaque as before.
The map parser uses checked sizes and caps maps at 2^26 pixels.

Tests: displace_map unit tests (layout, every truncation, hostile and
corrupted headers, round trip, edited settings, opaque fallbacks, bad
exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map
(8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:55 -04:00
Scott MacdonaldandClaude Opus 5.5 5ed758763d PSD: smart object Trnf frames the custom warp's control points, not the source rect (#3677)
A placed layer with a custom warp (warpCustom, a 4 x 4 customEnvelopeWarp
mesh) can have control points outside its source rect 0..w x 0..h. Photoshop
maps the bounding box of those 16 control points onto the Trnf quad. PhotoCraft
mapped the source rect onto it, so a mesh that spills past the source counted
the overhang twice and the re-rendered smart object came out stretched.

Measured against Photoshop's own export of a real shirt mockup (4200 x 4800
source, mesh spilling to x = -9, y = -88): before, a ~1.6% vertical stretch,
the tile offset growing from 0 to -16 px top to bottom; after, alignment is
exact everywhere and the RMSE against Photoshop is 0.49%. The bounding box of
the evaluated warp outline (rather than the control points) fixed that file
but put another at 5.4% RMSE, so it is the control-point box.

- blocks::placed_frame: the shared rule. The control-point box of a
  single-patch custom warp, else the source rect; a degenerate or non-finite
  box falls back to the source rect.
- blocks::parse_smart (and so smart_map::parse_sold): the affine maps that
  frame onto Trnf.
- smart_map::quad_points: export writes Trnf (and PlLd's quad) as the image of
  the same frame, so a spilled mesh round-trips and quad_moved keeps the
  imported Trnf as stored. Distort / Perspective placements keep the source
  rect, matching parse_smart_perspective.

Refs #579.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:38 -04:00
Scott MacdonaldandClaude Opus 5.5 2cb396d154 fix(engine): Displace smart filter ignores its mapPath displacement map (#3676)
A Displace applied to a smart object is recorded as a smart filter and
rendered by apply_smart_filters -> filters::apply_filter_to_surface, both
when it is added and on every later re-render (replace contents, toggling
a filter, transforms). That path built FilterParams with params_for alone,
which leaves Displace's map as None; only run_filter resolved `mapPath`
(through filters_ext::resolve, which needs the session). With no map,
algo's displace returns the source unchanged, so the smart filter was a
silent no-op.

apply_filter_to_surface now resolves `mapPath` through the same file read
and import the direct command uses (filters_ext::map_from_path, shared by
resolve and the new session-free resolve_map_path). A map that can no
longer be read on re-render leaves the pixels alone, as for an unknown
filter; the command still refuses an unreadable map when the filter is
added. `mapDocument` needs the session and stays unresolved on re-render.

Regression test: smart_cmds::tests::
displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:33 -04:00
Valentin KleinandClaude Opus 5.5 64a02107ba PSD: cap descriptor list/object preallocation from untrusted counts (#3636) (#3699)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:18 -04:00
Valentin KleinandClaude Opus 5.5 16341720c0 PSD: bound the zero padding of undersized pattern channels (#3652) (#3686)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:14 -04:00
Valentin KleinandClaude Opus 5.5 7c77143b97 PSD: don't preallocate knots from untrusted path length counts (#3635) (#3654)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:09 -04:00
Valentin KleinandClaude Opus 5.5 04396dd85f PSD: composite_rgba8 checks the output budget and converts planes fallibly (#3639) (#3651)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:27:57 -04:00
Brandon ThomasandClaude Opus 5.5 27f56eb762 feat(compose): Lab documents evaluate Invert, Posterize, Threshold, Brightness/Contrast and Gradient Map on L, a, b (#4038)
In Lab documents Photoshop evaluates these adjustments on the Lab channels,
not on the screen RGB colour:

- Invert and Posterize act on L, a and b alike (normalised L/100,
  (a+128)/255, (b+128)/255; Invert is n -> 1 - n).
- Brightness/Contrast (modern and legacy) applies its tone curve to L only;
  a and b are unchanged.
- Threshold splits on L alone and outputs neutral black or white.
- Gradient Map is driven by L (Reverse uses 1 - L).

Adjustment layers (CPU compositor) and destructive Image > Adjustments both
go through the new `adjust::apply_in`; the GPU compositor defers these
adjustments to the CPU in Lab documents. Levels and Curves keep their Lab
records; other adjustments stay on the screen colour.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:20:44 -04:00
zenjabbaandzenjabba ce16e100b0 Expose WebP and optional Batch export quality (#3956)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 06:20:16 -04:00
McClabe be877c27d0 fix(ui): keep filter jobs polling on idle Linux (#3961) 2026-10-12 06:20:00 -04:00
Nigel Ihza Firdausy 6ca7e60cb0 fix(ui): make checkbox labels nonselectable with pointer cursors (#4002) 2026-10-12 06:19:44 -04:00
Nigel Ihza Firdausy 73520f8681 fix(ui): Open layer color choices from the visibility icon (#4001)
* fix(ui): offer label colors from the layer eye icon

* fix(ui): offer label colors from the layer eye icon

* test(ui): cover layer eye color context menu

* style(ui): keep extracted color menu indentation consistent
2026-10-12 06:19:28 -04:00
MarekSpodBiedry db509dc4d9 fix(ui): show a paint-bucket cursor for the Paint Bucket tool (#3968) (#3989) 2026-10-12 06:19:13 -04:00
Vikram Bhaskaran ad3417dc05 Dismiss Home when the document count changes. (#3984)
Chrome stored the document count when Home opened so File New could leave the overlay. Closing a tab that restored that count re-opened Home and hid remaining tabs (#3981). Clear the stored count as soon as it no longer matches, then keep Home dismissed until the user opens it again.
2026-10-12 06:18:58 -04:00
Brandon ThomasandClaude Opus 5.5 0d6e658e73 PSD: rasterized fill and smart layers stay rasterized after save and reopen (#4034)
* fix(psd): rasterized fill and smart layers stay rasterized after save and reopen

Layer > Rasterize > Fill Content / Smart Object swapped the layer's content
to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE)
blocks read from the PSD. Written back, they made the reopened file a fill
layer or smart object again and discarded the pixels. Photoshop writes none
of them for a pixel layer.

- Rasterize drops those blocks from the layer.
- The PSD exporter never writes fill, placed, type (TySh), shape-content
  (vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever
  command made it one; a vector mask (vmsk/vsms with its vogk) stays.

Tests: synthetic fill + smart object rasterize/save/reopen, the exporter
backstop with stale blocks, and Photoshop-authored corpus files.

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

* fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:11:33 -04:00
Brandon ThomasandClaude Opus 5.5 6f1c4481bf fix(psd): type layer bytes match Photoshop (TySh bounds, AntiAlias, version info) (#4041)
- TySh: the four values after the warp descriptor are big-endian f32, not
  i32. Written as Photoshop writes them: zeros for unwarped type, the
  text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped
  text oracles).
- EngineData AntiAlias numbers as Photoshop writes them, on write and on
  read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong,
  3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which
  no corpus file confirms, write Smooth and keep their exact mode in AntA.
- Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026",
  no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles
  write.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:06:46 -04:00
5bc66094f8 fix(ui): allow selecting shapes and editing color with path selection (#3915) (#3932)
* fix(ui): allow selecting shapes and editing color with path selection (#3915)

- In vector_ui.rs, implement pick_shape_at to hit-test visible shape layers by path, surface pixels, or live shape geometry.
- In path_selection_finish and canvas ToolEvent::Down, select the clicked shape layer under the pointer when using Path Selection.
- In direct_select.rs, include visible shape layers in candidate hit tests and sync layer.select on press.
- In options_bar, display Fill swatch/checkbox, Stroke swatch/width/style, and corner radius when a shape is selected with Path Selection or Direct Selection, and dispatch shape.edit.
- Add tests in path_selection_tests.rs and vector_ui.rs verifying shape selection on click and options bar editing.

Fixes storytold/photocraft#3915

* Collapse nested ifs for clippy (collapsible_if)

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 06:05:15 -04:00
Scott C Hughes 24bd7533fc feat(ui): the generic filter dialogs get a Photoshop-style preview window (#3769) (#3877)
Photoshop shows a preview of the filtered result inside its filter dialogs; ours only had the parameter widgets, with the live result on the canvas behind the dialog. The generic schema dialog now draws a preview window from the same downsampled proxy the canvas already computes, with - / + zoom steps and a percentage readout, drag to pan, a click to centre on a document point, and press-and-hold to compare with the unfiltered image. It sits under the parameters (reserving its height so a small window still fits OK/Cancel); the Preview checkbox still toggles the on-canvas result, unchanged.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib (only the pre-existing Czech cs.tsv duplicate-key test fails); cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm; a snapshot of the Emboss dialog shows the window and its zoom controls.
2026-10-12 06:05:02 -04:00
Fabian GhenofandClaude Opus 5.5 83f4452bf2 Layer › Export As exports every selected layer or artboard (#3629) (#3909)
With several layers selected, Export As was greyed: its dialog exported only
the active layer. Someone with 20+ artboards (ads, Steam store capsules) had
to export them one at a time.

Export As now opens the same dialog for the selection ("Export As: N
layers", the preview showing the first) and, on Export, asks for one
destination and writes one file per layer with the dialog's format, quality,
transparency and scale, each at its own size. Files are named as Quick
Export as PNG names a selection's: the chosen path, then numbered siblings
named after each layer, now in the chosen format. The layers are captured
when Export is pressed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:04:35 -04:00
Scott C Hughes 19e51cf0ad refactor(ui): unify plain text inputs on one text_field widget (#3789) (#3896)
Plain text fields (Preferences search and hex, the Color Picker hex, and the brush search fields) each drew egui's default TextEdit frame, so they differed in padding and border and showed egui's white focus ring (its selection.stroke). Add widgets::text_field, which draws the same box as a value_field (field fill, field_border, radius_sm) with frame NONE, a consistent inner padding, no focus outline, and a scoped, non-blue text-selection colour (grey in the monochrome Pro themes, the theme accent elsewhere). Migrate those fields to it; numeric fields already use value_field.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib; cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm
2026-10-12 06:04:23 -04:00
coolvitto c0fa2269d2 Update Japanese translations (#3892)
Improved readability and several translation corrections.
2026-10-12 05:57:45 -04:00
Brandon ThomasandClaude Opus 5.5 32552a61a1 fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606) (#4047)
Liquify dabs were spaced a fifth of the brush radius apart and restarted
at every pointer event, so a fast mouse (few far-apart events) and a slow
one (many close ones) warped the same path differently, and a slow drag
piled up one dab per event. Dabs now fall every twentieth of the radius
by distance travelled along the whole stroke, carried from one segment
to the next, as in Photoshop: any sampling of a path gives the same
field. The stationary tools' per-dab strength is scaled to the new
spacing so their effect per distance travelled is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:56:09 -04:00
Brandon ThomasandClaude Opus 5.5 f86517a029 fix(psd): write descriptor ids warp, list and time as length-4 strings (#4039)
A descriptor class or key id is a u32 length and bytes, where length 0
means a 4-byte code follows. Photoshop stores `warp`, `list` and `time`
as length-4 strings and treats the code form as a different key, so the
warps of new smart objects and type layers and regenerated layer comp
lists were not recognised. In the corpus, `warp` appears 741 times as a
string and never as a code, `list` 5/0, `time` 7/0.

`Id::new` now makes these three names string ids, as keys and class ids.
Reading is byte-based and accepts both forms; a code read from a file is
written back as a code.

Tests: exact bytes for each name as class and key, both forms read,
fresh placed-layer warp (SoLd/PlLd) and layer comp list bytes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:50:36 -04:00
Brandon ThomasandClaude Opus 5.5 62346f7fb6 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399) (#4032)
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 05:45:50 -04:00
Brandon ThomasandClaude Opus 5.5 3d0c819d46 Release prep: 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:44:37 -04:00
Brandon ThomasandClaude Opus 5.5 dd708f4ce4 feat(psd): Gradient Map stops in CMYK, Lab, Gray and HSB import and round-trip (#4033)
Photoshop writes a Gradient Map's colour stops in the document's own colour
space (CMYK, Lab, Grayscale) or HSB. PhotoCraft only read RGB stops, so such
layers imported as unsupported and did nothing.

- Decode every plain PSD colour space (RGB, HSB, CMYK, Lab, Grayscale) in
  `grdm` stops; CMYK converts through the document's own CMYK profile
  (photocraft-cms), Lab through D50 Lab. Book colours and unknown ids stay
  unsupported (kept verbatim).
- Saving keeps the file's own `grdm` block, so stops keep their colour space;
  toggling Reverse or Dither patches just those flags in place.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:43:27 -04:00
Reynold Hu e79d6571ba Actions: replay tests for crop, filters, selections and styled text on a second document (AUTO-219-4) (#3566)
* test(engine): a recorded edit run replays on a second document

* docs(scorecard): mark AUTO-219-4 done; the replay tests cover all four families
2026-10-12 05:36:39 -04:00
a1a7b17ee0 fix(engine): a PSD type layer names the installed font, not an invented one (#3556) (#3564)
* fix(engine): a PSD type layer names the installed font, not an invented one (#3556)

A type layer created with a font by family name stored no PostScript name, so PSD export
invented one ("<Family>-Regular", spaces removed). Photoshop looks type up by PostScript name,
so it showed the font as missing and substituted Myriad Pro as soon as the text was edited.

Resolve the family's installed face (matching weight/italic) from the font database when the
family, style, weight or italic is set, and store its real PostScript name; an explicit
postscriptName still wins. A family PhotoCraft does not have keeps no name, so the export falls
back as before.

Test: a_family_font_gets_the_installed_postscript_name; the existing named-face test now expects
the resolved name.

* Type tool test: a family change names the installed face (#3556)

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 05:32:03 -04:00
79cfa2b65a Guides: Shift-drag out of a ruler lands on the nearest ruler tick (#3770) (#3796)
As in Photoshop, holding Shift while dragging a new guide out of a ruler
snaps it to the tick marks the ruler draws (tenths of the labelled step),
in the current ruler unit and zoom. Without Shift the guide snaps to the
document, layers, selection and grid as before.

The tick spacing draw_rulers computed inline moves to ruler_scale, shared
by the renderer and the new snap_to_tick, so the snap always matches the
ticks on screen.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 05:22:58 -04:00
bade115202 Fail CLI run when recorded action playback fails (#3861)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 05:14:54 -04:00
Jakub 6d03075ed7 perf: draw marching ants as one mesh instead of a line shape per dash (#3846)
Every ants segment was a white line shape plus a black line shape per 4 px dash, and egui
tessellates each. A selection with many edges seen whole (a speckled selection at 30% zoom:
94,666 segments) cost 45.6 ms per idle frame, ten frames a second while the selection shows.

Horizontal and vertical segments now go into one egui::Mesh of 1 px colored quads, with the
same pixel-centre rounding, dash length and phase; the white base runs half a pixel past both
ends so corners join as the line caps did. Slanted segments (Content-Aware Move's transformed
outline) stay line shapes, which egui antialiases. A textured, repeating dash isn't possible:
egui-wgpu clamps texture coordinates.

Speckled selection, idle frame: 45.6 -> 12.0 ms at 30% zoom, 6.6 -> 4.5 ms at 125%
(RTX 3090, Vulkan; an idle frame with no selection is about 3 ms). Screenshots at 100% and
800% zoom are pixel-identical to the line shapes.
2026-10-12 05:14:41 -04:00
Sir Starch 5bdb12ec51 fix(canvas): keep transform preview visible while panning (#3610) (#3843) 2026-10-12 05:14:27 -04:00
Scott C Hughes ae0e5eb731 feat(ui): drag to reorder swatch groups and preset folders (#3658) (#3828)
Swatch groups and the shared preset-browser folders could only be clicked, so there was no way to put them in the order the user wants. Dragging a folder header now mirrors the dock's per-group tab reorder (#2272): an accent insertion line shows where it will land, and the order is stored by name in PresetUi (UI state, groupOrder) so it saves and reloads with the rest of the state, reading as leniently as tabOrder. Because the folders share one browser, Gradients, Patterns, Styles and Shapes get it too.

Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel.
2026-10-12 05:14:17 -04:00
zenjabbaandzenjabba ad5fb53f68 Web: avoid shifting canvas during preset saves (#3805)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 05:14:07 -04:00
JGFandClaude Opus 5.5 a2cdc6c2d7 fix(ui): drag a document tab off the strip to float it in its own window (#3733) (#3785)
Dragging a document tab more than 24 pt above or below the tab strip now
tears it off into a separate OS window placed where it was dropped, so it
can be moved anywhere, other screens included (as in Photoshop). Sideways
drags still reorder. Both tab strips share the new tab_drag helper.

window.arrange.floatInWindow takes optional `document` and `position`
params (validated: bad values are errors, not panics); DocWindow gains a
serde-default `position`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:13:48 -04:00
Valentin KleinandClaude Opus 5.5 9f49cae093 Move: with an unlinked layer mask targeted, the Move tool moves the mask alone (#3604) (#3611)
layer.translate takes target=mask: a layer whose mask is unlinked then shifts that mask only
(the shell adds the target when the Layers panel targets the mask), live preview included.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:04:13 -04:00
xsvm 6d21a69968 Brush: symmetric live strokes update only what changed (#3593) (#3605)
With painting symmetry on, every pointer event cloned and finished the stroke and each mirrored
pass, merged them again, composited the whole stroke and restored each pass's full bounds, so an
event cost grew with the area painted so far. StrokeRenderer::composite_union_live merges and
composites only the coverage tiles that changed (new dabs, the smoothing tails now and before),
leaving exactly the pixels the full merge gave; a mirror joining redraws every tile once.
2026-10-12 04:53:36 -04:00
4736a4bcbe Character panel: OpenType feature buttons (TYPE-218-5, #3752) (#3757)
The Character panel (and the Properties Character section) gains
Photoshop's row of eight OpenType buttons: Standard Ligatures, Contextual
Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling
Alternates, Ordinals and Fractions. Each shows pressed while its feature is
on at the selection (else the layer) and toggles it through the existing
type.openType.* command, on the same scope as the other Character controls
(the selected characters while typing, else every selected type layer).

Scorecard TYPE-218-5 stays partial: the Numerals and Stylistic Sets
submenus are still missing.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 04:52:23 -04:00
MrCloudy2andClaude Opus 5.5 8b21621671 Chisel bevels: Photoshop's facet slope and ramp ends (#3595)
Measured on Photoshop 27.11 (scripted: a 128 px square with one Bevel &
Emboss, light from the top at 30°, sampled pixel by pixel across its
edges; Chisel Hard and Soft, inner and outer, size 10 and 20, depth 100
and 300):

- A chisel facet's slope is depth × size / (2 (size + 1)): it rises to
  the same depth × size / 2 as a Smooth bevel, over size + 1 px.
  PhotoCraft gave it the full depth, about twice as steep (facet 217
  where Photoshop shows 188).
- Inner Hard's ramp runs from half a pixel beyond the edge to size + ½,
  so the facet is flat right up to the edge pixel; Inner Soft starts a
  pixel further out (a steeper edge pixel) and is not blurred; Outer
  Hard ends a pixel further out; Outer Soft is a pixel shorter with a
  steeper edge pixel. Outer chisel bevels also shade the pixel where
  their ramp reaches zero (it still slopes), as emboss styles already
  did.

All six inner and outer chisel cases are now within 1 level (they were
27 to 154 off); Smooth bevels are unchanged. Emboss and Pillow chisel
are not modelled yet (they keep the blur).

On the GPU: the same ramps in fs_mbevelh (Chisel Soft flag in p0.z), the
emboss-only blur, and the outer reach-past flag in p3.x; the 44 GPU
parity tests pass. Corpus: every Photoshop chisel oracle improves
(bevel-inner-chisel-hard 20.1 -> 6.5 % differing pixels, outer-chisel-
hard 13.6 -> 5.2 %, inner-chisel-soft 22.3 -> 9.3 %, emboss and pillow
chisel a few points); pass counts unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:43:01 -04:00
fabiodalez-dev dbf9d0d998 i18n(it): translate the dynamic labels the Italian catalog was missing (#3742)
Adds the 1,287 rows the other catalogs have and Italian lacked (filter and adjustment parameters and choices, Camera Raw, Lighting Effects, History and Layers labels, metadata, dialogs), fixes mistranslations in existing rows (Facet, Gradient stops, Save As Preset, Canvas, Spot Color) and uses one term per concept (Predefinito, Traccia, clic, scorciatoia, filmato).

Factors the Korean generated-label check into missing_generated_filter_labels and runs it for Italian too. Adds docs/localization-it.md and updates localization-parity.
2026-10-12 04:38:03 -04:00
FredApps 62f4b9d453 Menus: keep clear of the taskbar when Wayland hides the window position (#3737) (#3739)
* test(ui): scaffold reported menu viewport coverage (#3737)

* chore: opt FredApps into public profile credit

* Menus keep clear of the taskbar when the window position is unknown (Wayland) (#3737)

On Wayland winit can't report a window's position, so egui has no
inner_rect/outer_rect, only the monitor size. work_area::visible_part
then returned the whole window, and fit_window never maximized an
oversized window, so long menus (Layer, View, Window...) were sized to
the full window and ran under a bottom panel. Without a position, cut
the window to the monitor size less the taskbar (the most that can be
visible wherever the window is), and use the content size as the
outer size for fit_window.

Replaces the scaffold test with one that fails on main.

---------

Signed-off-by: FredApps <[email protected]>
2026-10-12 04:33:54 -04:00
Giuseppe 986f977a01 Fix: video frames to layers working using folder dialog request (#3513) (#3718) 2026-10-12 04:33:48 -04:00
UnemarsandClaude Opus 5.5 986f68f969 Toolbar: the colour chips sit right under the tools again (#3745)
Since the shared docking migration (#3097) the tool column's scroll area
filled the toolbar's whole height, so a window taller than the tools
pushed the colour chips, Quick Mask and Screen Mode to the bottom.
Photoshop keeps them right under the last tool. The scroll area now
shrinks to its tools and only a column taller than the window scrolls,
with the chips kept at the bottom as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:33:36 -04:00
403e321034 Select: open the Save Selection and Load Selection dialogs (#3738)
The menu items ran the engine commands with no params: Save Selection
silently saved into a new alpha channel and Load Selection failed with
"missing `channel`". The form dialogs already existed in view_cmds but
the two ids were missing from wraps(), so they were never shown. Load
Selection now only offers the layer mask when the active layer has one.

Fixes #3713

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:33:25 -04:00
FredApps 4cf8a5fd05 Canvas: wheel and pinch zoom over the pasteboard keep the image in view (#3720) (#3731)
With the pointer on the gray pasteboard, wheel/Alt-wheel/pinch zoom anchored on the pointer,
so each notch slid the image away from it and out of the window. The anchor is now the
nearest point of the image (clamped in document space, so rotated and flipped views work);
over the image nothing changes.
2026-10-12 04:33:12 -04:00
MrCloudy2andClaude Opus 5.5 97c6769d6b Glows: Photoshop's precise ramp, and centre-source glows inverted after the contour (#3578)
Measured on Photoshop 27.11 (scripted: a 128 px square with one glow,
sampled pixel by pixel across its edges; outer and inner, both
techniques, sizes 10 and 20, spread 0/40 %, choke 0/30 %, Range 50 %):

- A Precise glow is 1 - D / (size + 1) beyond the solid spread/choke
  part, D being the centre-to-centre distance to the shape's edge.
  PhotoCraft's ramp ended at `size` and the default Range then doubled
  it, so it stayed solid for half the size and fell off steeply: up to
  122 levels off. The raw coverage is now 0.5 at the edge falling to 0
  at size + 1, so the shared Range step (v / range) gives Photoshop's
  ramp at the default 50 %; other Range values are unmeasured.
- A centre-source inner glow is exactly 1 - the edge glow, taken after
  the contour and Range. PhotoCraft inverted before them: the softer
  centre glow was 115 levels off, the precise one 64.

All 16 glow cases are now within 2 levels (10 were 58 to 122 off);
softer edge and outer glows are unchanged. On the GPU: the same ramp in
fs_mglow, and an "invert after the contour" mode (p0.y = 2) in
fs_mfinish for glows only (Satin keeps its own invert). The 44 GPU
parity tests pass. Corpus: the two Photoshop precise-glow oracles go
from 0.31 / 0.37 to 0.05 worst error (curved anti-aliased shapes,
still not under 2/255); nothing else changes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:32:33 -04:00
MrCloudy2andClaude Opus 5.5 78c611007b PSD: alpha channel resources count the merged transparency, 13-byte DisplayInfo (#3577)
Photoshop 27.11 refused two of PhotoCraft's re-exported corpus files
(ag-psd layer-mask/src.psd, psd-tools effects/stroke-without-vector-mask)
with "Could not import the clipboard because an unexpected end-of-file
was encountered". Both carry transparency in the merged image plus an
alpha channel: PhotoCraft wrote two extra channels but named and
described only one in resources 1006, 1045 and 1077. In every corpus
file that has them (177, Photoshop's included), those resources hold one
entry per extra channel, the merged transparency first.

- Export: a "Transparency" entry leads the names and DisplayInfo when
  the merged image carries transparency and there are alpha channels.
- DisplayInfo (1077) records are 13 bytes, as in all 166 corpus files
  that have one; PhotoCraft wrote and read 14 (the older 1007 layout),
  which shifts every channel after the first.
- Import skips the transparency's entry, so an alpha channel keeps its
  own name and options ("Alpha 1", not "Transparency").

Corpus oracles and round trips are unchanged (io 148/170, psd-tools
237/309, Photoshop 137/258; every round trip renders the same).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:22:10 -04:00
dac995ee6c Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (#3437) (#3590)
* Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (#3437)

In Skew mode a corner drag scaled the box and the side handles moved
freely. Checked in Photoshop: a corner moves alone, horizontally or
vertically (whichever way the drag mostly goes); the top and bottom
handles slide only horizontally and the left and right ones only
vertically; Option moves the opposite corner or edge the other way. On
editable type a corner slides its whole edge instead, so the text stays a
parallelogram and remains live type. Free Transform is unchanged.

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

* Skew mode: a drag outside the box doesn't rotate it; ⇧ changes nothing (#3437)

Checked in Photoshop. A click outside still commits.

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 04:18:33 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 8c6e4a3abc fix(engine): image.canvasSize rejects unlisted anchors instead of landing between two (#2946) (#3572)
anchor_factors resolved the x and y axes independently and with different matching
rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase
"top"/"bottom" prefix). A misspelling matched one rule and missed the other, so
"topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not
offer -- with no error.

Match the anchor exactly against the nine documented values via one ANCHORS table;
anything else is a BadParams error naming `anchor` and listing them, as #507 settled
for other command params. The nine valid values keep the exact factors they had, and an
omitted anchor still means center.

Test covers the nine offsets, the omitted default, and the misspellings from the issue
("topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE").

Fixes #2946.

Co-authored-by: photocraft-agent UNCONFIGURED <[email protected]>
2026-10-12 04:13:20 -04:00
Nigel Ihza Firdausy 84aa8df05a fix(transform): keep nearest-neighbor previews on original texels (#3688) 2026-10-12 04:10:55 -04:00
David MannandClaude Opus 5.5 b623db1ed7 Crop tool: a resized frame snaps its handle, not the pointer (#3638) (#3640)
With Auto Center Preview off, or in Classic Mode, dragging a crop edge or
corner snapped the pointer, which grabs the handle up to the 8 px grab
radius away from it. The snap line showed on the target while the frame
edge stopped as far from it as the grab offset, so recropping toward the
pixels a Delete Cropped Pixels-off crop kept left a transparent strip
along the top and sides (or cut a few pixels off).

The crop handle snap (snap_ui::crop_handle) now covers every resize: it
snaps the handle of the frame crop_ui would make and moves the pointer by
the difference, 1:1 without Auto Center Preview and holding the ratio on
a corner as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:10:50 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 55b3301384 test(ui): marquee shift+alt before the drag intersects the selection (#208) (#3624)
The marquee already picks the selection mode from the modifier held at the press
(shift adds, alt subtracts, shift+alt intersects) rather than constraining the shape.
marquee_tests.rs covered add and subtract but not intersect; this adds that case,
the one gap BUG-208-4 still names.

The test fixes an existing selection, holds shift+alt at the press (then releases so
the drag stays a plain rectangle, isolating the mode from the square/centre shape
constraints) and drags a second rectangle. It samples three points -- one in the
overlap, one only in the first rectangle, one only in the dragged rectangle -- so it
passes for intersect and fails for replace, add or subtract.

Test only; no production change.

Co-authored-by: photocraft-agent UNCONFIGURED <[email protected]>
2026-10-12 04:10:45 -04:00
Scott C Hughes 6ca04e5afa fix(ui): one eliding label for fixed-width chrome, so translations can't overrun (#3580) (#3616)
A translated label beside a fixed-width control could run over its neighbours: the Layers
Lock/Fill row guarded only the redundant "Lock:" caption, so "Fill:" ("Заливка:" in Russian,
longer still as pt-br "Preenchimento:" / fr "Remplissage :") overlapped the lock icons.

Rather than a per-row fit check, add the one shared primitive for fixed chrome:
`widgets::elided_label(ui, text, max_w)` lays the text out no wider than its room, cuts it with
'…' and puts the full string on hover (built on the existing `tab_strip::elided`). The
Opacity/Fill scrub labels now elide to the room beside their field, so any translation shrinks
instead of overlapping, in every locale.

Documented the rule in docs/ui-design.md: wrap by default; beside a fixed control, elide; a
redundant caption may drop on a narrow dock (the lock icons carry the meaning).

Test: widgets::tests::elided_label_never_exceeds_its_room. Full photocraft-ui-egui suite: 2077
pass; fmt and clippy clean.
2026-10-12 04:10:40 -04:00
Valentin KleinandClaude Opus 5.5 c7c6031254 Options bar: the Gradient tool's controls show in every theme (#3601) (#3607)
Studio, Studio Light and Classic showed only the hint 'Drag to draw a
gradient'. The Gradient arm was gated by t.pro; it now runs in every theme.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:10:35 -04:00
Reynold Hu d2b5b144aa test(automation): six rounding-out acceptance tasks (31-36) (#3568) 2026-10-12 04:03:30 -04:00
Nigel Ihza Firdausy c5a9e7f9b2 fix(ui): don't reopen floating Brush Settings on restart (#3570) 2026-10-12 04:03:07 -04:00
Reynold Hu 857a4f7d8a M11: ten more MCP acceptance tasks (11-20) for FILE-216-6 (#3562)
* test(automation): ten more MCP acceptance tasks (11-20)

* docs(scorecard): FILE-216-6 note counts 20 tasks
2026-10-12 04:00:30 -04:00
Atos LinsandBrandon Thomas da3da0942c Linux: StartupWMClass is the window's app id (#3481)
* Linux: StartupWMClass is the window's app id

The desktop entry said StartupWMClass=photocraft, but the window carries
the app id ai.storyteller.photocraft (APP_ID) on Wayland and under X11
alike, so nothing that matches a window to its launcher by that key ever
matched it. VectorCraft, GridCraft and WordCraft already use the app id.

A test reads the entry and compares it with APP_ID.

* Contributors: add atoslins (Atos Lins)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 03:28:09 -04:00
dbca08cd20 Add Vietnamese localization (#2636)
Adds the Vietnamese (vi) UI translation and turns on its coverage gate. A maintainer pass merged main, added 1,029 rows from @tonguyenducmanh's catalog (#3453) and 33 new ones, and set complete_menus.

Co-authored-by: tonguyenducmanh <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 03:05:18 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 649b1d5d92 psd: harden the standalone binary parsers (fuzz + property tests, gradient/swatch correctness) (#3542)
* psd: harden the standalone binary parsers (gradients, swatches, patterns, filter effects, metadata, resources, tagged blocks)

Correctness and robustness work on the psd crate's auxiliary parsers, with
fuzz targets and property tests so the invariants are enforced, not just asserted.

grd (gradients):
- Split num()/finite() into a single guard with a saturating f64->f32 cast: only
  NaN has no value, everything else (including +/-inf and 1e300) saturates to the
  representable range so the mapping stays monotonic instead of snapping to a field
  default and inverting a hostile value.
- Hue now wraps with rem_euclid(360) instead of clamping (370 -> 10, not 370 -> 360,
  which would be a different colour at the boundary).
- Every colour model is bounded: RGB/CMYK/Gray to 0..=1, L* to 0..=100 and a*/b* to
  the CIELAB gamut (-128..=127), so a downstream converter never sees out-of-gamut input.

aco/ase (swatches):
- Share one io::utf16_units() helper that caps a name by UTF-16 code units without
  ever splitting a surrogate pair; keeping it in one place stops the fix from drifting
  between the two formats.

Coverage:
- New fuzz targets for grd, aco, ase, patterns, filter_effects, metadata, tagged and
  resources (wired into the fuzz CI matrix).
- Property tests mirroring each fuzz invariant (round-trip stability, no panic on
  arbitrary bytes, documented colour/number ranges) so normal CI covers them; the
  fuzz jobs run on demand.

369 psd tests pass; clippy -D warnings and rustfmt clean; layering and wasm check pass;
all psd fuzz targets build and run clean.

* psd: address Gemini/Claude review of the parser hardening

Follow-up to the previous commit, applying the substantive points from two
independent code reviews (Gemini 3.8: ship; Claude: ship after small fixes).

Production:
- grd hue: an in-range value (0..=360, including exactly 360) is now kept verbatim;
  only an out-of-range hue wraps with rem_euclid. Wrapping unconditionally altered a
  legal 360 to 0 (and f32 round-off on a tiny negative could itself land on 360),
  changing the colour at the boundary for anything that interpolates in HSB.
- ase: compile-time assert that MAX_NAME_UNITS < u16::MAX, since put_name writes the
  length as `units.len() as u16 + 1`; guards the invariant if the cap is ever raised.

Stronger test/fuzz invariants (byte stability alone would pass even if the writer
silently dropped a field; these assert semantic equality):
- metadata fuzz: assert the parsed-then-written block re-parses to the same items
  (the weakened byte-only check was the fix the first fuzz run surfaced).
- patterns fuzz: assert parse(write(parse(x))) == parse(x); channel offsets/padding
  are wire artifacts, not fields, so the round trip is lossless (confirmed, 2M execs).
- filter_effects fuzz: assert back == fx as well as byte stability; also decode every
  cached slot, not just the mask.
- resources fuzz: round-trip ResolutionInfo through to_bytes/from_bytes.

Coverage:
- tagged fuzz: select the block key from a table of every typed/strict key via the
  first byte, so the fuzzer reaches each branch instead of guessing a 4-byte key.
- new property tests: damage-a-valid-block for metadata/patterns/filter_effects (pure
  random bytes rarely pass a header), and check_structure over every structural key.
- grd_every_colour_model_is_clamped_and_finite now pins exact saturation values
  (a*/b* to +/-128, L* to 0/100, units to 0/1), so a weaker clamp is caught, not just
  an out-of-range value; grd_hue_wraps now pins the exact-360 and 0 boundaries.

Verified: 371 psd tests pass; clippy -D warnings, rustfmt, layers and wasm clean; all
13 fuzz targets build and run clean (metadata 11.5M, patterns 2M, filter_effects 5.9M,
resources 3.5M, tagged 5.4M execs); the a*/b* clamp-weakening and hue-guard-removal
mutants are both killed by the strengthened tests.

---------

Co-authored-by: photocraft-agent UNCONFIGURED <[email protected]>
2026-10-12 02:49:08 -04:00
Valentin KleinandClaude Opus 5.5 35b5b8ceb0 Format: index ZIP entries by name and use crc32fast (#3474) (#3488)
ZipReader::find scanned every entry per lookup, and a bundle load looks
up one entry per tile reference, so loads were O(refs x entries). Build
a name -> index map once in ZipReader::new (first entry wins for
duplicate names, as the scan did) and look names up through it.

crc32 now delegates to crc32fast (already in Cargo.lock via flate2)
instead of a byte-at-a-time table loop.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:42:40 -04:00
Dan HiornsandClaude Opus 5.5 20cb95bc27 Windows installer: refuse Windows 7 and 8 with a clear message (#2310) (#3261)
The Rust standard library has needed Windows 10 since 1.78, so on
Windows 7 and 8 PhotoCraft fails at launch with "The procedure entry
point ProcessPrng could not be located in the dynamic link library
bcryptprimitives.dll" (#2310). The MSI installed anyway.

It now has a launch condition: a registry search sets WINDOWS10ORLATER
from HKLM\...\Windows NT\CurrentVersion\CurrentMajorVersionNumber,
which exists only from Windows 10 on (in the 32- and 64-bit views), and
without it the installer stops with "PhotoCraft needs Windows 10 or
later." Repair and uninstall still run (Installed). MSI's VersionNT
isn't used: it has reported both 603 and 1000 on Windows 10.

The README's Windows downloads now say Windows 10 or later is required;
the portable zip can't check, as Windows rejects the exe before any of
its code runs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:26:36 -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
skylar taylor-barrick 83fcbe7d9b Merge current main while preserving hardened print contracts through craft-print 2026-10-12 07:36:25 +02: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
skylar taylor-barrick e3d118e0f3 Separate print queue status from generic cancellable progress controls 2026-10-12 05:53:56 +02: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
skylar taylor-barrick 0467f715c6 Reuse upstream counted catalogs to prevent duplicate keys in PR merge 2026-10-12 05:48:59 +02:00
skylar taylor-barrick 8c557fcb7d Place identical browser engine profile beside upstream override 2026-10-12 05:47:22 +02: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 67ca74fe18 Align overlapping CI repairs with upstream while retaining regression coverage 2026-10-12 05:44:48 +02: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
skylar taylor-barrick 82d2b7e0f1 Fill missing plural statuses in coverage-enforced catalogs 2026-10-12 04:54:47 +02: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
skylar taylor-barrick a538567a0d Read plural keys from form arguments and report all catalog gaps 2026-10-12 04:49:59 +02:00
skylar taylor-barrick 6651724db8 Align CLI save test with native destination filename semantics 2026-10-12 04:35:18 +02: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
skylar taylor-barrick 3a06871d69 Keep print queue translations alongside background job controls 2026-10-12 04:11:22 +02:00
skylar taylor-barrick 055e4fee1b Merge remote-tracking branch 'upstream/main' into codex/photocraft-craft-print-sync
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-12 04:10:55 +02: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
skylar taylor-barrick ce48721c6b Translate the system print queue guidance in every UI language 2026-10-12 04:04:07 +02:00
skylar taylor-barrick cf98b91f7b Remove duplicate Czech catalog rows while retaining active translations 2026-10-12 04:03:54 +02: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
skylar taylor-barrick 9db0b30910 Keep browser command orchestration within the existing size budget 2026-10-12 03:53:52 +02: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
skylar taylor-barrick f65f7cc9eb Correct stale HDR Levels test endpoints 2026-10-12 03:24:40 +02: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
skylar taylor-barrick 4bb6ffc46f Record background print hooks as one command 2026-10-12 02:58:32 +02:00
skylar taylor-barrick cc902c681b Report refused print cancellation accurately to automation 2026-10-12 02:56:10 +02:00
skylar taylor-barrick b8ea8206cc Refresh document views after completed print hooks 2026-10-12 02:51:42 +02: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
skylar taylor-barrick 223e9499c3 Keep keyboard shortcut parser available in browser builds 2026-10-12 02:46:08 +02: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
skylar taylor-barrick c6407cac07 Keep scorecard test reference current 2026-10-12 02:34:03 +02:00
skylar taylor-barrick bb46f846d9 Keep print completion tied to its document and hold quit 2026-10-12 02:32:26 +02:00
skylar taylor-barrick 698c0f554d Format printing completion regression 2026-10-12 02:29:59 +02: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
skylar taylor-barrick ccee4894bd Use craft-print for PhotoCraft system printing 2026-10-12 02:28:40 +02: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
moiztermindandClaude Sonnet 5 f9b3c6bd85 winprint: draw crop and registration marks on GDI prints
Printer marks (corner/centre crop, registration) were silently dropped on
Windows GDI prints -- only the PDF export path drew them, as content-stream
vector operators. A workshop test print surfaced the gap.

Adds a Marks type to photocraft-winprint and draws the same geometry GDI's
way (MoveToEx/LineTo for lines, Ellipse for registration circles) once per
page between StretchDIBits and EndPage. The JSON field marksSkipped becomes
textSkipped: description and label text are still PDF-only (no font layout
in the GDI path yet).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-11 23:19:28 +01:00
MoiztermindandClaude Opus 5.5 ce8d8d950b winprint: let the driver validate settings; never print on defaults silently
The settings check compared the printer name stored in the DEVMODE with
the queue name. Windows keeps only 31 characters of it, so a long queue
name ("Sublimation - EPSON ET-18100 Series") failed the check and the
chosen paper type and quality were silently dropped: the job printed on
the driver defaults (plain paper).

Settings now go through DocumentProperties(DM_IN_BUFFER | DM_OUT_BUFFER)
before CreateDC, so the driver validates them itself, and a refusal is
an error instead of a silent fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:19:07 +01:00
MoiztermindandClaude Opus 5.5 628a54b564 Print on Windows: printer list, driver Print Settings, GDI printing
- photocraft-winprint: an isolated unsafe crate (like photocraft-tablet)
  for Windows printing: printer list, the driver's Print Settings dialog,
  page geometry, and GDI printing with ICM off.
- file.print prints on Windows (explicit "send": true only); new
  file.print.printers and file.print.page commands; printerSettings param.
- Print dialog on Windows: Printer menu, Print Settings…, paper from the
  driver.
- panic_hunt fuzzes print commands as dry runs.

Closes #2697.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:18:58 +01: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
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
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
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
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
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
1304 changed files with 323931 additions and 17001 deletions

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
# keeps working (#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'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
# at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
+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
+131 -34
View File
@@ -17,6 +17,10 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
# bypasses that, lp fails loudly on a printer that does not exist.
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
@@ -30,14 +34,17 @@ concurrency:
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 all three.
# Other PRs get Linux, plus macOS and Windows when they touch code whose behaviour differs by
# 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:
timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
os: ${{ steps.pick.outputs.os }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
with:
@@ -48,11 +55,22 @@ jobs:
BASE: ${{ github.event.pull_request.base.sha }}
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
run: |
all='["ubuntu-latest","macos-latest","windows-latest"]'
# 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(-boot)?/|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
@@ -60,6 +78,7 @@ jobs:
fi
test:
timeout-minutes: 100
name: test (${{ matrix.os }})
needs: platforms
runs-on: ${{ matrix.os }}
@@ -94,40 +113,12 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
- 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 --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 --features heif -- -D warnings
@@ -140,7 +131,53 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
# Separate native commands into steps so Windows reports either command's failure.
- name: Test
run: cargo nextest run --workspace --locked --features heif --profile ci
- name: Doctests
run: cargo test --doc --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 nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
# not what is built); engine and the apps only pass text on to them (#6120).
- 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 nextest run --workspace --locked --features heif --profile ci
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
@@ -173,6 +210,7 @@ jobs:
run: cargo xtask test-corpus
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
@@ -188,7 +226,56 @@ jobs:
- name: Check wasm-safe crates
run: cargo xtask wasm
web:
timeout-minutes: 60
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 builds, validates relative URLs, cuts the wasm into
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
- 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 "| --- | ---: | ---: | ---: |"
row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
# The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
for wasm in dist/web/*.wasm; do
[ -f "$wasm" ] || continue
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
done
# What a host sees: the largest file of the packaged site.
largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
} >> "$GITHUB_STEP_SUMMARY"
fuzz:
timeout-minutes: 60
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
# Placeholder: manual trigger only until a nightly schedule is enabled.
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
@@ -200,12 +287,22 @@ jobs:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: psd, name: grd }
- { crate: psd, name: ase }
- { crate: psd, name: aco }
- { crate: psd, name: patterns }
- { crate: psd, name: filter_effects }
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { 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
@@ -217,7 +314,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
+1
View File
@@ -27,6 +27,7 @@ concurrency:
jobs:
build:
timeout-minutes: 15
name: mdBook build
runs-on: ubuntu-latest
steps:
+8 -42
View File
@@ -19,47 +19,13 @@ 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:
timeout-minutes: 10
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 packaging/linux/AppRun
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.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
+23 -401
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,405 +11,33 @@ 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 }}
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
# embeds (today the Japanese fonts). An optional build input for everyone else; see
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
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-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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 }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
- name: Verify signatures and notarization
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/verify.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 }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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 }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
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 }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
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: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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 zsync
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)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
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
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
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: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- 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
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
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
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
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"
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
- 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 }}
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 }}
+6 -66
View File
@@ -22,70 +22,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: photocraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: photocraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'PhotoCraft'
foreach ($exe in 'photocraft.exe', 'photocraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'photocraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok photocraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'photocraft.exe')) { throw 'uninstall left photocraft.exe behind' }
Write-Output 'ok uninstalled'
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
+1
View File
@@ -10,3 +10,4 @@ target/
/book/output
/craft-fonts
*.log
__pycache__/
+19 -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, 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, 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` |
@@ -35,6 +39,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
@@ -70,15 +75,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 +96,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 +105,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,13 +125,13 @@ 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.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
- `log/` (local, gitignored): the dev log.
-
## 8. Keeping the native format complete
+35 -2
View File
@@ -10,22 +10,53 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
| `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/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | 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) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian 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/vi.tsv` | Vietnamese 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/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `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`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (110 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`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (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/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | 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/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc 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 |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
### Optional build input: craft-fonts (not files in this repo)
@@ -81,6 +112,8 @@ next to the files:
| `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/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, 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), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | 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
+2217 -64
View File
File diff suppressed because it is too large. Load diff
+50 -8
View File
@@ -4,12 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# 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" }
@@ -36,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -43,9 +46,14 @@ 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"
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
@@ -65,6 +73,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,14 +84,14 @@ 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
# 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.
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
# 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.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
opt-level = "z"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
@@ -87,6 +99,35 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Decoding and rasterizing keep their speed.
[profile.wasm-release.package.photocraft-codecs]
opt-level = "s"
[profile.wasm-release.package.photocraft-raw]
opt-level = "s"
[profile.wasm-release.package.photocraft-heif]
opt-level = "s"
[profile.wasm-release.package.photocraft-vector]
opt-level = "s"
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
[profile.wasm-release.package.photocraft-doc]
opt-level = "z"
[profile.wasm-release.package.photocraft-psd]
opt-level = "z"
[profile.wasm-release.package.photocraft-format]
opt-level = "z"
[profile.wasm-release.package.photocraft-affinity]
opt-level = "z"
[profile.wasm-release.package.serde_json]
opt-level = "z"
[profile.wasm-release.package.usvg]
opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
@@ -99,3 +140,4 @@ opt-level = 3
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+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
+14 -8
View File
@@ -122,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>
@@ -133,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>
@@ -178,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 · Magnetic 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>
@@ -187,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, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
</td>
</tr>
<tr>
@@ -199,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, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), 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 AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), 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>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</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>
@@ -277,6 +277,8 @@ 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. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
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
@@ -296,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).
@@ -346,6 +350,8 @@ Installers and executables are code-signed.
| 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 |
+143
View File
@@ -0,0 +1,143 @@
# 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-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; 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 953 issues are open (2026-10-13), 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 145 → 200+ of 258 | 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-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 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` |
+206 -20
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,20 +15,27 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
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.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
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>] [--quality <1-100>] [--tiff-layers]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
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>] [--quality <1-100>] [--in-place] [--tiff-layers]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON 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 --out folder that is the
--in folder is refused, as the results would replace the originals; --in-place allows it.
(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>]
@@ -39,7 +47,7 @@ USAGE:
[--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, jobs.list/cancel, engine.commands,
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
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
@@ -61,10 +69,20 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-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"], bare: &["--tiff-layers"], run: run_cmds },
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], bare: &["--in-place", "--tiff-layers"], run: batch },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-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 {
@@ -194,6 +212,13 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
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"))?);
}
if let Some(b) = a.get("--dpx-bits") {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
Ok(o)
}
@@ -221,7 +246,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&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(())
@@ -273,11 +298,16 @@ 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();
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@@ -289,9 +319,11 @@ 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}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
@@ -320,6 +352,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if let Some(o) = a.get("--out") {
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();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
@@ -330,7 +363,71 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
/// 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}"))?;
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
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())),
};
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => {
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
}
(None, [only]) => only,
(None, []) => return Err(format!("{path}: the set holds no actions")),
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
@@ -340,7 +437,10 @@ fn same_dir(a: &Path, b: &Path) -> bool {
}
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" | "kra" | "krz" | "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()))
}
@@ -348,8 +448,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
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, warnings) = load_actions_file(actions_path, a.get("--action"))?;
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
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).
@@ -375,9 +477,19 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
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())
@@ -429,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
})
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
@@ -545,6 +655,31 @@ mod missing_font_warning_tests {
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)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
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()));
@@ -566,3 +701,54 @@ mod missing_font_warning_tests {
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);
}
}
+193 -2
View File
@@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() {
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
for needle in ["ellipse", "marquee"] {
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
assert!(out.contains("select.rect"), "{needle}: {out}");
}
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
@@ -136,7 +141,8 @@ 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);
@@ -188,9 +194,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.
@@ -200,6 +208,50 @@ fn run_commands_and_save() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_stops_after_failed_action_without_exporting() {
let d = tmp("run-failed-action");
let out = d.join("result.png");
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "layer.new.layer"])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out)
.output()
.unwrap();
let error = String::from_utf8_lossy(&result.stderr);
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
assert!(!out.exists(), "a failed action must not export a partial result");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
assert!(playback["result"].get("failed").is_none());
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
assert!(out.is_file());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
@@ -220,6 +272,24 @@ fn run_errors() {
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]
fn batch_applies_actions_to_directory() {
let d = tmp("batch");
@@ -245,6 +315,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();
@@ -359,6 +460,66 @@ fn quality_outside_1_to_100_is_refused() {
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
let out = d.join("a.dpx");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--dpx-bits", b]);
}
ok(&mut convert);
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
assert!(back.exists());
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
}
}
/// `--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]
@@ -562,3 +723,33 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
assert!(!err.contains("panicked"), "{err}");
}
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
/// PhotoCraft can't play are warned about up front.
#[test]
fn batch_plays_a_photoshop_actions_file() {
use photocraft_engine::actions_cmds::Action;
let d = tmp("atn");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
assert!(skipped.is_empty());
let atn = d.join("Set.atn");
std::fs::write(&atn, bytes).unwrap();
let out_dir = d.join("out");
// Two actions: the set needs --action.
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
+7 -2
View File
@@ -125,14 +125,19 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
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 must match.
// 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 (ia, ib) = (info(&a), info(&b));
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));
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "photocraft-web-boot"
description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "photocraft-web-boot"
path = "src/main.rs"
[dependencies]
# Inflates the parts in browsers without `DecompressionStream`.
flate2 = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+6
View File
@@ -0,0 +1,6 @@
# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
[build]
target = "index.html"
dist = "../../dist/web-boot"
public_url = "./"
filehash = true
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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-boot" data-cargo-profile="wasm-release" data-wasm-opt="z" data-wasm-opt-params="--converge" 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; 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">
<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>
+356
View File
@@ -0,0 +1,356 @@
//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
//!
//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
//! loader, so the browser still compiles while downloading. No file on the host is larger than
//! one part, however large the app grows.
//!
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
//! In one that can't, this module inflates them itself, a little slower.
//!
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
//!
//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
//! for hosts without a per-file cap; only the packaged site starts through this module.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
/// without this an HTML page would be read as a list of parts.
#[cfg(any(target_arch = "wasm32", test))]
const MARKER: &str = "photocraft-parts 1";
/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
/// file next to `parts.txt`: anything that could point elsewhere is refused.
#[cfg(any(target_arch = "wasm32", test))]
fn parts(list: &str) -> Option<Vec<String>> {
let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
if lines.next() != Some(MARKER) {
return None;
}
let parts: Vec<String> = lines.map(str::to_owned).collect();
let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
#[cfg(any(target_arch = "wasm32", test))]
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
#[cfg(any(target_arch = "wasm32", test))]
impl Inflater {
fn new() -> Self {
Inflater(flate2::write::GzDecoder::new(Vec::new()))
}
/// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
/// to input until it has a whole block).
fn push(&mut self, chunk: &[u8]) -> std::io::Result<Vec<u8>> {
std::io::Write::write_all(&mut self.0, chunk)?;
Ok(std::mem::take(self.0.get_mut()))
}
/// The rest, once the stream has ended. An error when it ended early or its checksum is
/// wrong, the same as the browser's inflater gives.
fn finish(&mut self) -> std::io::Result<Vec<u8>> {
self.0.try_finish()?;
Ok(std::mem::take(self.0.get_mut()))
}
}
#[cfg(target_arch = "wasm32")]
mod boot {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
TransformStreamDefaultController, Transformer, WritableStream,
};
use super::Inflater;
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
/// Seconds to wait before each further try; one more than this many tries are made.
const RETRY_AFTER: [u32; 2] = [1, 3];
// The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
// runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
// name (Trunk's own name for it carries a content hash).
#[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
extern "C" {
#[wasm_bindgen(js_name = default, catch)]
async fn start_app(module: JsValue) -> Result<JsValue, JsValue>;
}
// The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
// have to be set for the whole build, so the three members used are declared here.
#[wasm_bindgen]
extern "C" {
type DecompressionStream;
#[wasm_bindgen(constructor, catch)]
fn new(format: &str) -> Result<DecompressionStream, JsValue>;
#[wasm_bindgen(method, getter)]
fn readable(this: &DecompressionStream) -> ReadableStream;
#[wasm_bindgen(method, getter)]
fn writable(this: &DecompressionStream) -> WritableStream;
}
fn window() -> Result<web_sys::Window, JsValue> {
web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
}
/// `fresh` skips the browser's cache.
async fn fetch(url: &str, fresh: bool) -> Result<Response, JsValue> {
let init = RequestInit::new();
if fresh {
init.set_cache(RequestCache::Reload);
}
let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
if !response.ok() {
return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
}
if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
}
Ok(response)
}
/// Copies each part into `sink` in order, then closes it.
async fn feed(parts: Vec<String>, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
let keep_open = StreamPipeOptions::new();
keep_open.set_prevent_close(true);
for part in &parts {
let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
}
JsFuture::from(sink.close()).await?;
Ok(())
}
/// Passes inflated bytes on to the stream's reader.
fn emit(inflated: std::io::Result<Vec<u8>>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
}
/// A gzip inflater with the shape of `DecompressionStream`, running in this module.
fn own_inflater() -> Result<TransformStream, JsValue> {
let inflater = Rc::new(RefCell::new(Inflater::new()));
let last = inflater.clone();
let busy = || JsValue::from_str("the inflater is in use");
let transform = Closure::<dyn FnMut(JsValue, TransformStreamDefaultController) -> Result<(), JsValue>>::new(
move |chunk: JsValue, out: TransformStreamDefaultController| {
let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
},
);
let flush = Closure::<dyn FnMut(TransformStreamDefaultController) -> Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
});
let transformer = Transformer::new();
transformer.set_transform(&transform.into_js_value().unchecked_into());
transformer.set_flush(&flush.into_js_value().unchecked_into());
TransformStream::new_with_transformer(&transformer)
}
/// `compressed` inflated: by the browser where it can, otherwise here.
fn inflated(compressed: &ReadableStream) -> Result<ReadableStream, JsValue> {
let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
let inflater = DecompressionStream::new("gzip")?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
} else {
let inflater = own_inflater()?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
};
Ok(compressed.pipe_through(&pair))
}
async fn run(fresh: bool) -> Result<(), JsValue> {
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
let joined = TransformStream::new()?;
let wasm = inflated(&joined.readable())?;
// Why the download stopped: the app's loader only reports that its stream broke.
let cause = Rc::new(RefCell::new(None));
let failed = cause.clone();
let sink = joined.writable();
spawn_local(async move {
if let Err(e) = feed(parts, sink.clone(), fresh).await {
let _ = sink.abort_with_reason(&e);
failed.replace(Some(e));
}
});
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
init.set_headers(&headers);
let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
let arg = js_sys::Object::new();
js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
match start_app(arg.into()).await {
Ok(_) => Ok(()),
Err(e) => Err(cause.take().unwrap_or(e)),
}
}
async fn sleep(seconds: u32) {
let wait = js_sys::Promise::new(&mut |done, _| {
let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
let _ = done.call0(&JsValue::NULL);
}
});
let _ = JsFuture::from(wait).await;
}
/// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
fn say(text: &str, reload: bool) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
loading.set_text_content(None);
let Ok(line) = document.create_element("p") else { return };
line.set_text_content(Some(text));
if reload && let Ok(link) = document.create_element("a") {
link.set_text_content(Some("Reload"));
let _ = link.set_attribute("href", "");
let _ = line.append_child(&link);
}
let _ = loading.append_child(&line);
}
pub fn start() {
spawn_local(async {
let mut attempt = 0;
loop {
let error = match run(attempt > 0).await {
Ok(()) => return,
Err(e) => e,
};
web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
let Some(&wait) = RETRY_AFTER.get(attempt) else {
say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
return;
};
say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
sleep(wait).await;
attempt += 1;
}
});
}
}
#[cfg(target_arch = "wasm32")]
fn main() {
boot::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
}
#[cfg(test)]
mod tests {
use super::{Inflater, MARKER, parts};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut z, bytes).expect("gzip");
z.finish().expect("gzip")
}
/// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
fn sample() -> Vec<u8> {
let mut x = 0x2545_f491_4f6c_dd1d_u64;
(0..200_000)
.map(|i| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
})
.collect()
}
/// The parts arrive in whatever pieces the network gives; the inflater must not care where
/// they are cut.
#[test]
fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
let (plain, packed) = (sample(), gzip(&sample()));
for cut in [1, 7, 4096, 65_536, packed.len()] {
let mut inflater = Inflater::new();
let mut out = Vec::new();
for chunk in packed.chunks(cut) {
out.extend(inflater.push(chunk).expect("push"));
}
out.extend(inflater.finish().expect("finish"));
assert_eq!(out, plain, "chunks of {cut}");
}
}
/// A download that stops early, or whose bytes changed on the way, must fail (the boot module
/// then tries again), never start a damaged app.
#[test]
fn the_inflater_refuses_a_short_or_damaged_stream() {
let packed = gzip(&sample());
let run = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
let mut inflater = Inflater::new();
let mut out = inflater.push(bytes)?;
out.extend(inflater.finish()?);
Ok(out)
};
assert!(run(&packed).is_ok());
assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
let mut damaged = packed.clone();
let middle = damaged.len() / 2;
damaged[middle] ^= 0x55;
assert!(run(&damaged).is_err(), "a changed byte");
assert!(run(b"<!DOCTYPE html><html>not gzip</html>").is_err(), "a web page");
assert!(run(b"").is_err(), "nothing at all");
}
#[test]
fn a_parts_list_gives_its_names_in_order() {
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
}
/// A host that answers a missing file with its home page must not have that page read as a
/// list of parts.
#[test]
fn a_web_page_is_not_a_parts_list() {
assert_eq!(parts(include_str!("../index.html")), None);
assert_eq!(parts(""), None);
assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
}
/// Part names are joined onto `./app/`, so one that could leave that folder, or name another
/// origin, refuses the whole list.
#[test]
fn a_part_name_cannot_point_outside_the_app_folder() {
for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
}
}
/// The packaged site shows this crate's page, the development build the app's own. They are
/// the same page apart from which program the `<link data-trunk>` line builds.
#[test]
fn the_page_is_the_apps_page() {
let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::<Vec<_>>().join("\n");
let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
assert_eq!(trunk(boot), trunk(app));
assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
}
}
+7 -2
View File
@@ -29,9 +29,14 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
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 }
+1 -1
View File
@@ -4,7 +4,7 @@
<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-cargo-features="heif" 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-wasm-opt-params="--converge" 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; }
+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
}
+6
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;
+390
View File
@@ -0,0 +1,390 @@
//! 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());
assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner");
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"));
}
}
+317 -38
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, Krita 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", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "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,48 +299,84 @@ 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) {
// Only while a preset change is unsaved. Storage the browser denies from the start (a
// sandboxed iframe, blocked site data) loses nothing until the first change, and that
// change brings the banner with the reason.
if self.presets.unsaved()
&& let Some(error) = self.presets.error()
{
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
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.")
});
});
}
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 };
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
photocraft_io::export(doc, path, &settings.io_options()).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);
}),
// Browser folder handles cannot be represented as filesystem paths for engine commands.
FileDialogRequest::Folder { .. } => reply.send(None),
// 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| {
@@ -224,6 +432,77 @@ 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",
Some("kra" | "krz") => "application/x-krita",
_ => "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 }
}))
}
+27 -6
View File
@@ -29,7 +29,7 @@ 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", "persistence"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -42,6 +42,9 @@ open = "5"
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.
@@ -52,20 +55,38 @@ fmv-macos-events = "=0.1.0"
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): Tauri's menu crate, AppKit menus behind a safe API (no GTK
# with default features off). Pinned exactly: review the diff before bumping.
# 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]
x11rb = { version = "=0.13.2", default-features = false }
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
wl-clipboard-rs = { version = "=0.9.4", default-features = false }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
resvg = { version = "=0.45.1", default-features = false }
# 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"] }
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
# its own. Pinned exactly: review the diff before bumping.
# 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.
+35
View File
@@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData {
}
}
/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
/// replaced it with a flat picture (#5849). `exe` is the running executable.
pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
fn in_app_bundle(exe: &std::path::Path) -> bool {
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
let macos = exe.parent();
let contents = macos.and_then(std::path::Path::parent);
let bundle = contents.and_then(std::path::Path::parent);
name(macos).as_deref() == Some("MacOS")
&& name(contents).as_deref() == Some("Contents")
&& name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
}
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
@@ -45,6 +64,22 @@ mod tests {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
/// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
/// Linux and Windows builds) still set the runtime icon.
#[test]
fn only_a_macos_app_bundle_skips_the_runtime_icon() {
use std::path::Path;
assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
assert!(!in_app_bundle(Path::new("photocraft")));
assert!(sets_runtime_icon(None));
assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+221
View File
@@ -0,0 +1,221 @@
//! 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")]
pub(crate) 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()?;
portal_code(value).and_then(decode).or_else(gtk_theme)
}
#[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,
}
}
/// GNOME fallback when the portal has no preference. Read only on the appearance worker,
/// using an absolute path rather than the shell's PATH.
#[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" | "default" => 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).or_else(gtk_theme));
}
})
}
#[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));
// GNOME's "default" appearance is light, even though the portal reports no preference.
assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), 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
}
}
+399
View File
@@ -0,0 +1,399 @@
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
//!
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
#[cfg(target_os = "linux")]
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Desktop {
Gnome,
Kde,
Other,
}
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
/// the like count as GNOME.
#[cfg(target_os = "linux")]
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
return if v.contains("gnome") {
Desktop::Gnome
} else if v.contains("kde") || v.contains("plasma") {
Desktop::Kde
} else {
Desktop::Other
};
}
if var("KDE_FULL_SESSION").is_some() {
Desktop::Kde
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
Desktop::Gnome
} else {
Desktop::Other
}
}
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
#[cfg(target_os = "linux")]
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
match desktop {
Desktop::Gnome => CaptionFamily::Adwaita,
Desktop::Kde => CaptionFamily::Breeze,
Desktop::Other => {
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
CaptionFamily::Adwaita
} else if theme.starts_with("breeze") {
CaptionFamily::Breeze
} else {
CaptionFamily::Generic
}
}
}
}
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
#[cfg(target_os = "linux")]
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
let mut inside = false;
for line in text.lines().map(str::trim) {
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
inside = name == section;
} else if inside
&& let Some((k, v)) = line.split_once('=')
&& k.trim() == key
{
return Some(v.trim().to_string());
}
}
None
}
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
/// defaults: the window menu on the left, minimize, maximize and close on the right.
#[cfg(target_os = "linux")]
fn kwin_layout(kwinrc: &str) -> String {
let side = |key: &str, default: &str| {
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
letters
.chars()
.filter_map(|c| match c {
'I' => Some("minimize"),
'A' => Some("maximize"),
'X' => Some("close"),
_ => None,
})
.collect::<Vec<_>>()
.join(",")
};
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
}
/// What the style is worked out from.
#[cfg(target_os = "linux")]
#[derive(Clone, Default)]
struct Found {
gtk_theme: Option<String>,
/// A GTK decoration layout.
layout: Option<String>,
icon_theme: Option<String>,
}
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
/// the desktop's setting, or its default theme.
#[cfg(target_os = "linux")]
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
match var("PHOTOCRAFT_CAPTION_ICONS") {
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
Some(name) => Some(name.trim().to_string()),
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
}
}
/// The style from what was found; `icons` loads a theme's glyphs.
#[cfg(target_os = "linux")]
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
// GNOME's own default shows Close only; GTK's shows all three.
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
style
}
#[cfg(target_os = "linux")]
fn read_text(path: &std::path::Path) -> Option<String> {
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
}
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
#[cfg(target_os = "linux")]
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
let config = var("XDG_CONFIG_HOME")
.filter(|d| std::path::Path::new(d).is_absolute())
.map(std::path::PathBuf::from)
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
let Some(config) = config else { return Found::default() };
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
let gtk_theme = ini_value(&gtk, "Settings", "gtk-theme-name");
if desktop == Desktop::Kde {
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
Found { gtk_theme, layout, icon_theme }
} else {
let layout = ini_value(&gtk, "Settings", "gtk-decoration-layout");
Found { gtk_theme, layout, icon_theme: ini_value(&gtk, "Settings", "gtk-icon-theme-name") }
}
}
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
#[cfg(target_os = "linux")]
fn load_icons(theme: &str) -> Option<CaptionIcons> {
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
if files.iter().all(Option::is_none) {
return None;
}
let render = move |icon: CaptionIcon, px: u32| {
let i = match icon {
CaptionIcon::Minimize => 0,
CaptionIcon::Maximize => 1,
CaptionIcon::Restore => 2,
CaptionIcon::Close => 3,
};
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
};
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
}
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
#[cfg(target_os = "linux")]
impl IconCache {
fn get(&self, theme: &str) -> Option<CaptionIcons> {
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((name, icons)) = last.as_ref()
&& name == theme
{
return icons.clone();
}
let icons = load_icons(theme);
*last = Some((theme.to_string(), icons.clone()));
icons
}
}
#[cfg(target_os = "linux")]
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
#[cfg(target_os = "linux")]
const LAYOUT_KEY: &str = "button-layout";
#[cfg(target_os = "linux")]
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
#[cfg(target_os = "linux")]
const ICONS_KEY: &str = "icon-theme";
/// A string setting from the portal, unwrapping the variants some portals nest it in.
#[cfg(target_os = "linux")]
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
_ => return None,
}
}
None
}
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
/// work and wakes the UI only when the style changes; the first answer usually lands before the
/// first frame.
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
let var = |k: &str| std::env::var(k).ok();
let desktop = desktop(&var);
let mut found = from_files(desktop, &var);
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (shared, waker) = (Arc::clone(&current), Arc::clone(&wake));
let cache = IconCache::default();
let publish = move |found: &Found| {
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
if *cur != style {
*cur = style;
if let Some(ctx) = waker.get() {
ctx.request_repaint();
}
}
};
let (ready, wait) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
let Some(proxy) = proxy else {
publish(&found);
let _ = ready.send(());
return;
};
// Subscribe before the first read, so a change during start-up isn't lost.
let signals = proxy.receive_signal("SettingChanged").ok();
let read = |namespace: &str, key: &str| {
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
value.and_then(portal_string)
};
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
publish(&found);
let _ = ready.send(());
for message in signals.into_iter().flatten() {
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
match (namespace.as_str(), key.as_str()) {
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
_ => continue,
}
publish(&found);
}
});
// A fast desktop answers before the first frame; a slow one updates the bar later.
if spawned.is_ok() {
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
}
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
}
#[test]
fn the_desktop_comes_from_the_first_variable_set() {
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[])), Desktop::Other);
}
#[test]
fn the_family_follows_the_desktop_then_the_gtk_theme() {
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
}
#[test]
fn kwin_button_letters_become_a_gtk_layout() {
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
assert_eq!(ini_value(rc, "nope", "library"), None);
// Garbage is just no buttons on that side, never a panic.
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
}
fn no_icons(_: &str) -> Option<CaptionIcons> {
None
}
#[test]
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
// The style carries what the loader returns for that name.
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
});
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
}
#[test]
fn layouts_and_overrides_resolve_to_a_style() {
let none = env(&[]);
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
let other = resolve(Desktop::Other, &found, &none, &no_icons);
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
// An unknown override name falls back to what was found.
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
}
#[test]
fn portal_strings_unwrap_nested_variants() {
use zbus::zvariant::{OwnedValue, Value};
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
}
#[test]
fn settings_files_are_read_from_the_config_dir() {
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
let other = from_files(Desktop::Other, &var);
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
let kde = from_files(Desktop::Kde, &var);
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
let _ = std::fs::remove_dir_all(dir);
}
}
+138 -6
View File
@@ -5,13 +5,15 @@ 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::{Duration, 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,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
@@ -46,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut reader = BufReader::new(read);
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -73,28 +81,41 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok && reader.get_mut().authenticated().is_err() {
break;
}
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
continue;
}
if let Some(reply) = repeat_auth_reply(&line) {
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
break;
}
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
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);
let dispatch = req.dispatch.clone();
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": dispatch.give_up()}));
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;
@@ -110,6 +131,34 @@ mod tests {
use super::*;
use std::io::BufRead;
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
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();
let started = Instant::now();
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
started.elapsed()
});
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
// the idle timeout.
let mut stream = TcpStream::connect(addr).unwrap();
for i in 0..150 {
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
if stream.write_all(byte).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let served = server.join().unwrap();
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
assert!(rx.try_recv().is_err(), "nothing was dispatched");
}
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
use photocraft_automation::budgets::MAX_RESPONSE_BYTES;
@@ -124,6 +173,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 +204,86 @@ 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();
}
#[test]
fn repeat_auth_reports_already_authenticated_without_dispatching() {
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", "a repeat auth must not reach the UI dispatcher");
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());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(stream, "{}", json!({"id": 9, "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"], 9);
assert_eq!(after["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}
+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();
}
}
+229 -17
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};
@@ -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 {
@@ -167,6 +177,12 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma
plan(backend, crashed, env, safe_gpu, os)
}
/// Persist successful hardware fallbacks, but never silently pin CPU mode.
/// The recovery dialog explicitly stores CPU mode when the user chooses it.
pub fn backend_to_remember(plan: &Plan) -> Option<GpuBackend> {
(plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend)
}
/// 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() {
@@ -181,7 +197,9 @@ 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,
},
})
}
@@ -214,8 +232,11 @@ 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`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
@@ -235,7 +256,7 @@ 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).
@@ -253,6 +274,14 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
}
/// 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).
@@ -261,6 +290,9 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
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();
@@ -400,6 +432,17 @@ impl Sentinel {
}
}
/// 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);
@@ -420,6 +463,27 @@ 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);
@@ -484,12 +548,33 @@ mod tests {
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
}
/// #3483: A temporary CPU recovery must not change the next launch.
#[test]
fn cpu_recovery_requires_explicit_confirmation_to_persist() {
let gl_crash = crashed("gl", "gl");
let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other);
assert_eq!((cpu.backend, cpu.remember), (Cpu, true));
assert_eq!(backend_to_remember(&cpu), None);
let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other);
assert_eq!(safe.backend, Cpu);
assert_eq!(backend_to_remember(&safe), None);
// An unconfirmed fallback leaves the original automatic selection intact.
assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto);
// A working hardware fallback can still be remembered.
let vk_crash = crashed("auto", "vulkan");
let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other);
assert_eq!(backend_to_remember(&gl), Some(Gl));
// CPU explicitly selected in preferences still works.
assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu);
}
#[test]
fn safe_gpu_forces_the_cpu_for_one_launch() {
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));
}
@@ -529,6 +614,42 @@ 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));
}
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
/// GL drew a black window; WARP works. GL stays the last resort.
#[test]
fn windows_auto_prefers_warp_over_gl() {
use wgpu::{Backend as B, DeviceType as T};
let svga_gl = cand(0x15ad, T::Other, B::Gl);
let warp = cand(0x1414, T::Cpu, B::Dx12);
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
// Elsewhere GL keeps its rank.
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
@@ -562,12 +683,65 @@ 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) = begin_when_unlocked(&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);
d
}
/// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns
/// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child
/// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test
/// drops its sentinel.
fn begin_when_unlocked(dir: &Path) -> (Previous, Option<Sentinel>) {
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);
}
(prev, s)
}
#[test]
fn sentinel_detects_a_crash_and_clears_on_success() {
let dir = temp_dir("cycle");
@@ -583,16 +757,7 @@ mod tests {
assert!(none.is_none());
// The process dies in the driver: the lock goes, the marker stays.
drop(s);
// 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 (prev, s) = begin_when_unlocked(&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.
@@ -605,6 +770,29 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn begin_when_unlocked_waits_for_a_briefly_held_lock() {
let dir = temp_dir("shared-lock");
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
// After the start dies, a second handle on the marker holds the lock for 50 ms, as a
// spawned child's copy of the descriptor does until the child calls exec.
let held = File::open(dir.join(MARKER_FILE)).unwrap();
drop(s);
held.lock().unwrap();
assert_eq!(Sentinel::begin(&dir).0, Previous::Busy);
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
held.unlock().unwrap();
});
let (prev, s) = begin_when_unlocked(&dir);
release.join().unwrap();
assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan"));
s.expect("sentinel").finish();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
let cases = [
@@ -622,7 +810,7 @@ mod tests {
} else {
s.finish();
}
let (previous, s) = Sentinel::begin(&dir);
let (previous, s) = begin_when_unlocked(&dir);
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
if !keep {
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
@@ -692,4 +880,28 @@ mod tests {
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));
}
}
}
+329
View File
@@ -0,0 +1,329 @@
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
use std::path::{Path, PathBuf};
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
/// theme lists, and the bytes read from one file.
const MAX_THEMES: usize = 32;
const MAX_DIRS: usize = 4096;
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
const SCAN_DEPTH: usize = 3;
const MAX_FILE: u64 = 1 << 20;
/// The size the caption glyphs are drawn at, in the spec's units.
const ICON_SIZE: i64 = 16;
/// A theme or icon name that stays inside its directory.
pub fn valid_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
}
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
let mut bytes = vec![];
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
Some(bytes)
}
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let absolute = |p: &String| Path::new(p).is_absolute();
let home = var("HOME").filter(absolute).map(PathBuf::from);
let mut dirs = vec![];
if let Some(home) = &home {
dirs.push(home.join(".icons"));
}
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
dirs.push(data.join("icons"));
}
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
dirs
}
/// One `[section]` of an `index.theme`, as (key, value) pairs.
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
let mut inside = false;
let mut pairs = vec![];
for line in index.lines().map(str::trim) {
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
if inside {
break;
}
inside = s == name;
} else if inside && let Some((k, v)) = line.split_once('=') {
pairs.push((k.trim(), v.trim()));
}
}
pairs
}
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
}
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
let size = num("Size", 0);
let scale = num("Scale", 1).max(1);
let d = match get("Type").unwrap_or("Threshold") {
"Fixed" => (size * scale - ICON_SIZE).abs(),
"Scalable" => {
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
}
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
};
(d, scale)
}
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
/// path order: scalable and symbolic ones first, as icons there fit any size.
fn subdirectories(root: &Path) -> Vec<PathBuf> {
let mut found = vec![];
let mut level = vec![root.to_path_buf()];
for _ in 0..SCAN_DEPTH {
let mut next = vec![];
for dir in &level {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.flatten().take(MAX_DIRS) {
let path = entry.path();
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
next.push(path);
}
}
}
found.extend(next.iter().cloned());
level = next;
}
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
found.sort_by_key(|p| (vector(p), p.clone()));
found
}
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
struct Theme {
roots: Vec<PathBuf>,
listed: Vec<PathBuf>,
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
}
impl Theme {
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
/// often add `symbolic/ui` without listing it.
fn unlisted(&self) -> &[PathBuf] {
self.unlisted.get_or_init(|| {
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
})
}
}
/// A theme and the themes it inherits, in search order.
pub struct IconTheme {
themes: Vec<Theme>,
}
impl IconTheme {
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
/// `index.theme`.
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
let mut chain: Vec<String> = vec![name.to_string()];
let mut themes = vec![];
let mut i = 0;
loop {
let Some(theme) = chain.get(i).cloned() else {
// Every chain ends in hicolor, whether or not the themes before it exist.
if chain.iter().any(|t| t == "hicolor") {
break;
}
chain.push("hicolor".into());
continue;
};
i += 1;
if !valid_name(&theme) {
continue;
}
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
// The first index wins; a theme can still be spread over several base directories.
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
let index = String::from_utf8_lossy(&index);
let head = section(&index, "Icon Theme");
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
.chain(list(get("ScaledDirectories")))
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
.take(MAX_DIRS)
.map(|d| (distance(&section(&index, d)), d))
.collect();
subdirs.sort_by_key(|(key, _)| *key);
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
for parent in list(get("Inherits")) {
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
chain.push(parent.to_string());
}
}
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
}
(!themes.is_empty()).then_some(Self { themes })
}
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
/// ones, before its parent.
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
if !valid_name(icon) {
return None;
}
let file = format!("{icon}.svg");
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
}
}
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
/// unreadable SVG or an absurd size.
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
use resvg::{tiny_skia, usvg};
if !(1..=256).contains(&px) {
return None;
}
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
// (`/dev/zero`, a huge picture) or nest an SVG.
let opts = usvg::Options {
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
let size = tree.size();
let side = size.width().max(size.height());
if !side.is_finite() || side <= 0.0 {
return None;
}
let s = px as f32 / side;
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway icon tree: `files` are (path under the base, contents).
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for (path, text) in files {
let p = base.join(path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
base
}
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
#[test]
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
let base = tree(
"chain",
&[
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
("Child/48/a-symbolic.svg", "big"),
// Not in the index: still found, and before the parent's copy.
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
("Parent/ui/d-symbolic.svg", SQUARE),
("Child/16/a-symbolic.svg", SQUARE),
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
("Parent/ui/b-symbolic.svg", SQUARE),
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
("hicolor/s/c-symbolic.svg", SQUARE),
],
);
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
assert_eq!(theme.find_svg("missing"), None);
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
let _ = std::fs::remove_dir_all(base);
}
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
#[test]
fn cyclic_or_hostile_indexes_are_harmless() {
let base = tree(
"hostile",
&[
(
"A/index.theme",
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
),
("A/ok/x.svg", SQUARE),
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
],
);
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn distance_follows_the_spec() {
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
}
#[test]
fn masks_are_square_coverage() {
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
assert_eq!(mask.len(), 32 * 32);
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
assert_eq!(mask[0], 0, "outside it");
assert!(rasterize_mask(b"not svg", 16).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
}
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
#[test]
fn masks_load_no_images() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
<image href="/dev/zero" width="16" height="16"/>
<image xlink:href="data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'&gt;&lt;rect width='16' height='16'/&gt;&lt;/svg&gt;" width="16" height="16"/>
</svg>"##;
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
}
#[test]
fn base_dirs_follow_xdg() {
let var = |k: &str| match k {
"HOME" => Some("/home/u".to_string()),
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
_ => None,
};
let dirs = base_dirs(&var);
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
assert_eq!(dirs, want);
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
}
}
+59 -35
View File
@@ -9,8 +9,8 @@
//!
//! 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`]
//! and [`available`] touch the system, and they are only called on Linux.
@@ -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();
@@ -262,6 +265,12 @@ 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 {
@@ -269,8 +278,7 @@ pub fn available(session: DisplaySession) -> bool {
}
/// 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; a warning is printed here and the
/// app starts.
/// 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(());
@@ -278,18 +286,17 @@ pub fn preflight(session: DisplaySession) -> Result<(), String> {
if session == DisplaySession::None {
return Ok(());
}
match check(session, &system_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(()),
}
}
@@ -327,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")));
@@ -362,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}");
@@ -384,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"));
@@ -398,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);
+47 -10
View File
@@ -1,17 +1,19 @@
//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK on
//! macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in
//! 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.
//! 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;
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, channel};
use muda::accelerator::{Accelerator, Code, Modifiers};
@@ -49,6 +51,8 @@ pub struct MacMenu {
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 {
@@ -67,7 +71,7 @@ impl MacMenu {
// 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 }
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0, window: None }
}
fn build(&mut self, bar: &MenuBar) {
@@ -75,12 +79,15 @@ impl MacMenu {
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 = Submenu::new(escape(&m.title), true);
self.append(&sub, &m.children);
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));
}
@@ -97,6 +104,33 @@ impl MacMenu {
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 {
@@ -110,9 +144,9 @@ impl MacMenu {
}
Node::Standard(item) => {
let p = match item {
Standard::Services => PredefinedMenuItem::services(None),
Standard::HideOthers => PredefinedMenuItem::hide_others(None),
Standard::ShowAll => PredefinedMenuItem::show_all(None),
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),
@@ -160,6 +194,9 @@ impl Backend for MacMenu {
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 };
+26 -5
View File
@@ -1,9 +1,13 @@
//! macOS title bar 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,
//! 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). All safe objc2 calls.
//! `performWindowDragWithEvent:`, which works on an unmovable window).
//!
//! Quitting: see [`terminate_later`]. All safe objc2 calls.
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
@@ -18,3 +22,20 @@ pub fn disable_native_title_drag() {
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"),
});
}
+154 -25
View File
@@ -22,12 +22,18 @@
mod app_dirs;
mod app_icon;
mod appearance;
#[cfg(target_os = "macos")]
mod apple_events;
mod caps_lock;
mod caption_style;
mod control_server;
mod crash_guard;
mod cursor;
mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "linux")]
mod icon_theme;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
@@ -37,11 +43,14 @@ mod mac_window;
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;
@@ -66,11 +75,14 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// 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.
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default();
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
viewport: viewport
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
@@ -84,10 +96,35 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
// 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(restored_window)),
..Default::default()
}
}
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
/// Full Screen did nothing until `F` had cycled through Standard.
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
at_least_min_size(builder).with_fullscreen(false)
}
/// 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.
@@ -134,6 +171,8 @@ 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();
// 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()) {
@@ -207,7 +246,23 @@ fn main() -> eframe::Result {
// 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.
@@ -223,7 +278,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 {
@@ -237,7 +292,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))
@@ -260,6 +315,9 @@ fn main() -> eframe::Result {
// 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);
@@ -275,6 +333,9 @@ fn main() -> eframe::Result {
}
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Centre only the first window: eframe centres after restoring the saved position, so a
// window closed on another monitor reopened in the middle of the main one (#4826).
options.centered = !ui_state::has_saved_position(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();
@@ -306,6 +367,7 @@ fn main() -> eframe::Result {
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();
@@ -322,6 +384,12 @@ fn main() -> eframe::Result {
#[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");
@@ -335,7 +403,14 @@ fn main() -> eframe::Result {
// 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 {
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
#[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),
@@ -380,24 +455,13 @@ fn main() -> eframe::Result {
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);
// CPU rendering after a failed GPU start is temporary. Only the
// recovery-dialog choice may persist it (#3483).
let remember = gpu_startup::backend_to_remember(&plan);
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()}))
@@ -412,6 +476,8 @@ 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);
@@ -421,6 +487,17 @@ fn main() -> eframe::Result {
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);
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
if custom_titlebar {
app.services.caption_style = caption_style::service();
}
// 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).
@@ -433,8 +510,10 @@ fn main() -> eframe::Result {
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
let lines = unreadable_paths.iter().map(|p| format!("{p}: the path is not valid Unicode; rename the file and open it again.")).collect();
photocraft_ui_egui::notices::post(&mut app, "Could not open", lines, true, None);
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 {
@@ -443,12 +522,15 @@ fn main() -> eframe::Result {
Ok(Box::new(app))
}),
);
// Closed or failed outside graphics initialization: not a driver crash. A renderer
// error keeps the marker, so the next start tries a safer backend.
if !gpu_startup::keep_marker_after_run(&result)
// 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()
{
if result.is_err()
&& !renderer_error
&& let Some(marker) = previous.crashed()
{
// This failure supplies no new graphics-crash evidence: retain the previous
@@ -532,6 +614,44 @@ mod tests {
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 a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
let saved = super::native_options(super::CUSTOM_TITLEBAR)
.viewport
.with_inner_size([2560.0, 1392.0])
.with_position([0.0, 0.0])
.with_fullscreen(true)
.with_maximized(true);
let opened = hook(saved);
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
@@ -552,4 +672,13 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
fn the_desktop_entry_names_the_window_class() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
}
+289 -11
View File
@@ -6,7 +6,12 @@
//! 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. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
//! 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;
@@ -16,27 +21,47 @@ use photocraft_engine::display_color::Display;
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>> {
if !cfg!(target_os = "macos") {
return None;
#[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());
});
Some(rx)
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").
/// 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")]
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") as integer)
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 ""
@@ -108,9 +133,225 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) ->
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
fn detect() -> Detection {
Err("this platform doesn't report display profiles".into())
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
@@ -123,7 +364,7 @@ fn parse_reply(out: &str) -> Detection {
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)) = (id.trim().parse::<u32>().ok(), num(x), num(y), num(w), num(h)) else { continue };
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();
@@ -136,12 +377,29 @@ fn parse_reply(out: &str) -> Detection {
displays.push(Display { id, name, frame: [x, top, w, h], profile_name, icc: icc.map(std::sync::Arc::new) });
}
if displays.is_empty() {
let first = out.lines().next().unwrap_or("").trim();
return Err(if first.is_empty() { "the display reader returned nothing".into() } else { format!("couldn't read the display list: {first}") });
// 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.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn base64_decode(s: &str) -> Option<Vec<u8>> {
@@ -213,6 +471,25 @@ mod tests {
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)]
@@ -247,6 +524,7 @@ mod tests {
}
/// 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() {
+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())
}
+575 -58
View File
@@ -5,41 +5,89 @@ use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{Recovered, Services};
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::RefCell;
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, Krita 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", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "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"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("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.
/// keeps defaulting to PSD.
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("Photoshop", &["psd", "psb"]),
("PSD Document", &["psd", "psb"]),
("PDF", &["pdf"]),
("PhotoCraft", &["pcraft"]),
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg"]),
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
("BMP", &["bmp"]),
("GIF", &["gif"]),
("ICO", &["ico"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
];
/// 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();
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);
let (name, mut exts) = v.remove(i);
// Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`.
exts.retain(|e| *e != ext);
exts.insert(0, ext);
v.insert(0, (name, exts));
}
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
None => {}
@@ -47,6 +95,106 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
let path = Path::new(suggested);
let name = path.file_name()?.to_string_lossy().into_owned();
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
}
/// 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::Folder { initial_dir } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
}
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
dialog = dialog.set_file_name(name);
}
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`].
@@ -75,6 +223,53 @@ 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 {
settings.io_options()
}
/// 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
@@ -88,18 +283,28 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
/// 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, s3) = (store.clone(), store.clone(), store.clone());
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(doc, revision, path.map(str::to_string)))
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 found = with_store(&s3, |s| s.recover()).unwrap_or_default();
found.into_iter().map(|(e, doc)| Recovered { key: e.info.key, path: e.info.original_path, doc }).collect()
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));
@@ -131,6 +336,62 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
})
}
/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real
/// screenshot is turned away, while a broken or hostile source can't make us allocate without end.
#[cfg(target_os = "linux")]
const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384;
/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png`
/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or
/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control
/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland.
#[cfg(target_os = "linux")]
fn wayland_clipboard_image() -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?;
image_from_offers(&offers, |mime| {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut bytes = Vec::new();
pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes)
})
}
/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image,
/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred.
#[cfg(any(target_os = "linux", test))]
fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<(u32, u32, Vec<u8>)> {
offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| {
let bytes = fetch(mime)?;
// TGA has no magic number (see `image_from_files`), so trust it only when the type says so.
if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") {
return None;
}
let img = photocraft_codecs::decode(&bytes).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 clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
@@ -143,6 +404,18 @@ 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(|_| {
@@ -151,49 +424,22 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}) 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;
}
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 };
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()?;
// A read failure goes back to the app, which reports it like any other open failure.
let bytes = photocraft_format::read_file(&path).map_err(|e| e.to_string());
Some((path.to_string_lossy().to_string(), bytes))
})),
pick_open_paths: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("All Formats", OPEN_EXTS)
.add_filter("PhotoCraft", &["pcraft"])
.pick_files()
.map(|paths| paths.into_iter().map(|path| path.to_string_lossy().into_owned()).collect())
})),
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| {
@@ -202,6 +448,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
})),
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]| {
@@ -226,12 +473,19 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
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()))
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
}
#[cfg(target_os = "linux")]
return wayland_clipboard_image();
#[cfg(not(target_os = "linux"))]
None
})
}),
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()))),
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())?;
@@ -246,6 +500,21 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}
}
/// 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());
}
}
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
#[allow(dead_code)]
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
@@ -316,28 +585,96 @@ mod tests {
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 {
pick_save: Some(Box::new(move |s: &str| {
log.borrow_mut().push(s.to_string());
None
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();
let invoke = |app: &mut PhotocraftApp, id: &str| photocraft_ui_egui::menus::invoke(app, &ctx, id, json!({}));
// 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.
@@ -380,9 +717,82 @@ mod tests {
.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]);
// The first type's first extension is the one Windows adds to the bare name.
assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]);
}
assert_eq!(save_filters("Untitled")[0].0, "Photoshop", "no extension keeps the default");
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
}
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
#[test]
fn save_as_offers_only_writable_formats_including_ico() {
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| !layered.contains(e)) {
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
// What Save As writes there: an icon up to 256×256, and a clear error past that.
let doc = |side: u32| {
let mut s = Session::new();
s.execute("file.new", json!({"width": side, "height": side})).unwrap();
s.active().unwrap().doc.clone()
};
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("256"), "{err}");
}
/// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to
/// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked
/// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's.
#[test]
fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() {
for (suggested, name, first) in [
("/pics/image.psd", "image", "psd"),
("/pics/image.psb", "image", "psb"),
("/pics/image.pcraft", "image", "pcraft"),
("/pics/sky.fit", "sky", "fit"),
("cat.jpeg", "cat", "jpeg"),
("swatches.aco", "swatches", "aco"),
] {
assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}");
assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}");
// Elsewhere the dialogs keep the whole name (see `dialog_file_name`).
let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned();
assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}");
}
// Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no
// extension, and a dotfile.
for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] {
assert_eq!(dialog_file_name(name, true).as_deref(), Some(name));
}
assert_eq!(dialog_file_name("", true), None);
}
#[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];
@@ -392,7 +802,16 @@ mod tests {
/// 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 {
PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())))
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.
@@ -467,6 +886,59 @@ mod tests {
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;
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
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);
// The worker commits the bundle's manifest before it publishes the sidecar, and
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
// the first non-empty listing can come from the middle of the write. Wait for the write
// to finish: the entry then carries the sidecar's revision.
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.iter().any(|e| e.info.revision == revision) {
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 {
@@ -529,6 +1001,51 @@ mod tests {
assert!(image_from_files(&[bad]).is_none());
}
fn encoded(format: photocraft_codecs::Format, 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, format, &EncodeOptions::default()).unwrap()
}
#[test]
fn non_png_clipboard_images_paste() {
// #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing.
let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2);
let offers = vec!["text/plain".to_string(), "image/bmp".to_string()];
let mut asked = Vec::new();
let got = image_from_offers(&offers, |mime| {
asked.push(mime.to_string());
Some(bmp.clone())
});
assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6))));
assert_eq!(asked, ["image/bmp"], "text offers are never fetched");
let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8);
let offers = vec!["image/jpeg".to_string()];
assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8)));
}
#[test]
fn undecodable_clipboard_offers_fall_through_to_the_next() {
let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2);
let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec();
let offers: Vec<String> = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec();
let got = image_from_offers(&offers, |mime| match mime {
"image/webp" => None,
"image/png" => Some(cut.clone()),
"image/x-tga" => Some(b"not an image".to_vec()),
_ => Some(bmp.clone()),
});
assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2)));
assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none());
assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none());
}
#[test]
fn clipboard_tga_is_trusted_only_by_its_type() {
assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
@@ -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();
}
+67 -6
View File
@@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
#[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.
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
#[cfg(any(target_os = "linux", test))]
const ABS_X: u32 = 0x00;
#[cfg(any(target_os = "linux", test))]
const ABS_Y: u32 = 0x01;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
#[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)
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
#[cfg(any(target_os = "linux", test))]
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % 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.
@@ -238,6 +256,49 @@ mod tests {
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:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
/// the device's own, from `/proc/bus/input/devices`.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"RustDesk UInput Keyboard\"\n\
P: Phys=\n\
S: Sysfs=/devices/virtual/input/input54\n\
U: Uniq=\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: PROP=0\n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
B: MSC=10\n\
B: LED=7\n";
#[test]
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
assert!(
bit_set(
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
BTN_TOOL_PEN
),
"the fixture claims the key"
);
assert!(!has_pen(VIRTUAL_KEYBOARD));
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
// pen's device and the pen's axes never into the keyboard's.
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
assert!(has_pen(&both));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
assert!(has_pen(pen_display));
// Axes without `ABS_X` or `ABS_Y` don't count.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
}
#[test]
+33 -1
View File
@@ -27,6 +27,23 @@ pub fn sanitize(path: Option<&Path>) {
}
}
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
pub fn has_saved_position(path: Option<&Path>) -> bool {
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
saved_position(&text)
}
fn saved_position(text: &str) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
// eframe restores the inner position on macOS and the outer position elsewhere
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
}
/// `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.
@@ -78,7 +95,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
#[cfg(test)]
mod tests {
use super::check;
use super::{check, saved_position};
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
@@ -105,6 +122,21 @@ mod tests {
}
}
#[test]
fn a_saved_window_position_is_detected() {
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(saved_position(&file(window, "1.0")));
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(!saved_position(&file(no_position, "1.0")));
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
assert!(!saved_position(text), "{text:?}");
}
assert!(!super::has_saved_position(None));
assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron"))));
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
+234
View File
@@ -0,0 +1,234 @@
//! 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::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// How far a parked maximize reaches past a monitor/work-area edge: 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;
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
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((left, top, right, bottom)) =
clamped_rect((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 (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
return;
};
// Moving the outer frame while winit pins the client rect to the work area must
// not copy the old frame's pixels into the custom title bar.
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT { x: left, y: top },
SIZE { cx: width, cy: height },
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
let _ = hwnd.InvalidateRect(None, false);
}
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
/// both makes the guard idempotent and handles Windows already correcting some edges.
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
let (l, t, r, b) = rect;
let (ml, mt, mr, mb) = monitor;
let (wl, wt, wr, wb) = work;
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
return None;
}
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
let border = [
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
]
.into_iter()
.flatten()
.filter(|n| (0..=FRAME).contains(n))
.min()?;
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
{
return None;
}
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
(corrected != rect).then_some(corrected)
}
/// `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_rect};
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_respects_taskbars_on_every_edge() {
let monitor = (0, 0, 1920, 1080);
let rect = (-8, -8, 1928, 1088);
for (work, expected) in [
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
] {
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
}
}
#[test]
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
let monitor = (-1920, -1080, 0, 0);
let work = (-1920, -1032, 0, 0);
for border in [0, 8, 16, 32] {
let rect = (-1920 - border, -1080 - border, border, border);
let expected = (-1920 - border, -1032 - border, border, border);
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None);
}
}
#[test]
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
}
#[test]
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 48, 1920, 1080);
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
}
}
#[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}");
}
+27 -1
View File
@@ -134,7 +134,8 @@ fn main() {
.iter()
.filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None })
.collect();
assert_eq!(keys, [(egui::Key::R, true, true), (egui::Key::R, false, true)], "the key press, then its release");
// 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");
@@ -164,6 +165,31 @@ fn main() {
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");
}
+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
@@ -9,6 +9,6 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" />
</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 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"
>
<path d="m10 15 5 5 5-5" />
<path d="M4 4h7a4 4 0 0 1 4 4v12" />
<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

+1 -1
View File
@@ -10,5 +10,5 @@
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 18a6 6 0 0 0 0-12v12z" />
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none" />
</svg>

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 317 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

+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

+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"
>
<defs>
<linearGradient id="g" x1="3" y1="0" x2="20" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<rect width="18" height="16" x="3" y="4" rx="2" fill="url(#g)" />
</svg>

After

Width:  |  Height:  |  Size: 549 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

+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"
>
<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="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
</svg>

After

Width:  |  Height:  |  Size: 466 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

+1
View File
@@ -22,6 +22,7 @@
- [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 |
+26
View File
@@ -35,6 +35,32 @@ 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.
+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), [SVG](svg.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).
+13 -3
View File
@@ -17,7 +17,17 @@ Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP,
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.
- 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.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile;
invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains
8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency
over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use
the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink
separations, black generation or native CMYK blending, and is not a claim of Photoshop parity.
## Implemented decode limits
@@ -27,8 +37,8 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
|---|---:|
| 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.
+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:
+15 -7
View File
@@ -7,10 +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 on desktop and headless TCP and on the headless
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
are limited 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.
@@ -19,7 +24,9 @@ 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. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
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.
@@ -47,8 +54,9 @@ Automation is a privilege boundary because requests can cause filesystem access,
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
`prefs.set`;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- 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
File diff suppressed because it is too large. Load diff
+126
View File
@@ -17,11 +17,25 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
[people.Skyfuller1303]
display_name = "Skyfuller"
sync_github_name = true
[people.AlanRockefeller]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.Streetblock]
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
@@ -36,3 +50,115 @@ real_name = "Arthur Vinicius"
[people.SckraT]
sync_github_name = true
[people.GamerWil26]
real_name = "Willy Foo"
display_name = "Willy Foo"
sync_github_name = false
[people.SilentYeti]
sync_github_name = true
[people.Elvis33LE]
sync_github_name = true
[people.stevanmarjanovic]
sync_github_name = true
[people.Hathor875]
real_name = "Krzysztof Cieślik"
sync_github_name = true
[people.maxm11]
sync_github_name = true
[people.skraus2]
sync_github_name = true
[people.excited-raccoon]
sync_github_name = true
[people.hamada147]
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
display_name = "Alexander Alber"
sync_github_name = false
[people.Erica4fr]
sync_github_name = true
[people.itsF4LCON]
real_name = "Ran Geerts"
display_name = "Ran Geerts"
sync_github_name = false
[people.vangel76]
sync_github_name = true
[people.FinnNk]
sync_github_name = true
[people.TheTeslak]
sync_github_name = true
[people.timothydaly1]
display_name = "SyntaxError"
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FredApps]
sync_github_name = true
[people.arthurdeka]
sync_github_name = true
[people.aweussom]
real_name = "Tommy Leonhardsen"
[people.Furash]
sync_github_name = true
[people.lucAmbr0]
sync_github_name = true
[people.Hevarh1]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.deadmeu]
sync_github_name = true
[people.megaDon101]
display_name = "megadon"
sync_github_name = false
[people.sssehnsuchttt]
sync_github_name = true
[people.VanTan666]
display_name = "VanTan"
sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "photocraft-affinity"
description = "Bounded reader for native Affinity documents: archive, object stream and document model"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[features]
# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
+150
View File
@@ -0,0 +1,150 @@
# photocraft-affinity
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
and hands `photocraft-io` a document in pixels with every transform composed. It does not write
Affinity files.
## What is imported
`photocraft-io` maps the model to an 8-bit RGB document at the Affinity document's resolution:
| Affinity | PhotoCraft |
|---|---|
| Pages, Publisher spreads | one artboard per spread, spreads side by side 64 px apart |
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
| Vector masks (`AdCh` curves and shapes) | vector masks |
| Pixel masks (`MRst`, attached or as a layer in a group) | layer masks |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | shape fill (converted to RGB) |
| Linear, elliptical, radial and conical gradients with Affinity's midpoint bias | gradient fills |
| Strokes: weight, scale with object, dash pattern, alignment, caps, joins, miter limit | shape strokes |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | type layers (rendered with the fonts installed) |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded smart objects |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content | pixel layers |
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
| Opacity, visibility, lock, names, blend modes | the same (Add as Linear Dodge) |
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
profile), facing pages and the Average, Negation, Reflect, Glow and Erase blend modes (as Normal).
Opened Affinity documents start without a save path: Save asks for a new copy and never writes
back over the Affinity file. When the native document can't be read at all, `photocraft-io` opens
the embedded PNG preview instead, as one pixel layer named “Affinity preview”, with a warning
that names the reason. That preview can't be placed or used by imports that can't show the
warning (smart-object decoding, stacks, batch, displacement maps, variables).
## Embedded preview
`preview(bytes)` reads only the thumbnail record the header's offset 24 points to (the same in
container versions 8 to 12); it never scans for PNG signatures. All integers are little-endian.
| Record offset | Size | Observed value |
|---|---:|---|
| 0 | 8 | `ffffffff` followed by `Thmb` |
| 8 | 4 | Record version 1 |
| 12 | 4 | PNG byte length + 13 |
| 16 | 4 | PNG start offset 29 |
| 20 | 4 | Zero |
| 24 | 4 | PNG byte length |
| 28 | 1 | PNG encoding 1 |
| 29 | declared length | Exactly one PNG |
The PNG is limited to 16 MiB and 4096 pixels a side. IHDR must come first and be unique, IDAT
chunks contiguous, an optional PLTE unique and before IDAT, every chunk CRC must match and IEND must
end the file; compressed metadata (`zTXt`, `iTXt`, `iCCP`), APNG chunks and unknown critical chunks
are rejected. The I/O adapter decodes it with a 128 MiB allocation limit.
## Provenance and clean-room
Affinity has no published file-format specification, so this reader is built only from a public
description of the format and from public files, never from Affinity itself. This is how
PhotoCraft already treats formats that are only partly documented: `photocraft-psd` fills the gaps
in Adobe's specification from the MIT-licensed psd-tools and ag-psd, and `photocraft-raw` recovered
Nikon's compression tables by black-box analysis of CC0 sample files (see their READMEs).
* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or
disassembled for this work, and nothing from an Affinity installation (program code, resources,
presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample
is a document its author published.
* **A public, permissively licensed description.** The container and object-stream layout was
learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at
`04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described
in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such
as Inkscape's Affinity extension) was read.
* **Every structure checked against public files.** afread doesn't say how its author learned the
format, so nothing here rests on it alone: each structure the reader relies on is confirmed by
the public documents. Every archive entry carries a CRC-32 that must match, and every field in
the object stream names its own type, so a misread layout fails loudly instead of producing
plausible wrong data.
* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was
worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's
render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows,
macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens
all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
(no licence stated, or GPL or non-commercial terms) were only opened locally to compare
pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned
files, which are CC0, MIT or Apache-2.0 only.
* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test
files are fetched at pinned commits and sha256-verified, never committed.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself.
The same reader is shared, as an independent copy, with VectorCraft's `vectorcraft-affinity`.
## Validation
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
ceiling per file).
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
and the I/O import (`import_preview`); CI's fuzz job runs `preview` and `import_preview`:
```sh
cd crates/affinity/fuzz
cargo +nightly fuzz run stream -- -max_total_time=600
cargo +nightly fuzz run import_preview --features io -- -max_total_time=600
```
What has not been verified: files from Affinity builds or platforms outside the public set, files
written by other applications, rotated or skewed images against Affinity's render, mask polarity
against a render with and without the mask, and anything listed as a warning above. The `synth`
builders write only what this reader accepts; Affinity has never opened their output, so they are
test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
XMP metadata. This reader never imports those fields.
+5
View File
@@ -0,0 +1,5 @@
target/
corpus/
artifacts/
coverage/
Cargo.lock
Loaded 100 of 1304 files, more files were not shown because too many files have changed in this diff. Show more