Commit Graph
711 Commits
Author SHA1 Message Date
f3fda94c87 Large document: a notice explains a slow CPU fallback redraw (#1015) (#1427)
* Large document: a notice explains a slow CPU fallback redraw (#1015)

A full refresh that falls back to the CPU compositor blocks the canvas for as
long as it runs, with only a log line to say why. The first one per document
that takes over a second now posts a notice: how long it took, which edits
redraw the whole document, and for the GPU memory budget fallback, that
raising Memory usage raises the budget (up to a quarter of RAM).

* Slow-refresh notice test: check the real "Memory usage" casing; add Polish rows

The last assertion looked for "Memory Usage" while the string says
"Memory usage", so it passed vacuously. The budget case now asserts the
hint is present too. pl.tsv gets rows for the new strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:53:39 -04:00
Brandon ThomasandClaude Opus 5.5 7a9a2684a1 Toolbar test: qualify egui::Vec2 (main fails to compile ui-egui tests) (#1519)
#1376's test used the module-level Vec2 import that #886 dropped; each
compiled alone, but not together.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:49:21 -04:00
Brandon ThomasandClaude Opus 5.5 ed6f989bc6 Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1518)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:47:09 -04:00
5bef14dbdc Swatches: user swatch model with .aco/.ase import and export (FILE-215-14, part of #215) (#886)
- photocraft-psd: clean-room .aco (v1/v2, UTF-16 names, RGB/HSB/CMYK/Lab/Gray at 16 bits,
  other spaces kept verbatim) and .ase (groups, global/spot/process, RGB/CMYK/LAB/Gray floats,
  unknown models kept) readers and writers; byte-stable round trips, hostile input -> Err.
- engine: presets::swatches (colours in their own model, display/foreground conversion through
  photocraft-cms working spaces, persisted with the prefs, default 40 colours in 4 groups) and
  swatch_cmds (swatches.list/add/delete/rename/move/newGroup/renameGroup/deleteGroup/use/reset/
  import/export) with graceful-failure tests.
- ui: swatches_ui.rs replaces the const grid (groups, tooltips, click = foreground and selected
  type, Alt/Ctrl-click = background, rename/delete/move, panel menu Import/Replace/Export/Reset),
  Color Picker "Add to Swatches", .aco/.ase via File > Open and drag-and-drop; strings translated
  in every complete catalog.
- Scorecard FILE-215-14 -> done.

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:43:01 -04:00
harrisonoconnorhover bf8d52ca74 Fix Paint Bucket sampling across visible layers 2026-10-09 07:39:55 +07:00
MarekSpodBiedry 12b014f677 Edit › Toolbar: hidden tools leave the toolbar (#1116) 2026-10-09 07:39:51 +07:00
MarekSpodBiedry 7842e57085 Select › Modify refuses a radius below the dialog's minimum instead of clamping it to 0 (#993)
modify() rejected only a non-finite radius or one above the maximum, then
clamped with r.max(0.0). A negative or zero radius reported success: Border
cleared the selection, Expand and Contract did nothing, and Feather 0 was
accepted although its documented range starts at 0.1.

Each command now checks the range in its params string (Border 1..200,
Smooth/Expand/Contract 1..500, Feather 0.1..1000). A radius outside it,
including NaN and infinity, returns BadParams naming that range, and the
selection is left as it was. The clamp is removed.
2026-10-09 07:39:48 +07:00
kayrockscreenprintingandClaude Opus 5.5 9af9df8275 PNM: the file, not the header, bounds what a decode reserves (#1110) (#1365)
ASCII PGM/PPM reserved a u32 (four bytes) per declared sample before
reading any, against a Limits budget of one or two bytes per sample, so a
19-byte header could ask for 12 GiB. With Limits::none() a 2³¹ × 2³¹
header panics with "capacity overflow"; PBM reserved 2⁶² bytes before
finding the raster missing and aborted. ASCII reservations are now capped
by what the remaining bytes can hold (a PBM digit is at least a byte, a
PGM/PPM sample at least a digit and a separator), and binary PBM checks
the raster length before reserving.

Limits::none() documents that it is still bounded by usize overflow
checks, but the decoder multiplied w × h × channels, n × 2 (16-bit
samples) and the PFM raster length unchecked. A new sample_count checks
the product, and the length checks use checked_mul.

Tests: a header alone reserves nothing (P1-P4), u32::MAX sizes are errors
(P3, P5, P6, PF, P7), and ASCII files with no slack (PBM digits without
separators, no trailing whitespace) still decode. The first two failed
before the fix: an abort on the 2⁶² reservation, "capacity overflow" for
the P2/P3 headers, and "attempt to multiply with overflow".

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 07:39:45 +07:00
MarekSpodBiedry 6cb36f3ddb New Document: a size typed in millimetres or another physical unit keeps its digits (#1147) (#1378) 2026-10-09 07:39:42 +07:00
harrisonoconnorhover d1ff8920d5 Test Export As scaling through real PNG exports 2026-10-09 07:39:39 +07:00
ragtuxandClaude Fable 5.1 a7587f6270 Export As: a scale that changes only the height is applied (#1353)
export_document scaled the width alone and skipped Image Size when the
rounded width matched the original, so a 1 × 100 document exported at
50% stayed 1 × 100 while the dialog promised 1 × 50. Scale both sides,
send both to image.imageSize, and let the size label use the same
rounding (never below 1 px) so it names the exported size.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-09 07:39:39 +07:00
2f42b1cc80 Camera Raw opens in a window of its own (#1361)
* Camera Raw opens in a window of its own

The Camera Raw dialog (Filter › Camera Raw Filter and the open-time
dialog) was a full-window overlay drawn over the PhotoCraft window,
covering its title bar: it looked like a fullscreen app without window
controls. Adobe Camera Raw is a separate window.

Show it in an egui immediate viewport: an OS window titled "Camera Raw
(name)" / "Camera Raw Filter (layer)", 1360 x 900 by default, resizable
(minimum 900 x 600). The dialog stays modal: the main window is dimmed
and takes no input while it is open. Closing the window is Cancel (for
the open-time dialog that closes the raw being opened). Where the
platform has no extra windows (ctx.embed_viewports(), the web build) it
is drawn over the main window as before, with its painted title strip.

The drawing code is unchanged apart from the title strip, which is not
painted in a window of its own.

* Camera Raw window: scrim theme token, close-is-Cancel test, document the screenshot gap

- theme::Tokens gains `scrim` (the dim over the main window behind a modal
  that takes all input); Camera Raw uses it instead of a hard-coded colour.
- closing_the_camera_raw_window_cancels: the overlay fallback keeps the
  dialog open, a close request on its window cancels without history.
- control-protocol.md: eframe 0.36 cannot screenshot an immediate viewport,
  so ui.screenshot shows the dimmed main window while Camera Raw has its own
  window; agents read it via ui.inspect.cameraRaw and see it offscreen with
  the snapshot example or kittest (which embed viewports).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:39:15 -04:00
BFlatandClaude 4477cf6bdf Fix #1380: the web build asks before leaving the page with unsaved work (#1424)
Closing or reloading the browser tab dropped unsaved documents without a
word, while the desktop app asks before closing its window. The web shell
now registers a beforeunload listener that has the browser show its own
"Leave site?" prompt while a document has unsaved changes (the new
PhotocraftApp::has_unsaved_work, refreshed every frame).


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

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:12 +07:00
BFlatandClaude 15e4a00991 Selection tools: ⌥ with nothing selected draws a new selection (#1364)
With #1106's engine change, subtract or intersect with no selection
selects nothing. The UI turned ⌥ (and ⇧⌥) into subtract (intersect)
even with nothing selected, so ⌥-dragging a marquee or lasso on a clean
canvas would select nothing; in Photoshop ⌥ then only draws the marquee
from its centre. A modifier-made subtract or intersect is now a new
selection when nothing is selected; an options-bar Subtract or Intersect
is kept, and the cursor badge follows.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:09 +07:00
BFlatandClaude acb57c169f Fix #1387: the Type tool's font, style and size controls show in every theme (#1414)
Outside the Pro themes the options bar showed only the hint "Click to add
text" for the Type tools, so Studio, Studio Light and Classic had no way to
pick a font while typing. The Type options bar now shows in every theme,
as the Move tool's Auto-Select does since #1275.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:06 +07:00
BFlatandClaude 980cf75492 Fix #1354: the macOS menu bar is modal under dialogs, like shortcuts (#1413)
Native menu clicks went straight to menus::invoke, so a menu command ran
beneath an open dialog: rotating the canvas under Image Size left the
dialog's stale size to be applied on OK. While a dialog, the unsaved
changes prompt or Camera Raw is open, the native rows are now disabled and
a click that raced the update is dropped; Zoom In/Out, Fit on Screen and
100% stay live, as their shortcuts do.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:03 +07:00
BFlatandClaude e2a760f68d Fix #1371: read large macOS display ids, keep ICC bytes out of the error (#1412)
NSScreenNumber was coerced with `as integer`, so a CGDirectDisplayID above
AppleScript's integer range (2^29) came back as a real, "2.077748985E+9".
parse_reply rejected it, dropped the only display, and the error quoted
the whole raw line, base64 ICC profile included, in the Display profile
notice and Color Settings.

The helper now asks NSNumber for stringValue; the parser also accepts an
exact scientific-notation id; and the error names only the start of the
first field, capped at 80 characters.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 07:38:00 +07:00
BFlatandClaude Opus 5.5 7238b4100d Move tool moves the selected pixels when there is a selection (#578) (#1068)
* Move tool moves the selected pixels when there is a selection (#578)

With a selection, a Move-tool drag (from anywhere) or arrow-key nudge now
floats the active layer's selected pixels (select.float) instead of moving
the whole layer: the hole is transparent, or the background colour on the
Background layer; Alt copies the pixels instead of duplicating the layer;
Shift constrains the drag to 45 degrees; Enter, Deselect or any other
command drops the piece as one "Move" history step. Layers whose pixels
can't float (type, shape, smart object, group, position-locked, hidden, or
several selected layers) and Auto-Select without a selection behave as
before. select.float gains "copy"; a click that moves nothing no longer
leaves a floating piece behind.

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

* Pipette cursor test: Windows draws the precise crosshair itself

input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 07:37:57 +07:00
Claude 38c97bdc5d Layers panel: test the Blend Mode dropdown's arrow keys with the Move tool active
The open dropdown takes ↑/↓ ahead of the Move tool's nudge, checked in
the full app. The arrow-key step reads the option with get() and uses the
response's own id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 07:37:54 +07:00
Rozaq 4f99e42dcb fix: support arrow keys in dropdowns 2026-10-09 07:37:54 +07:00
BFlatandClaude 8208761a7c Fix #1288: keep edits to a video frame when the playhead moves (#1395)
Paint, fills and filters edit a video layer's displayed raster, but the
frame stack kept the old pixels, so scrubbing or Render Video re-synced the
stale frame over the edit. Write the displayed content back into the
playhead's frame (video_cmds::store) before any navigation, frame edit,
new video layer or render.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 07:37:51 +07:00
BFlatandClaude 88a47dc7c7 Fix #1248: Create and Release Clipping Mask act on every selected layer (#1392)
With several layers selected, Layer › Create Clipping Mask (⌘⌥G) now
clips every selected layer except the lowest, which becomes the base,
and Release Clipping Mask releases them all, as in Photoshop; each is one
history step. A layer param still acts on that layer alone. The command
returns the layers it changed.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 07:37:48 +07:00
UnemarsandClaude Opus 5.5 ce1da78d93 Color Balance: Photoshop's slider gestures, Alt+Cancel Reset, Properties Reset and history (#1256)
Measured on Photoshop 25.4 (Image > Adjustments > Color Balance and the adjustment layer's
Properties): a double-click on a slider (knob or track; Windows double-click counting) zeroes it
in the shown tone, whatever modifier is held; clicks and drags ignore Shift/Ctrl/Alt; the
dialog's sliders step 1 per wheel notch (Shift 10), the panel's ignore the wheel. Holding Alt
turns the dialog's Cancel into Reset (every tone 0, Midtones shown, Preserve Luminosity kept,
dialog stays open). In the Properties panel consecutive edits are one history step, and Reset
first returns to the settings the panel found, then to the defaults. Up/Down in the fields are
every value field's since #1326 (1, Shift 10, as Photoshop); the tests check them on Color
Balance, below zero too.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 20:35:44 -04:00
f5c3d37c5a Add layer and group color labels (#1283) (#1289)
* Add layer and group color labels (#1283)

* i18n(pl): layer colour label rows for the Polish catalog now on main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:32:09 -04:00
Ebisuzawa KurumiandClaude Opus 5.5 43b4742e3f GPU startup: a failed CPU start no longer pins later starts to CPU
A start that returned an error kept its gpu-starting.json marker so the
next start would try a safer backend. On the CPU path there is no safer
backend: the marker made every later start plan CPU again and fail the
same way (seen under XWayland, where the GL adapter can't present: "no
graphics adapter found"), until the file was deleted by hand.

Keep the marker after an error only when a safer backend exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:26:55 +07:00
dde8fcddce Curves: add black, gray, and white point eyedroppers (#873)
* feat(curves): add black gray and white point eyedroppers

* fix(curves): preserve luminance with gray-point eyedropper

* Curves eyedroppers: drivable by the command's eyedropper param (rule 1)

image.adjustments.curves and layer.newAdjustmentLayer.curves accept
{"eyedropper":{"point":"black|gray|white","at":[x,y]}} (samples the merged composite) or
{"point",…,"color":[r,g,b]}, resolved by the same curves_eyedropper the dialog uses, so agents
and the CLI can set black/gray/white points without ui.pointer. Bad points, positions outside
the image, transparent samples and malformed values are errors. The picker swatches are the
tone each picker maps to (image data, like the Curves gradient bars), drawn as
Color32::from_gray(level) with a token ring.

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

* i18n(pl): translate the Curves eyedropper strings

The Polish catalog landed on main after this PR was written; the
translation-coverage tests need every language to carry new UI strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:26:33 -04:00
d2ad238e82 PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1406)
* PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1405)

ag-psd, GIMP and other writers leave a PSD type layer without image data and
let Photoshop re-render it on open. The importer made those empty pixels the
layer's cache, so the canvas showed nothing until the layer was edited, and
the family guessed from the PostScript name ("CC Wild Words" for
CCWildWords-Regular) never got corrected to the installed family
("CCWildWords"), so Missing Fonts replaced an installed font.

- crates/io/src/psd_import.rs: a type layer's record pixels are the cache only
  when there are some.
- crates/io/src/text_import.rs (new, run by import): resolves each run's
  PostScript name with FontDb::resolve_postscript, as the layout does, and
  writes the installed family, weight and italic back into the run; renders
  every type layer that has no cache from its model.
- crates/io/tests/psd_type_bounds.rs: a type layer exported without pixels
  imports rendered, on the engine's own pixels; "JetBrainsMono-Regular" (guess
  "Jet Brains Mono") imports as the family "JetBrains Mono".

Fixes #1405

* PSD import: cap the automatic type render for untrusted sizes

text_import::prepare now renders a type layer without pixels only when its
ink fits MAX_RENDER_PIXELS (64 Mpx); the renderer's own guard is 256 Mpx,
which for an absurd type size or scale in a hostile PSD meant several GB of
f32 accumulation buffers on open. Over the cap the layer keeps empty pixels
until edited, as before. Test: hostile_type_sizes_without_pixels_import_without_a_huge_render
(an ink between the two caps, plus 1e30 pt, a 1e12 scale and f64::MAX).

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

* rustfmt (module order, the hostile-size test)

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

* PSD import: keep empty type pixels when the engine text is blank

cargo xtask test-corpus: rendering every pixel-less type layer regressed the
Photoshop oracle text/path-wave-open.psd (133 -> 132, below the floor).
Photoshop wrote that layer with no pixels because its EngineData text is
blank (the characters sit only in the descriptor's Txt ): it draws nothing,
and the import must not draw it either. New photocraft_text::psd::
engine_text_is_blank; the oracle passes again (133).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:22:25 -04:00
SckraTandClaude Opus 5.5 2619cd2200 fix(ui): wrap New Document preset titles inside their cards (#1401)
Preset card titles were painted with Painter::text, which lays out a single
unwrapped line centred on the card, so translations longer than the 164 px
card (ru "Размер Photoshop по умолчанию", es "Tamaño predeterminado de
Photoshop", ...) spilled past both edges. Lay titles out wrapped to at most
two lines, elide anything longer with a hover tooltip carrying the full name,
and move the size line down to make room.

Test: every preset title in every language fits the card in two lines
without eliding.

Also adds my contributors/people.toml entry.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 20:20:24 -04:00
b8235e132a Layers panel: Alt-drag a row to copy the layer there, as in Photoshop (#1216)
* Layers panel: Alt-drag a row to copy the layer there, as in Photoshop

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:16:39 -04:00
01752be81a Lasso: Alt clicks add straight segments, releasing Alt closes the outline (#888)
* Lasso: Alt clicks add straight segments, releasing Alt closes the outline

- With no selection, Alt held at the first press starts straight segments
  (there is nothing to subtract from) instead of latching subtract; with a
  selection, Alt at the first press still subtracts.
- While the button is down the outline follows the pointer, Alt or not: an
  Alt click adds a straight segment, an Alt drag a freehand stretch.
- Releasing Alt while the button is up closes the outline and makes the
  selection (no double-click needed).
- No subtract badge for Alt when nothing is selected.
- The Polygonal Lasso no longer draws handles on its vertices.

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

* Lasso: clicking the last point again closes the outline

A second click on the last straight-segment point (however long after the
first, not a double-click) closes the outline and makes the selection, like
a click on the first point.

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

* Polygonal Lasso: ⌥-drag draws freehand into the polygon

With the Polygonal Lasso, holding ⌥ while the button is down draws a
freehand stretch into the polygon; releasing ⌥ goes back to straight
segments and the polygon stays open. ⌥ at the first click with nothing
selected starts a new selection (nothing to subtract from), and shows no
subtract badge.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 20:12:16 -04:00
a7f6c2b71c I18N: Localize startup notices and welcome text (#1094)
* I18N: Localize startup notices and welcome text

Translate startup guidance when it is drawn so it follows language changes. Add welcome and notice-overflow strings to all language catalogs, with a Spanish/English regression test.

Validation: 697 UI tests, fmt, all-target clippy, layers, wasm,
i18n coverage and a fresh native startup screenshot.

* I18N: the notice test reads the catalogs without changing the shared language

The interface language is process-wide (i18n::CURRENT), and the regression test
for translated startup notices set it to Spanish, which other UI tests running
in parallel draw with. It now looks the strings up with an explicit language
(i18n::tr) instead.

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

* I18N: Polish rows for the startup notice strings

pl.tsv landed on main; the coverage test needs every catalog to translate them.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:08:07 -04:00
b0680fd421 Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (FILE-215-16, part of #215) (#903)
* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)

Blending Options > Advanced Blending now works end to end:

- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
  interior effects as group, blend clipped layers as group, transparency
  shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
  model and written back (in place, or added when not at the defaults);
  the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
  (isolated groups and clipping groups stop a deep knockout), clipped
  layers blended individually, effects over the whole layer when
  Transparency Shapes is off, effects built from an unmasked copy when a
  mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
  five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
  Advanced Blending, Blend If with split sliders), translated.

psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.

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

* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.

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

* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard

#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 20:08:03 -04:00
4cb7cf3931 Layered TIFF: the review changes of #672 (keep unknown blocks with their byte order, io behind the engine, fixture attribution) (#835)
* Review: keep unknown blocks with their byte order, io behind the engine, fixtures documented

Addresses the review on #672:

* Unknown layer blocks read from a little-endian TIFF are no longer dropped. Their bytes are
  kept verbatim with the signature `MIB8` (`46B8` for `8B64`) marking the little-endian
  layout (`TaggedBlock::is_foreign_order`), so a save back to TIFF restores them exactly. A
  malformed known block is kept the same way. Only a save to the other byte order has to drop
  such a block, with a warning: a PSD export strips them (`strip_foreign_order_blocks`), and a
  big-endian block of unknown layout still cannot go into a little-endian TIFF.
* The UI no longer depends on `photocraft-io`: the layer predicate is exposed by the engine
  (`file_cmds::tiff_would_write_layers`); io is a dev-dependency again.
* The layered TIFF path ends in the same `encode_image` as every flat export, so export-wide
  metadata policies (such as #733's XMP choice) apply to its composite too.
* Scorecard FILE-215-7 is `partial`: extra alpha channels and a Quick Mask are not written.
* The committed fixtures get an ATTRIBUTION.md row and a README describing their contents and
  how they were generated (psdtags + tifffile); the Python generator script is removed.

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

* tiff_corpus: ask for layers explicitly (headless default is flat since #825)

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

* i18n: drop the duplicate Korean "Ambient" entry that fails bundled_catalogs_are_consistent

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

* i18n: drop the duplicate Korean "Warmth" entry as well

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

* agent_tasks: tolerate Windows holding the test directory open during teardown

The three tests that remove their directory right after cancelling the client failed on Windows (CI and locally) with os error 32: the spawned server task is still closing its files. The cleanup now retries briefly and otherwise leaves the directory for the next run's tmp() to clear, as it already did at the start.

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

* preset store: a missing directory is an empty store on Windows too

DirBackend::read passed the OS message through, and Windows words a missing directory as "cannot find the path specified (os error 3)", which missing_file() did not recognise, so attaching a store on a path that does not exist yet (first launch, and two engine unit tests) reported actions.json as skipped. The backend now reports every NotFound as "<name>: not found", the wording the predicate and the memory backend already use. Regression test: opening a store on a nested missing directory warns about nothing and writes nothing.

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

* Review: keep scripts/layered_tiff_fixtures.py, drop the fixtures README and attribution rows

The four .tif fixtures those described were never committed (binary test data stays out of the repo; the tiff_layers tests read them from the gitignored corpus/layered-tiff/, written by the script that tiff_layers.rs:181 still points to).

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

* automation: the Direct Selection path commands edit vector paths, not files

#865 added path.moveAnchors, path.moveHandle, path.bendSegment and path.convertPoint, whose registry docs mention the vector path they return, so registry_filesystem_path_params_are_classified fails on main (Linux and Windows): they join the list of commands whose "path" is a document path.

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

* i18n(cs): the lower-case "{n} layer|{n} layers" plural (main red since #829)

xtask's source_key_set_is_covered_by_complete_catalogs fails on main: the key reached every catalog but the Czech one. Same forms as the capitalised twin already in cs.tsv.

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

* Drop the duplicate path.* entries from the workspace test (main already lists them)

main already has path.moveAnchors/moveHandle/bendSegment/convertPoint in this match; the
duplicates trip unreachable_patterns.

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-08 19:57:19 -04:00
869ecf3cff Red Eye tool in the J-group flyout (#1084)
* Add the Red Eye tool to the J-group

Click a red pupil to neutralize the flash reflection. Pupil Size
sets the search window around the click; Darken Amount controls how
far corrected pixels go toward a darker, desaturated pupil.
Catchlights stay white. A miss reports an error instead of a silent
no-op. The kernel only reads a window plus a 5 px mask-filter halo.

* paint.redEye: write back only changed pixels, so CMYK/Lab stay untouched

write_rgba re-encoded every pixel of the click window through the naive
RGBA round trip, which regenerates K in CMYK and shifts Lab values. Now
untouched pixels keep their original channel data; only pixels whose RGB
changed are re-encoded. Adds a CMYK test checking untouched pixels inside
the window stay bit-exact.

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

* algo::redeye: cargo fmt

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

* Polish catalog rows for the Red Eye tool; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage tests need every catalog to translate new UI strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:57:14 -04:00
Elvis33LE 5ecc860ac1 ui.set validates every field before applying the first one (#820)
* ui.set validates every field before applying the first one

ui.set applied fields as it validated them, so a call like
{"tool": "zoom", "theme": "nope"} changed the tool and then replied
ok:false - the caller is told the call failed and assumes nothing
changed, but the app state moved. Nested objects were worse: a non-object
"panels"/"dockTabs" skipped the merge silently and still replied ok:true,
and an unknown nested key (a typo like "layers2") was inserted and then
dropped by the serde round-trip, also with ok:true - the guarantee of
(selectionMode: "add", brushSize: "big", center: [1]) were silent no-ops
too, and brushSize passed raw floats through (`1e300 as f32` = inf).

All fields are now validated and materialized into locals first; the
apply pass cannot fail, so a rejected call applies none of its fields.
Nested objects reject non-object values and unknown keys (the known set
is the serialized current state, so it tracks the struct automatically),
scalars are type-checked, and brushSize clamps into the Brush Settings
panel's range instead of producing an unusable or infinite tip.

* ui.set: validate-first on rebased main; brushSize keeps the journaled dispatch

Rebased onto main and reworked per review:

- brushSize keeps main's journaled `tools.setBrush` dispatch (#744) with
  its semantics intact ("large"/null/non-numeric = the historical silent
  no-op with ok:true, f64::MAX = the command's error). The size is
  validated in the first pass against MAX_BRUSH_SIZE, so the journaled
  command cannot fail after earlier fields were applied.
- colorPanel (#664) joins the validate-first pass through a new
  whole_object helper (unknown nested keys rejected, value replaces).
- dock gets the same unknown-nested-key check (replacement semantics,
  like main).
- center requires exactly [x, y] again: 3+ elements are an error, not a
  silent skip.
- merged_object (merge semantics, panels/dockTabs) and whole_object
  derive the known-key set from the serialized current state, so new
  struct fields are automatically known.

* CI: retrigger ubuntu (only failing job; its logs expired unretrievable, corpus/windows/macos/wasm32 green and the full suite is green locally)

* rebase: colorPanel, brushPicker and brushPickerView join the validate-first set
2026-10-08 19:41:12 -04:00
53985e5f34 Color Lookup: keep the .cube input domain and reject the ones we cannot apply (#1130)
* Color Lookup: keep the .cube input domain and reject the ones we cannot apply

DOMAIN_MIN/DOMAIN_MAX (and LUT_3D_INPUT_RANGE) were parsed and thrown
away, so a log-domain or extended-range look was applied to plain 0..1
inputs: silently wrong colours, exactly what the in-code comment
admitted. The domain now travels with the parsed LutFile (validated:
every component finite and min < max), and colorLookup refuses a
non-default domain with the reason instead of shifting the look -
the same unsupported-over-wrong-output call the codecs make. Honoring
a custom domain end to end means carrying it through ColorLookup
(doc, compose, gpu, PSD round trip) and stays open as the follow-up.

* tests: the colorLookup domain rejection needs a pixel layer active

The outer session of the test still had the adjustment layer active, so
the command was disabled before reaching the domain rejection - the
assertion only passed when the active layer happened to differ. Make it
deterministic with a fresh pixel layer.

* Color Lookup: bake a 1D .cube's input domain into the resample

The 1D branch of parse_cube still ignored DOMAIN_MIN/MAX and
LUT_1D_INPUT_RANGE. Since the 1D table is resampled onto a 0..1 cube anyway,
the domain is now applied there (saturating outside), and a degenerate
domain is rejected like the 3D one.

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

* cargo fmt after rebasing onto main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:41:07 -04:00
Elvis33LE 9182f8f76d undo/redo report the step's damage instead of recompositing the whole canvas (#810)
* undo/redo report the step's damage instead of recompositing the whole canvas

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

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

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

Rework per review (correctness of the bounded claim):

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

Tests: undo of Global Light, of image.mode.indexedColor, of a clipped
toggle and of an excluded channel each report None.
2026-10-08 19:41:02 -04:00
Brandon ThomasandClaude Opus 5.5 9d3447b153 Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1489)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:34:20 -04:00
Claude b71991e15a Batch layer.moveTo test: make the target group before B moves into G
layer.new.group inserts above the active layer, which was B inside G, so
Target was created inside G and moving G into it was rightly refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy d3632f361b style: remove extra blank line after deleted helper 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 38257f0209 style: apply rustfmt layout for multi-layer drag handler 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 408975423c test(ui): verify multi-selected group drop payload and single-layer fallback 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy bc2b0a3ca6 test(engine): batch group move, stable stacking, undo/redo, nested selections and atomic rejection 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy aec1aa1980 refactor(engine): share cycle checks through batch move implementation 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy b3b5801a73 feat(ui): drag all selected layers into existing groups with a single drop (#1245) 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 4b14baf4f8 feat(engine): expose batch layer.moveTo through the command registry 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 6fbdbdb396 feat(engine): move multiple selected layers in one atomic layer.moveTo operation (#1245) 2026-10-09 06:20:20 +07:00
才倾梦华V 8492f14620 Keep the active document when closing another tab (#1148) 2026-10-09 06:14:12 +07:00
BFlatandClaude 10f3d9efad Pipette cursor test: Windows draws the precise crosshair itself (#1437)
input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 06:13:47 +07:00
006e1a3432 i18n: translate the Fill dialog, layer style contours and Image Size "(was …)" (#1291)
* fix(i18n): translate the Fill dialog labels, contour names and Image Size "(was …)"

The Fill dialog painted its row labels and the Contents options without
going through the catalog, the layer-style contour presets had no catalog
entries, and Image Size formatted "(was …)" in English. i18n-coverage only
sees tl! literals, so these stayed English in every language while it
reported 100%.

Wrap the Fill labels and the Image Size template in tl!, add the missing
keys to every catalog, and add a live test that checks each complete
catalog covers the Fill options and CONTOURS and that the dialog draws in
the selected language.

* chore(contributors): add my real name to the About credits

* i18n: Polish rows for the Fill dialog, contour presets and Image Size

pl.tsv landed on main after this PR; the translation-coverage tests need
every catalog to carry the new strings.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:10:19 -04:00