Commit Graph
37 Commits
Author SHA1 Message Date
7958dc63fb Layers: Merge Group for a selected group (#3832) (#3888)
* Layers: Merge Group for a selected group (#3832)

With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the
Layers panel's right-click menu offers Merge Group instead of Merge Down, as in
Photoshop. The new layer.mergeGroup command flattens the group's visible contents
into one pixel layer in its place, named after the group, keeping its opacity, blend
mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran
Merge Down and merged the whole group into the layer below it.

* i18n: Vietnamese row for Merge Group

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:42:58 -04:00
3dd5fc1cdc automation: say why a command is disabled in engine.commands (#3740)
engine.commands, command.list and the MCP command_list tool now give each
row a disabledReason (null when enabled, else the failed precondition) and
needsDocument (true when the command is off because no document is open),
so an agent can tell "open a document first" from "not runnable". enabled
keeps its meaning. Both listings share one row builder in the engine.

Fixes #3664

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:57 -04:00
3674ed62bd Single Row/Column Marquee: History names the step after the tool (#3031) (#3035)
select.rect takes an optional `line` ("row"|"column"); the canvas sends it
for the Single Row / Single Column Marquee, so History records
"Single Row Marquee" / "Single Column Marquee" instead of
"Rectangular Marquee". An unknown `line` is an error.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:52:17 -04:00
Sir StarchandGitHub f435eb0b55 fix(paint): respect Lock All on layer masks (#3094) (#3111) 2026-10-11 16:15:08 -04:00
865c8a15f8 fix(ui): toggle color swatches when selecting or deselecting a layer mask (#2560)
* fix(ui): toggle color swatches when selecting or deselecting a layer mask (#2420)

- Call sync_mask_targets() when handling ui.maskTarget / ui.vectorMaskTarget
  clicks in the Layers panel, Channels panel, and control channel
- Automatically target the layer mask when creating new adjustment or fill layers
- In tools.defaultColors, reset to mask default colors (white/black) when a
  mask is currently targeted
- Add unit test in engine and UI harness test in mask_thumbs_ui

* rustfmt

* Rasterize prompt test: target the fill layer itself, now that a new fill layer targets its mask

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

---------

Co-authored-by: huudinh1401 <huudinh1401@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:55:43 -04:00
eb20f83654 Masks: target a new layer mask and keep separate colour pairs for mask and pixels (#2166) (#2200)
* Masks: target a new layer mask and keep separate colour pairs for mask and pixels (#2166)

Photoshop keeps one foreground/background pair for layer masks and one for
layer pixels and swaps them when the edit target changes; adding a mask
targets it, with white/black. PhotoCraft never touched the colours when a
mask was targeted, and adding a mask left the pixels targeted.

- engine: ToolState::target_mask swaps in the other target's pair
  (mask pair starts at white/black, colours picked on either side are kept).
- ui-egui: sync_mask_targets applies the effective target (mask targeted,
  active layer has a mask, composite channel, no Quick Mask) after every
  command, every frame and every control request; the Layer Mask add
  commands target the new mask.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fix merge: close mask colour-pair test before the deletion tests

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 02:20:48 -04:00
8b1bb0fd05 fix(engine): select neighbouring layer after deletion (#2189)
Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 02:01:55 -04:00
cc0441f9ae Save As: the document takes the saved file's name (#2018)
A successful Save As (UI, automation or headless) renames the document to the written file, so its tab and title stop saying Untitled. The name is file identity, not a history step: undo and redo keep it. Failed writes, Save a Copy and exports leave the name, path and dirty state alone, and a deferred save no longer steals the selected tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:30:52 -04:00
5aaf0e4aae file.new rejects invalid parameters instead of falling back (#1878)
Unknown keys, wrong types, out-of-range sizes or resolution and unsupported
mode/depth/background now fail naming the parameter, before a floating selection is
committed or a document added; omitted fields keep their defaults. The renderer tests
now pass numeric depths (strings had silently fallen back to 8-bit). Part of #507.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:20:34 -04:00
7c2bd89e6a Canvas: edits that change no pixels (a lasso selection) don't recomposite (#1853)
Session::edit now reports empty damage when the step leaves every layer's pixels and the
document's compositing fields untouched (same_pixels: a bounded walk over the layer tree
comparing tiles by pointer), so a selection change no longer recomposites the whole canvas.
Adds a lasso_bench example. Fixes #1773.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:03:25 -04:00
d7078f3623 Layers: a new empty layer no longer recomposites every layer (#1846)
step_damage pairs layers by id and steps over a layer only one side has when it draws nothing
(an empty pixel layer or group in Normal/Pass Through, no masks, effects or Advanced Blending,
not a clipping base), so New Layer, New Group and their undo/redo report no damage. Adds the
P51 perf scenario (new layer in the 150-layer document). Fixes #1771.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:59:12 -04:00
175603a644 New adjustment and fill layers get a white mask (#1858)
As in Photoshop, a new adjustment or fill layer with no selection or active path gets
an enabled, linked reveal-all mask (sparse, no canvas-sized allocation), ready to paint,
in the layer's single undo step. Fixes #1768.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:48:26 -04:00
buivanvu94andBFlat e80a691ecf fix(engine): report invalid index on document.inspect and document.activate (#1557) 2026-10-09 13:24:03 +07:00
buivanvu94andBFlat 1ddda05394 fix(engine): use grammatical article for adjustment layer error messages (#1549) 2026-10-09 13:24:03 +07:00
99b6aff26b Fill and adjustment layers take the active path as their vector mask (#1419)
In Photoshop, when a path is selected in the Paths panel (the work path
just drawn with the Pen or made from a selection, or a saved path), Layer
> New Fill Layer > Solid Color/Gradient/Pattern and every new adjustment
layer get a vector mask made from that path: a Solid Color fill with a
vector mask is a shape layer, editable as a vector. The path wins over an
active selection; with no path selected the selection becomes a layer
mask (#1250), else there is no mask. PhotoCraft always used the selection
and ignored the path, so Solid Color filled the whole canvas.

Engine: the new fill and adjustment layer commands take an optional
"path" param ("work", a saved path's name or a path object). The shared
helper mask_new_layer turns it into the layer's vector mask and falls back
to the selection mask without it; an unknown name is an error, not a
panic.

UI: run_command passes the path selected in the Paths panel as "path"
(callers can opt out with "path": null). Drawing a work path with the Pen
and Make Work Path select it, as in Photoshop, and clicking the panel's
empty area deselects it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:16:12 +07:00
88a47dc7c7 Fix #1248: Create and Release Clipping Mask act on every selected layer (#1392)
With several layers selected, Layer › Create Clipping Mask (⌘⌥G) now
clips every selected layer except the lowest, which becomes the base,
and Release Clipping Mask releases them all, as in Photoshop; each is one
history step. A layer param still acts on that layer alone. The command
returns the layers it changed.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:37:48 +07:00
b8235e132a Layers panel: Alt-drag a row to copy the layer there, as in Photoshop (#1216)
* Layers panel: Alt-drag a row to copy the layer there, as in Photoshop

Holding Alt when a dragged Layers panel row is dropped leaves the layer in
place and drops a duplicate above, below or into the target, in one
"Duplicate Layer" undo step; the copy becomes active. A plain drag still
moves the layer. The cursor shows a copy icon while Alt is held.

layer.moveTo takes "copy": true for this and returns the copy's id. The copy
is made by the same helper as layer.duplicate (naming, unlocked Background
copy). A group can be copied beside itself but not into itself.

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

* Alt-drag copy test: expect "A copy 2" for the second copy of A

Main's Duplicate Layer now names copies with Document::copy_name ("A copy", then "A copy 2"),
and layer_copy uses it after the rebase, so the second copy of A in the test is "A copy 2".

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 20:16:39 -04:00
Elvis33LEandGitHub 9182f8f76d undo/redo report the step's damage instead of recompositing the whole canvas (#810)
* undo/redo report the step's damage instead of recompositing the whole canvas

Session::edit, undo and redo all cleared last_damage, so every undo of a
local edit recomposited the entire document instead of the edited area
(scorecard P6: undo a move on 20 MP = 256 ms p50 against a 25 ms budget;
P7 redo 144 ms). Only layer.setProps and the move paths computed damage.

Undo/redo now diff the two documents on either side of the history step:
every layer whose pixel identity changed contributes the union of its
change bounds (where it was, where it landed) to last_damage. The identity
covers everything the compositor reads - blending, placement, masks,
effects, and pixels via their copy-on-write tile pointers (both documents
stay alive for the comparison, so tile addresses cannot be reused under
it). Steps the fingerprint cannot bound still composite everything, as
before: canvas size/format/profile changes, layer-structure changes
(order, additions, removals, kind flips), and video layers - so mode
conversions, flatten/merge and structural undos keep the safe full path
by construction rather than by hoping the edit's target layers were
recorded correctly.

* step_damage: structural comparison, document-field and clipping bail-outs

Rework per review (correctness of the bounded claim):

- Document-level fields the compositor reads are compared up front in
  doc_pixels_equal (size, resolution, mode, depth, profile, channels,
  quick mask, global light, patterns, colour table, duotone); any
  difference composites everything. Undoing Layer Style > Global Light
  or an RGB > Indexed conversion (which keeps the pixel format) no
  longer reports Some(EMPTY) and leaves shadows stale.
- Layer clipping and channel exclusion reach outside the layer's own
  bounds; a difference composites everything, like layer.setProps
  already reports for edits.
- layer_fp's 64-bit hash is gone: layers are compared structurally
  (surfaces by copy-on-write tile pointers, blobs by pointer or bytes,
  parameters by ==), so no hash collision can under-report damage.
- Layer and Document are destructured exhaustively in the two compare
  functions: a new field fails to compile until it is classified as
  compositing (compared) or pixel-irrelevant (explicitly ignored), the
  same sentinel the .pcraft manifest uses.
- Groups no longer hash their children (the walk recurses into them),
  so an edit inside a group damages the edited child's extent, not the
  whole group's.

Tests: undo of Global Light, of image.mode.indexedColor, of a clipped
toggle and of an excluded channel each report None.
2026-10-08 19:41:02 -04:00
ClaudeandBFlat b71991e15a Batch layer.moveTo test: make the target group before B moves into G
layer.new.group inserts above the active layer, which was B inside G, so
Target was created inside G and moving G into it was rightly refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 06:20:20 +07:00
Nigel Ihza FirdausyandBFlat bc2b0a3ca6 test(engine): batch group move, stable stacking, undo/redo, nested selections and atomic rejection 2026-10-09 06:20:20 +07:00
a593b3651c CPU effect maps: key fill layers by their cached pixels, drop opacity/visible (#1059)
* CPU effect maps: key fill layers by their cached pixels, drop opacity/visible

The layer-effect map cache keyed a Fill layer by its Fill spec alone.
But what shapes the maps is the layer's cached pixels whenever they
still match the fill (what a PSD import stores and Image > Transform /
crop / canvas ops rewrite in place, without touching the spec): after
such a rewrite the CPU compositor kept serving the pre-rewrite maps, so
flattens and the CPU canvas showed the drop shadow of the pre-rewrite
shape while the GPU canvas was correct - until budget eviction or a
purge. The key now hashes the cache surface's tile fingerprint while it
matches the fill, like the GPU's fx::shape_key/group_key already did.

layer.opacity and layer.visible leave the key: the maps depend only on
fill_opacity (opacity is applied when the maps are composited,
visibility only skips the lookup), so an opacity slider drag or a
visibility toggle rebuilt every shadow and glow map over the whole
layer for nothing. Again matching the GPU key.

* Effect-map key: keep child visibility/opacity/blend for groups

A styled group's maps come from its children's composite, so hiding a
child or changing its opacity (or blend, clipping) must change the key;
only the top layer's own visible/opacity stay out. Adds a warm-vs-purged
render test for a group with a drop shadow.

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

* Effect-map test: name the edit tuple type (clippy type_complexity)

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-08 16:08:45 -04:00
9df3a481ee New adjustment and fill layers take the active selection as their mask (#1250) (#1282)
Photoshop masks a new adjustment or fill layer with the active selection, so the
adjustment acts on the selected area only. `layer.newAdjustmentLayer.*`,
`layer.newFillLayer.solidColor`, `.gradient` and `.pattern` inserted the layer
without reading `doc.selection`, so select → New Adjustment Layer gave an
unmasked adjustment over the whole image. The Gradient tool's fill-layer
command already did the right thing; its inline code becomes the shared
`commands::selection_mask`, used by every creator. The selection stays active,
as after Layer › Layer Mask › Reveal Selection.

Tests: each command at 8, 16 and 32-bit gets a linked, enabled mask that reveals
the selection and hides the rest, the composite outside the selection is
untouched, undo removes the layer; and no mask without a selection.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:42:23 -04:00
Ian SwopeandGitHub 874867461d file.new at 300000x300000 no longer fills ~360 GB of tiles before returning (#705) (#900)
Surface::fill_rect materialized every tile it touched, so a new document's
background cost one 256x256 tile per tile slot: ~360 GB for the 300000x300000
size the command accepts. Fully covered tiles now share one Arc'd tile, so a
solid fill costs one tile plus the tile index; edge tiles are written as
before, and copy-on-write splits a shared tile off when it is edited.
2026-10-08 15:04:57 -04:00
2a2459f57a Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot (#739)
* Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot

Like Photoshop, a file dropped on the active document's canvas is placed as an
embedded smart object layer and enters Free Transform (Esc takes the place back,
Enter makes place + transform one "Place Embedded" step; several files are placed
one after another). Dropped on the tab strip, it opens with its tab at the slot
under the pointer, shown by an insertion line while dragging. Elsewhere it opens
as before.

winit 0.30 drops carry no position, so the desktop shell reads the pointer from
the OS (apps/photocraft/src/cursor.rs: AppKit, x11rb, winsafe; no unsafe).

* Drop product names from comments, translate the place error, keep move_item panic-free

- Reword the new comments that named another product ("the reference app").
- "Couldn't place {name}: {error}" is a tl! string with entries in every
  complete catalog; `failed` now takes the finished message.
- The now-public `move_item` returns None for an out-of-range index instead of
  panicking (regression test in move_document's test).

* Tab moves as a document.move command, views follow their documents, drop the windows-sys lock bump

- document.move ({document?, to}) reorders tabs for the UI, agents and scripts; tab-slot drops and
  background opens go through it.
- sync_views keeps each view and its windows with their document by DocId, so moving or closing a
  tab never hands its view to another document.
- Restore os_pipe's windows-sys 0.52 lock entry (unrelated to this change).
- cursor::client_points: Windows pixel-to-point conversion, tested across monitors with different DPIs.
- de and pt-br strings for the place error.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 18:57:50 -04:00
57452bfd22 Bound layer-group nesting at import and Group Layers (stack-overflow guard) (#796)
* Bound layer-group nesting at import and Group Layers (stack-overflow guard)

Everything that walks the layer tree (Document::walk behind nearly every
command, the layers panel, PSD export, .pcraft save/load) recurses once per
level, but nothing bounded how deep a document could get: a PSD whose records
nest groups a few thousand deep (each level costs only a divider + folder
record pair) overflowed the stack in the importer's build recursion, in
apply_link_groups, and again in every consumer - an abort no panic hook or
fuzz run catches. The one cap that existed lived in the .pcraft save path
(MAX_GROUP_DEPTH = 100), so such a file even imported 'fine' and then bricked
saving.

- photocraft-doc now owns the limit (MAX_GROUP_DEPTH = 100, the value the
  format crate already enforced) plus Document::max_group_depth(), an
  iterative depth measure that cannot itself overflow.
- PSD/PSB import rejects files nested deeper than the cap with an actionable
  Unsupported error, mirroring what the .pcraft loader already does; the
  never-fail psd_to_document keeps working on such files by capping the
  recursion and warning about the unimported remainder (this was the overflow).
- apply_link_groups (resource 1026) recurses over the same tree and is
  bounded the same way.
- layer.groupLayers refuses to nest past the cap (checked inside the edit, so
  a rejected call records no history step and leaves the document untouched).

* Enforce the group nesting cap in layer.moveTo and Artboard from Layers

Moving a group beside or into a deeply nested layer, or wrapping a deep
group in an artboard, could still build a tree deeper than MAX_GROUP_DEPTH.
A shared check_group_depth helper now rejects those edits with an error and
records no history step, like layer.groupLayers.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:46:45 -04:00
Armaan SandhuandGitHub 0aabb48687 document.pixel: no panic at i32::MAX, reject coordinates beyond i32 (#629)
Rect::from_xywh(i32::MAX, y, 1, 1) saturates to an empty rect, so the composite has no pixels and .px[0] panicked. Coordinates are also cast with as i32, so 2^32 silently read column 0. Read the coordinates with i32::try_from (BadParams when out of range) and return transparent for the empty rect, which is what any other off-canvas pixel reads as.
2026-10-07 16:30:52 -04:00
e30b121969 History: tool and preset names, Frame from Layers as one step, undo/redo restore the targeted layers (#513, #528, #446, #496, #495) (#551)
* Elliptical Marquee selections record "Elliptical Marquee" in History (#513)

Both marquee tools commit through `select.rect`, which named every step
"Rectangular Marquee" whatever its `ellipse` param. The step is now named
after the tool that made it; the rectangular marquee keeps its name.

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

* Preset filters record their own names in History (#528)

Blur, Blur More, Sharpen, Sharpen More, Sharpen Edges and Despeckle run
a generic algorithm with fixed parameters and recorded its name
("Gaussian Blur", "Unsharp Mask", "Surface Blur"). The presets now live
in one table with their names, used for the history step and the job
progress text; Gaussian Blur, Unsharp Mask and Surface Blur keep theirs.

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

* Vertical type command no longer claims it renders horizontally (#446)

Vertical type has rendered in columns since #199, but the params text of
`type.orientation.vertical` still said it was "rendered horizontally for
now". It now documents the same `{"layer":id?}` as Horizontal.

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

* Frame from Layers is one history step (#496)

It grouped the layers through `layer.new.groupFromLayers` (its own
"Group Layers" step) and then recorded "Frame from Layers", so one undo
left the group behind. The grouping step is now folded into the frame
step with `purge_last`, as Transform Again on a copy does; if the group
can't be framed, the grouping is undone with nothing left to redo.

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

* Undo and redo restore the layers each history state targeted (#495)

History states held only the document, so after undo or redo the active
layer stayed whatever it was: redoing New Layer left the old layer
active (a Fill then painted it), and undoing Delete Layer left the
restored layer unselected. Each state now also keeps the active and
selected layers it had when it was created (when the document was
opened, or right after the step that made it), and undo and redo target
them again where they still exist. Selecting a layer is still not a
step and doesn't change any state's target, so selecting another layer
and then undoing or redoing goes back to that state's target, as the
reference app's Step Backward does.

The History panel lists redo labels from a new `redo_labels` accessor
instead of cloning and replaying the history every frame.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:54:10 +07:00
4c94e4d63a Marquee: stop the selection at the canvas edge (#263) (#263)
Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
2026-10-06 07:43:21 -04:00
Brandon ThomasandGitHub ea0beac9fd New Document: a typed Width/Height reaches Create (#254) (#270) (#270) 2026-10-05 18:15:03 -04:00
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 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
16ce03877c Test: Image Rotation rotates every layer, not just the active one (re #1) (#21)
Guards the whole-canvas rotation behaviour (the reported symptom was rotation
affecting only the selected layer, on 0.1.1). remap_document already rotates
all raster layers, masks, channels and the selection; this locks it in.
Non-raster layers (text/shape/smart) rotating under canvas rotation remains a
tracked follow-up.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 01:39:38 -04:00
Brandon ThomasandClaude Opus 5.5 42266291d5 Fidelity, GPU fallbacks, interactive performance; print spool-file fix
PSD oracle 111 -> 113 (tent effect blurs, bevel height/shading, glow
range, spread rounding, text gamma, gradient span, Lab blending, colour
burn/dodge edges), channel restrictions (brst), bevel contour/texture,
chisel techniques. Vector masks, pass-through clips, artboards, pattern
fills and bevel textures render on the GPU (parity <=1/255). PNG export
7x, PSD save 4x, .pcraft save 3.6x faster; CPU effects refresh 11x;
deadlock in the effect-map blur fixed. interactive_bench example.

Print: unique spool file per call (concurrent prints of same-named
documents overwrote each other).

Workspace: 2,040 tests pass, clippy clean, layers + wasm OK, parity 600/625.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:26:34 -04:00
Brandon ThomasandClaude Opus 5.5 aa87012aab Analysis, notes, proof views, workspaces (+ in-progress work of other agents)
Image > Analysis (ruler, count, measurement scale/log/record, scale
marker), notes (PSD Anno round trip, Notes panel, import), Modifier Keys
panel, Proof Setup plates / Legacy Mac RGB / colour blindness (Vienot),
32-bit preview options, custom pixel aspect, extras options, New/Delete/
Lock Workspace. Parity 600/625, FLOOR 595.

Checkpoint: also contains compiling in-progress work from the parallel
panels, type styles, File menu, photo, filters, UI-parity and fidelity
agents, plus photocraft-cli serve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:49:07 -04:00
Brandon Thomas aad724f28e work in progress 2026-10-01 10:07:04 -04:00
Brandon Thomas ae92ac20bf continued wip 2026-09-30 09:37:18 -04:00
Brandon Thomas 16941f7198 one-shot 2026-09-30 05:02:08 -04:00