100 Commits
Author SHA1 Message Date
vangel76andClaude Sonnet 5.5 3749c7d70f Clear texture deltas in the contour name-draft test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 00:04:59 +02:00
vangel76 d614b67ad5 Regenerate contour-library scorecard after merging main 2026-10-21 20:34:12 +02:00
vangel76 c1810b2c1d Preserve contour presets on malformed input and discard naming drafts 2026-10-21 20:27:53 +02:00
vangel76 e98988c3a7 Merge remote-tracking branch 'upstream/main' into review/pr-4392 2026-10-21 19:56:30 +02:00
vangel76 cd55b63b4f fix(move): show Align and Distribute in the Move options bar of non-Pro themes 2026-10-20 01:38:05 +02:00
vangel76andClaude Sonnet 5.5 f3a727cc94 Merge upstream/main into fix/3490-move-align-bar
Resolve ATTRIBUTION.md: keep main's ellipse-dashed.svg entries and this PR's align-edge/distribute icon entries.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 02:28:09 +02:00
vangel76andClaude Sonnet 5.5 ce6f1eef0d Fix merge with main: load the contour library in load_presets_json; regenerate scorecard
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 02:21:11 +02:00
vangel76 19b473f3eb Merge remote-tracking branch 'upstream/main' into tmp-4392 2026-10-19 02:17:07 +02:00
vangel76andClaude Opus 5.5 489feb2333 Tests: pcraft keeps text run baseline; tighten sub/superscript size check (#5645)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:57:15 +02:00
vangel76andClaude Opus 5.5 bfa5a75b5d Merge upstream/main into feat/4391-contour-library
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:17:17 +02:00
vangel76andClaude Opus 5.5 49438e71d2 Merge upstream/main into fix/5645-type-edit-keeps-subscript
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:12 +02:00
vangel76andClaude Opus 5.5 ad12ee294e Merge upstream/main into fix/3490-move-align-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:35:37 +02:00
vangel76andClaude Opus 5.5 e1821fd4fa Merge upstream/main into feat/4391-contour-library
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:35:33 +02: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
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
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
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
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
vangel76andClaude Opus 5.5 5f83b53a32 Type: keep superscript and subscript (FontBaseline) when a text layer is re-rendered (#5645)
CharStyle had no superscript/subscript attribute, so PSD import ignored FontBaseline and the first re-render (entering text editing) drew the run as plain text. Model it as CharStyle::baseline, read and write FontBaseline in EngineData, draw such runs at 58.3% of their size shifted by 33.3% of it (Photoshop defaults), and accept "baseline" in the character params.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:13:41 +02:00
vangel76andClaude Opus 5.5 47299e45af Merge upstream/main into feat/4391-contour-library
Keep both sides in commands.rs, lib.rs and the workspace sandbox list. Drop stale duplicate ja/hu rows left by the tsv union, add the contour strings to the new sr.tsv catalog, regenerate docs/scorecard.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:48:49 +02:00
vangel76andClaude Opus 5.5 9303c10bb6 Merge upstream/main into fix/3490-move-align-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:23 +02: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
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
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
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
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
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
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
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
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
vangel76 2b3df66ff0 Merge remote-tracking branch 'upstream/main' into m/x
# Conflicts:
#	ATTRIBUTION.md
2026-10-13 21:55:40 +02:00
vangel76 1e4d41be0a Merge remote-tracking branch 'upstream/main' into m/c1 2026-10-13 21:54:21 +02:00
vangel76 9cd9049c73 Merge remote-tracking branch 'upstream/main' into m/c1
# Conflicts:
#	crates/automation/src/workspace.rs
2026-10-13 19:21:26 +02: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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
vangel76andClaude Opus 5.5 5484591dc2 docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:49:20 +02:00
vangel76andClaude Opus 5.5 9f1d4760fa Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:49:19 +02: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
vangel76andClaude Opus 5.5 b2f67ef31f Automation: classify contour library load/save paths in the workspace sandbox (#4391)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:02:41 +02:00
vangel76andClaude Opus 5.5 99ba7f2688 Layer Style: user contour library with New…, Load/Replace/Save Contours (#4391)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:53:16 +02: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
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
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
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
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
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
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
vangel76andClaude Opus 5.5 3454c40fa7 Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:16:34 +02: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
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
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
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
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
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
0e2c34af3e Vanishing Point: on-canvas plane editor with Create/Edit Plane, grid and paste (#3122) (#3133)
Filter > Vanishing Point now opens a dialog mode on the canvas instead of
the generic parameter dialog:

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

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

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

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

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

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

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

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

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

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

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

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

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

Scorecard UI-217-15 is done.

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 21:27:08 -04:00
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
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
6de37ce7f0 Interface: an Enable Animations preference (#3273) (#3276)
Preferences › Interface › Enable animations (interface.enableAnimations, on by
default). Off, prefs_ui::sync_animations sets egui's animation_time to 0 and
scroll_animation to none in both the dark and light styles, so menus, popups
and collapsing sections change at once and scroll areas jump. It is re-applied
each frame, so a theme change keeps it. Translated in all 16 catalogs; the
scorecard row UI-217-10 goes to partial.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* layout_keys: drop unused field, fmt

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* layout_keys: drop unused field, fmt

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 20:11:43 -04:00
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
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
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
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
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