Commit Graph
42 Commits
Author SHA1 Message Date
aiongg 15df0acb04 Merge PR #4585: Prevent clipboard centering overflow and incorrect paste placement 2026-10-22 02:21:47 +00:00
FinnNkandClaude Opus 5.5 8c3c3237f4 refactor(engine): drop the second damage pass after single-edit duplicates and pastes (#5880)
Session::edit has reported a new layer's bounds as damage since #3847, so
Duplicate Layer, Duplicate Layers and Paste no longer need to keep the
document from before the edit and work the damage out again with
note_insert. Paste Into still does: it is three edits.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:33:06 -04:00
Brian TandClaude Opus 5.5 ca69059dd0 Edit: Copy with no selection keeps fill and adjustment layers (#2344) (#7497)
Copy refused fill and adjustment layers ("the active layer has no
pixels"). With no pixel selection they are now taken whole, like smart
objects, type, shape and group layers, and Paste recreates them in this
or another document. An adjustment layer's clip spans the canvas, so
paste placement and the OS-clipboard mirror stay consistent.

Scorecard UI-217-23 note, gaps.md G9 and target-app-parity.md updated;
the row stays partial (a single pixel layer still copies pixels only).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:23:26 -04:00
Vikram Bhaskaran 21a9d13431 Prevent clipboard centering from overflowing document coordinates. 2026-10-15 10:23:24 +11: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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

* 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 <noreply@anthropic.com>

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:17:10 -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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:58:42 -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) <noreply@anthropic.com>
2026-10-13 13:24:17 -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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:45:20 -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 <hi@skylar.cc>
2026-10-12 23:38:45 -06:00
Nigel Ihza FirdausyandBrandon Thomas 5cff9ebed2 fix(clipboard): prompt to save new documents containing pasted pixels (#4683)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:11:20 -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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:10:58 -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 <noreply@anthropic.com>
2026-10-12 07:04:33 -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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:46:56 -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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 23:08:12 -04:00
Vikram Bhaskaran 2af2cec6ea Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267) 2026-10-11 20:50:49 -04:00
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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:53:25 -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 <noreply@anthropic.com>
2026-10-11 10:05:13 -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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:24:51 -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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 18:50:04 -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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 18:43:46 -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 <noreply@anthropic.com>
2026-10-10 18:09:03 -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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:28:53 -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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Cut on a colour channel: reuse clear_color_channel from #2730

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

---------

Co-authored-by: Fabian Ghenof <fabianghenof@techie.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 16:05:08 -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 <noreply@anthropic.com>
2026-10-10 15:59:34 -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 <noreply@anthropic.com>

* Adapt #2530 to main: fallible steep-Distort warp, Result from mask paste in float moves; regenerate scorecard

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:07 -04:00
MarekSpodBiedry 1433c3c520 fix(engine): Layer via Copy and Cut deselect, as Photoshop does (#1096) (#1696) 2026-10-09 17:59:48 +07:00
a32ba211c9 Clear, Cut and Image › Adjustments refuse a pixel-locked layer (#1103) (#1194)
* Clear, Cut and Image > Adjustments refuse a pixel-locked layer (#1103)

clear_area, destructive_adjust and auto_adjust took the layer's surface
directly. They now go through commands::paint_surface, which refuses a
pixel- or fully-locked layer with the same message painting uses.

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

* edit.cut: refuse a pixel-locked layer before filling the clipboard

copy() ran before clear_selected noticed the lock, so a refused Cut still
replaced the clipboard. Check the lock first (check_pixels_unlocked, shared
with paint_surface) and assert the clipboard is unchanged in the test.

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

---------

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:04:33 -04:00
BFlatandClaude Opus 5.5 fa186c59b9 Paste into a targeted layer mask or channel instead of a new layer (#1035) (#1078)
With the layer mask targeted (its thumbnail clicked, or Alt-clicked to view
it), Edit > Paste, Paste in Place, Paste Into and Paste Outside now paste
the clipboard's luminosity into the mask, placed as a paste places it, over
the mask's values (the pasted pixels' transparency, and the selection for
Paste Into / Outside, let the mask show through). One history step, no new
layer, at 8, 16 and 32 bits. A targeted alpha channel or Quick Mask takes
the paste the same way, as in Photoshop.

The paste commands now follow the target like the fills and filters do
(channel_cmds::routed), and accept a "target" param.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:41:26 -04:00
059546680c Layer copies are numbered: "Layer 1 copy 2", not "Layer 1 copy copy" (#897)
Duplicating a layer (Layer › Duplicate, ⌥-drag with the Move tool, Layer
via Copy without a selection, New Smart Object via Copy, the Edit menu's
duplicate, Select and Mask's new layer) names the copy with
`Document::copy_name`: "Layer 1 copy" first, then "Layer 1 copy 2",
"Layer 1 copy 3"…, unique in the document. A name already ending in
"copy" or "copy N" is numbered from its root instead of growing another
"copy"; names that merely contain the word keep it.

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:04:52 -04:00
Brandon ThomasandClaude Opus 5.5 d64f809e4d Layer via Copy, Copy and Cut read only the selected area: 33x faster on 24 MP (#668) (#985)
lift() converted the whole layer to a format with alpha before reading the
selection's rectangle, so on a 24 MP Background every Layer via Copy cost about
a second whatever the selection's size, and it sampled the selection mask one
pixel at a time. It now reads just that rectangle and the mask's rectangle.

6000x4000 Background, 2000x1500 selection, release: Layer via Copy 984 ms ->
30 ms. Select All then Layer via Copy: 212 ms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:48 -04:00
BFlatandClaude Opus 5.5 727daf5d67 Copy works on smart object, type and shape layers (#541) (#841)
Edit > Copy only reads, so it now takes whatever the active layer shows
(a smart object's, type layer's or shape's rendered pixels), with or
without a selection, as Photoshop does. Cut still needs a pixel layer and
says a smart object is not directly editable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:14:43 +07:00
BFlatandClaude Opus 5.5 f697e43ef7 Transform Again on a Copy (Cmd+Alt+Shift+T): step and repeat (part of #352) (#474)
The hidden shortcut for step-and-repeat was missing. A new command,
`edit.transform.againCopy` (no menu item, Cmd+Alt+Shift+T), duplicates
the active layer and repeats the last transform on the copy, as one
history step labelled "Transform Again", so repeated presses lay out a
row (or a ring) of copies one transform apart. The transform is worked
out before anything changes (`transform_again_params`, now shared with
Edit › Transform › Again), so nothing to repeat leaves no copy; a copy
that can't be transformed (position locked) is taken back with nothing
left to redo (`History::clear_redo`).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:55:50 +07:00
BFlatandClaude Opus 5.5 3c6310e559 New from Clipboard, and Paste with nothing open makes a document (#368) (#458)
File › New from Clipboard (`file.newFromClipboard`, right after New…)
opens the clipboard image as a document of its own: sized to the image,
holding it as one layer, in the pixel format it was copied in. It has
no default shortcut; one can be assigned in Keyboard Shortcuts. Edit ›
Paste (Ctrl+V) with no document open does the same, so a screenshot is
one keystroke from the start screen. Both read the OS clipboard only
when invoked, stay enabled from the clipboard service like Paste, and
are a quiet no-op when it holds no image.

Agents reach it through MCP and `serve` too: the command only uses the
session clipboard, so it joins the automation allowlist of file
commands. The menu label is in every catalog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:00:22 +07:00
Multipad-cyberandMultipad-cyber 8713e26f5a Background layer: double-click, masks and cut behave like Photoshop (#266) (#266)
Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
2026-10-06 07:43:37 -04:00
Brandon ThomasandClaude Opus 5.5 d3b540234c Large images: adapter texture limits, banded compositing/export, proxy thumbnails, history byte budget (#71)
* Compose: render in place and in bands; area-reduced and proxy thumbnails

render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.

New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).

thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.

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

* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail

egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.

The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.

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

* IO: banded export and import; no full-size copies in encoders; larger decode limits

- Flat export renders the composite in bands and quantises each band; alpha
  is dropped in place when every pixel is opaque. The single-layer native
  path copies surface rows a band at a time (no f32 read_region and no
  per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
  flatten, no second plane copy); near-opaque documents are re-encoded
  without the white matte so the output is unchanged. Layer interleaved
  bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
  the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
  instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
  was refused at 2^28).

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

* Filters: write tile results in memory-bounded groups

apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).

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

* History: bound pixel memory by Preferences > Performance > Memory Usage

History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.

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

* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves

One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:45:17 -04:00
Brandon ThomasandClaude Opus 5.5 37c8f81032 Image › Adjustments overhaul: full params, one editor per kind, live-preview dialogs, Curves point deletion (#68)
* Adjustments: full parameter sets for every kind, Hue/Sat ranges, CMYK/Lab tone channels

- doc: Hue/Saturation gains six colour ranges (HueRange), Gradient Map a dither flag,
  Levels/Curves a ToneSpace (RGB, CMYK inks + black, Lab) for mode-native channels.
- engine: new adjust_params module (from_params over a base / to_params) used by
  image.adjustments.*, layer.newAdjustmentLayer.* and layer.setAdjustment. Curves,
  Color Balance, Channel Mixer, Black & White, Gradient Map and Photo Filter now honour
  their params; bad params are BadParams errors; setAdjustment merges (empty = reset).
- compose/gpu: hue-range tables, CMYK/Lab tone channels (CPU; GPU falls back), dithered
  Gradient Map, Black & White from grey/secondary/primary parts; GPU parity extended.
- io: hue2 ranges, grdm dither, CMYK/Lab Levels/Curves records round-trip.
- destructive adjustments use the document's tone transfer.

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

* Adjustments UI: one editor per kind for Properties and Image › Adjustments dialogs

- adjust_editors: Brightness/Contrast (legacy), Levels and Curves (per channel in RGB, Gray,
  CMYK and Lab, histogram behind), Exposure, Vibrance, Hue/Saturation (master + six ranges with
  draggable range sliders, colorize, before/after spectrum), Color Balance (tones, preserve
  luminosity), Black & White (six weights, tint colour), Photo Filter (presets or colour,
  density), Channel Mixer (outputs, constant, monochrome, total), Posterize, Threshold
  (histogram), Gradient Map (stops, presets, reverse, dither). Replaces the M7 placeholder.
- Properties previews live (GPU) while a control moves and commits one setAdjustment per gesture.
- adjust_dialog: Image › Adjustments opens the same editor modally; the canvas previews the
  real command on the proxy, Preview checkbox, OK = one history step, Cancel leaves the doc.
- Curves (#43): hit-test at the press origin, Delete/Backspace removes the selected point when
  the graph has focus, Cmd/Ctrl-click removes, dragging off removes (and back on restores),
  arrows nudge; endpoints and the last two points stay; pressing the gradient bars does nothing.
- tone: histograms per channel space, of the image below a layer or of the target layer alone.
- kittests: every adjustment layer kind edited through the Properties panel with real input at
  8/16/32-bit RGB, Gray, CMYK and Lab (#12); Curves point interactions; dialog preview/OK/Cancel.

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

* io tests: adjustment round trip covers the new Levels/Curves/Hue-Sat/Gradient Map fields

#60's round-trip tests predate #68's doc-model additions (tone space and black channel,
Hue/Saturation ranges, Gradient Map dither); fill them with their defaults.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:40 -04:00
Brandon ThomasandClaude Opus 5.5 4218d07f40 Never crash: the engine is panic-free (Rule 9) (#54)
Every unwrap/expect/unreachable in non-test engine code now returns an
EngineError (or a predicate's Err string) or uses a real fallback:
- enabled predicates read the active layer through active_layer_of()
  instead of unwrap() after has_layer();
- new raster layers get their pixels through pixels_mut()? instead of
  surface_mut().expect("raster");
- layer paths use split_last().ok_or(..)? (Merge Down, Arrange, Ungroup,
  Release Clipping), Auto-Align/Auto-Blend/Photomerge carry placements
  instead of re-unwrapping them, and "next free name" searches are bounded;
- web export builds its options map directly, quantize_rows has a wasm
  tail, parse_script rejects non-array/object JSON, the shape-layer edit
  returns an error instead of unreachable!, and the gray composite
  profile falls back to sRGB.
The engine denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:26:26 -04:00
Brandon ThomasandClaude Opus 5.5 f7eb6ea9e7 Format: adopt the craft-family rustfmt.toml and format the workspace
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:31:17 -04:00
Brandon ThomasandClaude Opus 4.8 bf111b524e Fix panics on malformed params; add panic-fuzz harness
Rule 9 (no panic on input). Fixes found via fuzzing:
- edit.paste: a short/non-array "center" (e.g. [5], []) indexed a[1] and
  panicked; now requires len>=2, else falls back to the canvas centre.
- edit.transform.again: a quad row with <2 columns indexed [1] and panicked;
  now rejected with the other shape checks.
Tests: paste_with_malformed_center_does_not_panic (fast regression) plus
tests/panic_hunt.rs, an opt-in (#[ignore], run --ignored) fuzz that dispatches
every command with adversarial params and asserts none panic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 00:24:01 -04:00
Brandon ThomasandClaude Opus 4.8 a0eff27619 Fix test-module attributes from prior insertion (dup #[test]; restore cut_clears)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 10:32:40 -04:00
Brandon ThomasandClaude Opus 4.8 5442398a91 Tests: copy/paste between tabs (incl. cross-mode) and transform scale
User-reported features verified working; added the missing regression coverage.
- copy_paste_between_documents: copy in one tab, paste into another (shared
  clipboard targets the active document).
- paste_between_different_color_modes: RGB clipboard pastes into a Grayscale
  doc, converting to the target format (no panic).
- transform_scale_grows_content: edit.transform 2x scales layer content.
Cross-document paste had no test before. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 10:30:58 -04:00
Brandon Thomas ae92ac20bf continued wip 2026-09-30 09:37:18 -04:00