Commit Graph
9303 Commits
Author SHA1 Message Date
David Mann dee552a817 Merge pull request #9284 from davidmann4/fix/crop-click-snap
Crop: a click on the frame or a handle never snaps it (#9283)
2026-11-06 03:20:20 +01:00
David Mann 5d10956221 Merge pull request #9261 from davidmann4/fix/image-size-resolution-range
Image Size: refuse a resolution outside 1..30000 ppi instead of clamping it (#9260)
2026-11-06 03:16:20 +01:00
David Mann 7122a21fec Merge pull request #9670 from davidmann4/fix/svg-markup-check
SVG: a DTD entity can't hide nesting from the depth check (#9669)
2026-11-06 03:15:57 +01:00
David Mann 03f9f53b13 Merge pull request #9213 from davidmann4/fix/crop-hidden-resample-budget
Crop W x H budgets the hidden pixels it resamples (#9212)
2026-11-06 03:15:14 +01:00
David Mann b81932c64a Merge pull request #9224 from davidmann4/fix/crop-drag-tool-switch
Crop: a tool switch during a frame drag ends the drag (#9223)
2026-11-06 03:14:21 +01:00
David Mann b7b61addcd Merge pull request #9276 from davidmann4/fix/9275-crop-esc-mid-drag
Crop: Esc in full screen cancels a drag on the default frame (#9275)
2026-11-06 03:13:52 +01:00
David Mann 908afc7bb3 Merge pull request #5533 from davidmann4/fix/5532-blend-separators
Layers: separators between the Blend Mode groups (#5532)
2026-11-06 03:12:28 +01:00
David Mann 5b9900e52e Merge pull request #9310 from davidmann4/fix/crop-drag-autoscroll
Crop / Perspective Crop: a drag held past the canvas edge scrolls the view (#9307)
2026-11-06 03:10:57 +01:00
David Mann 0b9a113414 Merge pull request #9672 from davidmann4/fix/svg-fallback-cmap-cache
SVG: the fallback font search reads each font's character map once (#9671)
2026-11-06 03:10:12 +01:00
Zinadin ZidanandClaude Opus 5.5 3d60ad01f9 Channels: the panel menu has the channel commands, New Spot Channel among them (#11451) (#11557)
* Channels: the panel menu has the channel commands, New Spot Channel among them (#11451)

The Channels panel's menu had no channel items, so New Spot Channel... and
the rest could only be reached from a row's right-click menu. It now lists
New Channel..., Duplicate Channel, Delete Channel, New Spot Channel...,
Merge Spot Channel, Channel Options..., Split Channels and Merge
Channels..., each acting on the targeted channel and greyed where it can't:
Duplicate takes a single colour, alpha or spot channel, the composite of a
one-channel image, the Quick Mask or a targeted layer mask. Channel
Options... in Quick Mask mode now opens the targeted alpha or spot
channel's options, not Quick Mask Options. The panel menus run their
commands through the menu bar's enabled rules, so the Layers panel menu
greys its dialog items the same way too. The labels are in every catalog.

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

* Channels: New Channel... and Merge Channels... ask first, from the panel and row menus (#11451)

The panel menu's Merge Channels... ran channel.merge with no parameters, and
the engine merged the first grayscale documents into RGB and closed them with
no choice; New Channel... added a channel straight away. Both now open a
dialog when called without parameters, as New Spot Channel... and Channel
Options... already did: New Channel... reuses the Channel Options dialog
(name, colour indicates, colour and opacity), and a new Merge Channels
dialog picks the mode and the open grayscale document for each channel. The
channel rows' menu had the same two items and goes through the same path.
With parameters (scripts, the New button) both still run at once.

The Merge Spot Channel test now checks pixels, so a delete couldn't pass it.

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

* Channels: Merge Channels waits for three grayscale documents and the dialog offers only modes they fill (#11451)

With two grayscale documents open, Merge Channels... was enabled and its
dialog repeated a document for the third channel; OK then failed in the
engine and the dialog closed. The engine's rule now needs three documents
of one size (no mode merges fewer), so the menus grey the item below that.
The dialog offers only the modes the open documents can fill (RGB and Lab
from three, CMYK from four), greys OK with the reason while the picks
repeat, keeps itself open when OK is refused, and comes back with the
engine's message if the merge itself fails.

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

* Channels: the Merge Channels dialog keeps its picks by document id (#11451)

The dialog kept the picked source documents by tab index, and background
work can still run while it is open, so a document moved or closed in the
meantime could leave another one at a picked index; OK would then merge,
and close, the wrong document. The picks are now document ids, resolved to
the current tabs when OK is pressed: a closed pick, or one that is no
longer a grayscale document of the same size, is refused with the reason
and the dialog stays open.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:20:08 +01:00
Zinadin ZidanandClaude Opus 5.5 9b02eac2c4 Export to files: a Custom JPEG quality in Artboards, Layers and Layer Comps to Files (#11481) (#11558)
* Export to files: a Custom JPEG quality in Artboards, Layers and Layer Comps to Files (#11481)

The engine commands already took `quality` (0..12), but their dialogs
offered no way to set it. Each dialog now has a Quality row with a Custom
checkbox, shared with Batch: quality is sent only for JPEG with Custom on,
so an export left alone writes exactly what it wrote before (the source
JPEG's own quality, else the encoder default), and PNG, WebP (still
lossless), PSD, TIFF and BMP never get one. Alt+Reset restores the Custom
state too. The fields follow the reference dialog's order.

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

* Tests: the export quality tests go through the dialog's own controls (#11481)

Custom and Quality are now ticked and typed through their widgets, a new
test switches one dialog from JPEG to PNG and WebP and back and checks the
Custom quality survives, and Alt+Reset is clicked through the same path the
app uses instead of calling reset_form.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:10:18 +01:00
David MannandClaude Opus 5.5 88704d2350 Merge fix-up: the turned crop's hidden pixels after Layer 0 replaces the Background
Since #10867 the non-deleting crop turns a 2000 px layer to 2001 px bounds, so
the budget refusal names 200100 x 200100 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:06:41 +01:00
David Mann 4b61c8ef34 Merge commit '9bef3b2dc8b84c1b6cefc8ce0dc2b01d089cd9e8' into HEAD
# Conflicts:
#	crates/algo/src/resample/tests.rs
2026-11-06 01:58:38 +01:00
David MannandClaude Opus 5.5 5b1b6cf13d Merge fix-up: check entity markup in main's bounded embedded-SVG parse too
nesting_depth now returns a Result; main's parse_image_bounded_with gets the
same entity-markup rejection as the other callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:49:40 +01:00
David Mann d563b3100b Merge commit '9bef3b2dc8b84c1b6cefc8ce0dc2b01d089cd9e8' into HEAD
# Conflicts:
#	crates/engine/tests/it/svg_smart_object.rs
2026-11-06 01:46:48 +01:00
David Mann 1d36dc5f0d Merge main into fix/5532-blend-separators 2026-11-06 01:40:39 +01:00
David MannandClaude Opus 5.5 82f5a11067 Address review: separators on main's ComboBox, without #5501
Merge main (0f15223e18) and rebuild #5532 on main's craft_ui::ComboBox:
#5501's hand-written combo_list and its call-site migrations (gradient
map, crop ratio, kerning, press_menu, the wheel and height tests) are
dropped in favour of main's versions; the list height stays #7622's.

The group lines are drawn inside ComboBox::show_ui: a 1 pt line that
takes no width during egui's sizing pass, above Darken, Lighten,
Overlay, Difference and Hue, never above the first row. Used by the
Layers panel's Blend Mode box (also the greyed one with no layer), the
Brush/Pencil Mode list (Clear starts no group), the retouch and Paint
Bucket Mode lists when they hold every blend mode (the Healing Brush's
short list draws none) and the Gradient's Mode list. The Brush Mode
right-click menu shares the same rule (starts_blend_group).

Tests: the Layers kittest at 1440x900 finds exactly the five group
boundaries; a widget test checks the first row gets no line and the list
keeps its width; a 1440x900 click released in place on the Blend Mode box
opens the list beside the box and changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:40:15 +01:00
David Mann 9bef3b2dc8 Merge pull request #6088 from davidmann4/fix/snapshot-followups
History snapshots: follow-ups to #3567 (#6087)
2026-11-06 01:32:54 +01:00
David Mann 0f15223e18 Merge pull request #7739 from davidmann4/fix/crop-swap-button-turns-frame
Crop: the options-bar swap button turns the frame instead of shrinking it (#7738)
2026-11-06 01:07:36 +01:00
David Mann bac5c2802b Merge pull request #9405 from davidmann4/fix/svg-guard
SVG: a drawing the renderer panics on is an error, not a crash (#9404)
2026-11-06 01:06:04 +01:00
David Mann a46eb590a9 Merge pull request #9410 from davidmann4/fix/svg-filter-cost-9409
SVG: refuse filters too costly to render (#9409)
2026-11-06 01:05:30 +01:00
e59dcc9c44 GPU plan: cache adjustment LUT rows between plans (#11498) (#11539)
* GPU plan: cache adjustment LUT rows between plans (#11498)

adjustment_program rebuilt the Levels/Curves tone tables on every plan
(twice per refresh), about 1-2 ms per Levels layer each time. Keep the
rows of Levels, Curves, Color Balance and Hue/Saturation ranges in a
32-entry most-recently-used cache (photocraft_compose::lut_rows, at
most 32 x 3 x 16 KiB = 1.5 MiB), built outside the lock.

Entries are keyed by a u64 hash of exactly what each row builder reads,
fields destructured in full and floats by bits (-0.0 and 0.0 stay
apart). Hue/Saturation master sliders don't add entries; Hue/Sat ranges
and Color Balance don't depend on depth. Edit > Purge > All drops the
cache, and a filled cache enables it.

Release, per call: Levels 8/16-bit 1.10 -> 0.003-0.018 ms, Curves
0.16 -> 0.018, Color Balance 0.11 -> 0.001-0.015, Hue/Sat ranges
0.05 -> 0.008-0.018. The tables are identical, so rendering is unchanged.

Tests: cached rows match fresh tables bit for bit at 8/16/32-bit, hits
and LRU eviction on a local cache, key coverage, and Purge All
releasing the cache.

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

* compose: rustfmt the LUT row cache tests (#11498)

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

* GPU plan: test that plans read cached LUT rows and that curve points key them (#11498)

Plant rows no builder makes under a fresh key and check the planner returns
them, and check that moving a Curves point changes its key (review on #11539).

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

* compose: prevent in-flight LUT builds from undoing purge

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-06 00:32:49 +01:00
e4e2607c85 Preview spot inks over the RGB/CMYK composite as an overprint (#10276)
* PSD: a flat file's named extra channels are not its transparency

A PSD without layer records had its first extra channel read as the merged
transparency, so the first spot or alpha channel of a flattened document
vanished and its ink punched a transparent hole in the image (psd-tools
cmyk-alpha-spot.psd, cmyk-spot.psd). When the alpha channel names (1045, or
1006 when 1045 is missing) name every extra channel and the first is not
"Transparency", there is none. The import also reads channel names from
1006 when 1045 is missing, and a spot whose DisplayInfo colour space is
unknown takes its ink from the Alternate Spot Colors (1067).

* channel_map: read 1067 records with as_chunks

* Preview spot inks over the RGB/CMYK composite as an overprint

Visible spot channels of RGB and CMYK documents print over the canvas
composite with the Multichannel ink formula, in Channels panel order,
instead of a tint; alpha channels and Quick Mask still tint over them.
Spot channels open visible, and the PSD thumbnail (1036) prints them.
flatten, export and the merged image stay free of spot inks. Ink
colours go through the document's CMYK profile, and into an RGB
document's own profile.

Refs #10249

* channel_view: test solidity over a gray picture

* Thumbnail: matte only inked pixels; test the merged planes directly

Review feedback on #10276: pixels without spot ink keep their
transparency in the thumbnail buffer, and the thumbnail test checks the
saved merged process planes instead of a re-imported flatten.

* Thumbnail spot inks: sample big boxes on a 4x4 grid

Review feedback on #10276: reducing each spot channel no longer scans the
whole document; a thumbnail pixel averages at most 4x4 samples, so a huge
document costs about h x 4 row reads per spot.

* Spot overlay: composite a proxy for the first draw of big documents

The overprint needs the composite under the inks. Rendering the full-resolution
composite to sample every factor-th pixel cost ~1 s for a 24 MP CMYK document.
A proxy document picks the same pixels (nearest-neighbour downsample) for about
1/factor² of the work. Documents with layer effects, vector masks or feathered
masks keep the full-resolution path.

* Proxy: downsample rows in parallel; the overlay proxy leaves out unused channels

The spot overlay's proxy shrank the spot channels, selection and Quick Mask that an
RGB or CMYK composite never reads: about two thirds of its cost. downsample now
reads its rows in parallel, 64 at a time.

* Spot overlay proxy: keep the full-resolution composite under uncached pattern fills

A pattern fill renders its tiles at their own size on a proxy, so the proxy would
sample other pattern pixels. Unless the fill's matching cache is shrunk with the
proxy, use the full-resolution composite.

* Spot overlay proxy: hidden layers don't decide the composite path

Hidden layers and the contents of hidden groups are not composited, so a hidden
feathered mask or pattern fill no longer forces the full-resolution composite
(the same rule as proxy_faithful).

* Update crates/compose/src/multichannel.rs

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>

* Spot overlay: only inked pixels replace the canvas; inks through the display transform

Where every visible spot is 0 the overlay stays transparent, so the normal
canvas (display LUT, proofing, 32-bit preview, full resolution) shows; an
empty spot renders no composite. Printed pixels go through the canvas
display transform (sRGB encoding of linear composites, monitor profile,
Proof Colors), keyed into the texture. Ink colours come from one InkSpace
per render: the document's profiles are parsed once, not per spot per
damage rect.

* test(compose): cover hidden spot preview and document approximation

* fix(ui-egui): preserve solo spot channel display when process colours are hidden

Clicking a spot channel row in the Channels panel targets the channel alone
and hides all process colour channels (`vis == 0`). Overprint inks were
previously extracted from `overlays` before checking `vis == 0`, leaving
`overlays` empty and causing the solo channel view to fall back to an active
layer mask (or return `None` if the layer had no mask).

Keep the spot channel in `overlays` so `vis == 0` can select it as the solo
grayscale picture, separating overprint inks only when process colours are
visible (`vis > 0`).

Adds a regression test targeting an existing spot channel in RGB and CMYK on
an unmasked layer and editing it.

* channel_view: document the spot overprint preview as an approximation

* test(ui-egui): solo spot channel wins over an unrelated layer mask

* channel_view: sRGB spot inks, lazy display transform, inked-area composite

Review fixes for the spot overprint preview:

- RGB spot colours are sRGB, like the CMYK, Lab and gray ones: InkSpace now
  converts them into an RGB document's own profile too. In a linear (EXR/HDR),
  Adobe RGB or ProPhoto document an RGB ink showed the wrong colour (brightened
  in a linear one once the canvas sRGB-encoded it).
- The display transform is looked up only when the view is redrawn and inks
  are shown, not every frame before the cache check; display_transform_for
  borrows the proof state (proof_ref) instead of cloning a default CMYK profile.
- Visible spot channels without ink, with nothing else shown, draw no texture:
  render returns None and ensure remembers it, instead of uploading an
  all-transparent document-sized texture. A damage re-render that finds nothing
  clears its rect, so erased ink never lingers.
- Printing a few inked pixels composites only their bounding box when that is
  cheaper than the whole (proxy) composite, and a cached composite is borrowed
  instead of copied.

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

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:34:12 +01:00
e55e2f3577 Place decodes and fits in the background: dropping a large file no longer freezes the window (#10147)
* Place decodes, fits and renders in a background job

File > Place Embedded / Linked started with Session::start, and
Session::start_place_bytes for bytes with no path, now run the decode,
flatten, resize and SVG render on a worker (prepare_place) and insert the
layer when the job is applied (apply_place). Inline callers (execute, the
CLI, headless MCP, actions) behave and return exactly as before. Part of
#10031.

* Dropped and placed files no longer freeze the window on desktop

With background jobs on, File > Place and files dropped on the canvas start
the place as a job (JobsUi.place). Free Transform begins when the layer has
landed, a failed or cancelled place is reported and the drop queue goes on,
and a drop that arrives meanwhile waits for it. The place shows its progress
in the status bar instead of the modal dialog, so the view stays usable.
Part of #10031.

* Tests: compare place job results without the layer id

Layer ids come from a counter shared by every session, so a job and an inline place in two sessions never get the same one.

* Dropped files stay with the document they were dropped on

Each queued canvas drop now carries its document's id. A file is placed
only while that document is active: files dropped on a document in
another tab wait until it is back in view, and are discarded with a
message when it is closed. Web drops, read by the shell after the drop,
go to the document the drop landed on.

Before, the next queued file went into whichever document was active
when the previous place finished, failed or was cancelled, in the
background and inline paths alike.

* Remove the unused file_open import

* fix: address background placement review feedback

* fix: preserve drop targets and interrupt placement pixel stages

* fix: qualify placement inbox document identity

* test: wait for background placement in app drop regression

* fix: retain queued drops while external document jobs run

* test: clone rendered pixels from drop-managed buffers

* test: cover band rows channels and queued-drop resumption

* Place decodes and fits in the background: dropping a large file no longer freezes the window

Placing a file decoded, flattened and resized on the UI thread; a large PNG froze the
window for seconds (#10031). `place_bytes` is split into a session read, `prepare_place`
(decode, flatten, fit, translate, SVG render at placement scale) and `apply_place`, and
runs through `jobs::run`, so File › Place and canvas drops run on a worker with progress
and cancel. The window follows the job: status-bar progress, Free Transform when the layer
lands, and drops queued per target document.

Review fixes on the way:
- a path and byte drop queue entry records its target DocId, so files dropped on one
  document never land in another after a tab switch, and closed documents discard theirs;
- the closed-document notice stays up for a frame instead of being cleared by the next
  queued placement;
- the queue is bounded (32 files / 256 MiB) so drops waiting on another tab cannot grow it
  without limit;
- flatten and resize stop at the job's interrupt, and a place skips the modal progress
  dialog only when the status bar actually shows its progress;
- the placement's `doc` is kept only for SVG input, through the importer's `is_svg_input`;
- the interrupted-composite test uses an opaque fixture that varies by row and channel, and
  checks the composite against the layer's stored pixels.

* Place reads its file in the job, and refuses a huge canvas as before

file.placeEmbedded and file.placeLinked read the whole file in the
command before the job started, so a path dropped on the canvas or
picked in File > Place still blocked the window for the read. The place
now carries the path and reads it in prepare_place: on the job's worker
in the background, on the calling thread inline (wasm, background jobs
off), with the same read_file limit and error text. Whether the source
is a vector one is decided once it has been read.

The job's flatten check returned Other("the document canvas is too large
to flatten"), where the place used to give flattened_checked's
BadParams for file.place naming the canvas size. Both now share
check_flattenable, so the old error type and message are back.

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

* Path drops aren't capped, and a discarded drop wakes the queue

The 32-file cap on the drop queue counted path drops, which hold no
file contents while they wait, so dropping 40 files on the canvas
placed 32 and refused 8 where main placed them all. The count cap now
applies to browser drops only (each holds its whole file); the byte
budget still bounds those.

When place_next_dropped discarded a closed document's files it returned
without asking for another frame, so another document's queued files
waited for the next input event. It now requests a repaint when files
remain, keeping the one frame that shows the notice.

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

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:49:52 +01:00
5c157e8400 Build the GPU compositor off the UI thread (#9969)
* Prepare GPU compositor in background

* Add compositor startup performance scenarios

* Generate scorecard in scratch CI

* Fix pipeline build type inference

* Address compositor startup review and document measurements

* Run startup performance checks on scratch CI

* Remove scratch performance job

* Count failed GPU attempts in frame timing

* Make shader fingerprint test portable

* Address review: run first-document scenario as a guarded group; share COMPOSITOR_BUILDING constant

* Handle compositor startup benchmark failures

* Fix frame timing after upstream merge

* Keep GPU startup failures on CPU fallback

* Record the failed GPU attempt of a deferred redraw, and bound --reps

A redraw deferred after a failed GPU composite carries that attempt's time to its end, where it is
recorded once beside the CPU time. Perf::record clears the attempt so a direct CPU refresh doesn't
inherit the last one's. perf_scenarios caps --reps.

* Refresh cached canvases after compositor build

* Expose canvas cache key to GPU completion handler

* ci: drop the GPU compositor probe job

AGENTS.md: feature and fix PRs don't add or change CI, and app workflows
only call the shared craft-common-actions workflows. The probe example
stays as a manual tool (cargo run -p photocraft-gpu --example
compositor_build_probe).

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

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:49:12 +01:00
c441fd350c perf(paint): sampled, textured and wet-edge dabs rasterize on all cores (#11369)
* perf(paint): sampled, textured and wet-edge dabs rasterize on all cores (#11350)

The general dab loop runs its rows on the thread pool, with the same pixels as serially.
It reaches the pool from about 16,000 pixels, a quarter of the round path's threshold.

* test(paint): the parallel-vs-serial depth test checks colour, not alpha, for painted pixels

The opaque background's alpha (1.0) is 0.5 away from 0.5, so the check
passed even if nothing was painted (CodeRabbit on #11369).

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

* test(paint): read pixels with as_chunks (clippy chunks_exact_to_as_chunks)

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

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:40:33 +01:00
6326117c53 Web build: big operations that won't fit fail with an error instead of trapping, and a trap says so (#10129)
* Raster: ask the allocator whether a big operation fits; PSD save reserves its whole output

New photocraft_raster::headroom: ensure(need) tries to reserve the need plus a margin and frees it
at once (web only; native always passes), below(bytes) tells whether less than that can be
allocated, and simulate_available fakes the answer for tests and diagnostics.

PsdFile::to_bytes reserved only the pixel data, so embedded smart-object data and other tagged
blocks grew the output by doubling, which on the web is one unrecoverable huge realloc. It now
reserves the exact encoded length.

* Big operations that won't fit in memory fail with an error instead of trapping

Place, Image Size, a smart-object re-render and Save now ask the allocator whether their estimated
need can be allocated before they start (photocraft_engine::memory_gate, estimates per operation,
photocraft_io::estimate_export_bytes for saves). A refusal changes nothing and says what the
operation needed and what to do (save, Edit > Purge > All, close documents).

A standing notice warns when less than 512 MiB can be allocated, with hysteresis, so the user can
save before anything traps. Notices can now be sticky, which keeps them past the auto-hide delay.

* Web build: a trap says so instead of freezing the page, and ?free-mb simulates low memory

index.html holds a hidden, static 'PhotoCraft stopped' message with a Reload link. web.rs reveals
it when the module traps (the window error event, or an unhandled rejection from a spawned
future), tells running out of memory from an internal error by the panic message a chained panic
hook kept, and skips the Leave site prompt once stopped. ?free-mb=N makes the memory gates and the
low-memory warning see only N MiB, for checking them in a browser.

* Docs: web build memory handling in the gaps list and the parity assessment

* Engine: use is_multiple_of in the megabyte formatter

* Cargo.lock: the web shell depends on photocraft-raster

* Web boot page: carry the stopped message too, as the test that keeps the two pages equal requires

* Save check counts one copy of the document, and runs once per save

The encoded file's buffer is reserved fallibly, so a save it doesn't fit
in fails with an error instead of trapping; counting it as well refused
saves that would have fitted. On the background-save path the check ran
in write_document and again in start_save, which other callers reach
directly; it now runs only in start_save there.

* Review fixes: warning check allocates nothing, PSD merged image counted, automation saves checked

The low-memory warning's periodic check reads the size of linear memory
instead of reserving a block: wasm memory never shrinks, so a trial every
few seconds raised the high-water mark by up to 768 MiB for nothing. The
operation checks keep their trial reservation.

PSD and PSB saves always build a canvas-sized merged image (a placeholder
without maximum compatibility) and hold it raw and encoded; the save
estimate now counts it. The synchronous automation save checks memory
before encoding, as File > Save does, and its test asserts the memory
error.

The trap listeners go in before the app is built, so a trap while
building it shows the stopped message too, and a memory access out of
bounds reads as unexpected rather than as running out of memory.

* Cover layered PSD preflight limitation

* Fix layered PSD test iteration

* Docs: layered PSD preflight limitation in the gaps list

* Web OOM gate: gate direct exports, truthful advice for unbounded PSD sources (#10024)

* Web OOM gate: estimate layered PSDs per layer instead of refusing every real PSD

The PSD check admitted a file only when its color-mode data, image
resources and layer section were all empty. Every Photoshop file has
image resources (at least the resolution), so on the web nearly every
real PSD was refused for Place, PSD smart objects could not render,
and Image Size was refused for documents holding one, whatever the
free memory (u64::MAX never fits).

The color-mode data and image resources hold no pixels, so they are
now only skipped. A layered file reads its layer count from the layer
info (signed, absolute value) and adds a canvas-sized surface and mask
per layer to the flat estimate. Files that are not 8-bit RGB or
grayscale, keep their layers in a Layr tagged block, or whose sections
can't be read stay unbounded and refused, as before.

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

* Web stopped page: match trap messages ignoring case

Safari reports the allocation-failure abort as "Unreachable code
should not be executed" and Firefox says "out of memory", which the
case-sensitive matching missed: the trap was not recognised, or was
shown as an unexpected stop instead of running out of memory.

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

* Export estimate: count the merged image OpenRaster writes

The ORA writer keeps the layers but also flattens the document for
mergedimage.png, a 16 B/px composite of the canvas, which the
estimate left out.

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

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:04:59 +01:00
a3c0b7258c Select Subject: keep parts that run off the image edge and detached parts near the subject (#10160)
* Select Subject: keep parts that run off the image edge and detached parts near the subject

The backdrop pass trained its colour model on the whole frame outside the first
result's box and fixed it as background, so a subject cut off by the edge (a
portrait's torso) taught the backdrop its own colour and was dropped. Frame
pixels of a side that the rest of the frame cannot explain are now left out of
the model and left to the cut; a floor or table running up the sides stays
backdrop.

The first pass also dropped every part under a fifth of the largest, losing a
head behind a thin neck or a tail. Non-speck parts next to the main one are kept.

* Select Subject: stray edge pixels stay backdrop; cache test checks the recompute

A lone odd pixel in the frame (noise, a dead pixel) is not the subject running
out of the picture: only patches of at least half a frame square are released.

The #2865 cache test asserted a bit-identical selection after painting a corner
pixel. One changed pixel can move the cut's edge slightly (the original
algorithm already did on other noise), so it now checks that the mask was
recomputed and still selects the subject.

* Test subject selection against edge and nearby clutter

* Keep narrow edge clutter out of subject selection

* Preserve cropped subject components at image edges

* Keep edge poles and diagonal streaks out of subject selection

A detached part counted as near the subject when its bounding box
overlapped the main part's margin box. A thin diagonal streak from a
corner (a cable, a rail) has a box that reaches the margin without any
of its pixels doing so, and was kept as part of the subject. A part is
now near only when some of its pixels lie inside the margin box.

A patch that runs along an image edge for two frame widths was released
from the backdrop model whatever its colour, so a pole or door frame
along a side edge next to the subject's shoulder was left to the cut,
which could grow the selection along it. A wide patch is now released
only where its extent along the edge overlaps the first-pass blob's
margin box. Beside the subject that box is cut out of the frame, so the
pole only remains above or below it and is not released, while a torso
cut off below a head (the case the release exists for) still lies under
the head's box. Requiring the subject's colour for wide patches too, or
requiring them to touch the box, would drop exactly that torso: its
colour is not in the head-only blob and it is far from the head's box.

The margin box computation moves into margin_box so subject_at_edge
and its tests share it. Tests pin the diagonal streak, the pole beside
the shoulder (on all four edges, with the cropped torso still
released) and clutter that is too far or too small.

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

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:04:32 +01:00
f127459ca3 perf(algo): build Remove Tool target masks with a bounded separable dilation (#10179) (#10194)
* perf(algo): build Remove Tool target masks with a bounded separable dilation (#10179)

* docs(algo): remove::dilate no longer always runs two full-grid passes

* test(algo): shrink the square dilation tests

Exhaustive masks up to 3x3 instead of 4x4, drop the 300x200 named grid
(the smaller grids already clip at r = 200, so its oracle-cost guard goes
too), and run the path-choice tests on 600x400 and 240x240 grids.

* test(algo): a mask with more set cells than bounds keeps matches the oracle

Covers the branch where bounds drops its cell list (more than KEEP set
cells) and the direct path rescans the box.

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

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:04:08 +01:00
fcb4f4e494 Grow and Similar: budget the colour index and report allocation failures (#9825)
* Select: Grow and Similar index colours under a byte budget and fail instead of aborting

ColorMatcher::new returns a Result, the colour tree is a flat node array reserved at its exact size, and Grow/Similar's mask and frontier stack use fallible allocation so a failure keeps the selection.

* Select: a 1 GiB colour index on 32-bit, and Sample All Layers reports a failed composite

The web build's 256 MiB index budget (about 4.2M colours) refused Grow and
Similar on a noisy 24 MP photo whose index fits easily; 1 GiB admits every
distinct colour of such a photo. The 64-byte charge per colour is an average,
not a worst case, so the comment now says so, and the point list the tree keeps
gives back its doubling slack once the dedupe set is gone.

Sample All Layers renders the composite inside compose, so the selection tools
now pass an allocation checkpoint before it, like the other commands that hand
big work to another crate, and a failure there keeps the selection.

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

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:03:48 +01:00
3317aede3b Fix Paint Bucket target with Gradient-style crosshair (#7994)
* Mark Paint Bucket fill target with a lower-right crosshair

* Unify Paint Bucket artwork and crosshair in one cursor bitmap

* Match Paint Bucket target crosshair to Gradient cursor

* Keep Paint Bucket cursor errors concise and fix Windows Clippy

* Simplify blend preview condition for strict Clippy

* Fix deprecated IDs in Linux bucket cursor test

* Paint Bucket: a cursor error clears when the cursor recovers

The failure branch wrote its error to the status bar, but nothing cleared
it after a later scale worked, and the same failure could not report again.
The open dialog stays until it is closed: it reports what happened.

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

---------

Co-authored-by: envy-ai <bart.kelsey@osumc.edu>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:27:28 +01:00
David Mann 405b56c3c2 Merge pull request #11320 from davidmann4/integration-batch-17
Integration batch 17: 14 epic PRs
2026-11-05 18:02:17 +01:00
16eef63294 Type: a duplicated type layer is renamed after its new text (#9023) (#10352)
* Type: a duplicated type layer is renamed after its new text (#9023)

A type layer's name follows its text until the layer is renamed (#483),
but only while the name is exactly the text. A duplicate is "Title copy"
(or "Title copy 2"), so editing its text kept the old name. A name that
is the text plus Duplicate Layer's " copy" or " copy N" now counts as
named after the text too, so the copy takes its new text's name; any
other name is still kept.

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

* Type: a copy of a "… copy" type layer follows its text too (#9023)

Duplicate Layer names a copy of "Draft copy" "Draft copy 2", so the
check now compares the names' roots (the name before " copy" or
" copy N") rather than adding " copy" to the text's name.

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

* Type: a copy keeps its "… copy" name until its text changes (#9023)

A type layer named after its text was renamed after every edit through
the type helpers, so with "Title copy" now counting as named after its
text, Change Type Size, a style-only or kerning edit, Convert to
Paragraph or Point Text, Area Type and Type on a Path renamed the copy
"Title". The name now follows the text only when the text changed; a
layer named exactly after its text gets the same name as before.

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

* Type: a "… copy" name the user typed is kept when the text changes (#9023)

A type layer named "Title copy 2" by hand, with "Title" as its text,
counted as a Duplicate Layer copy and was renamed after its next text
edit. The document now remembers the names the user typed (Rename
Layer, a name Duplicate Layer's dialog didn't offer, type.edit's name)
for the session, and such a name is kept. A name Duplicate Layer made
still follows the text, and so does one an undone rename brings back.

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

* Type: a name given in the Layers panel or by type.create is the user's (#9023)

The Layers panel renames in place with layer.setProps, and type.create
takes a name: neither recorded it in typed_names, so a copy-shaped name
typed there ("Title copy 2" on text "Title") was replaced by the next
text edit. Both now record it, as Rename Layer and type.edit's name do.

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

* Type: a name typed for a duplicate into another document is the user's (#9023)

Duplicate Layer into a new document and Copy Layers to Document take a
name but returned without recording it, so a copy-shaped name typed there
was replaced by the next text edit. A name other than the copy's own is
now recorded in the destination document, as the in-document duplicate
does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 17:27:14 +01:00
Luis daa9a01955 Release: refresh contributor credits for each release (#8737) (#11426)
About > Contributors still showed a snapshot from 2026-10-10, because
nothing regenerated contributors/contributors.json before a release.
Opt in to the shared refresh from craft-common-actions: a read-only
credits job regenerates the snapshot for the release commit, and the
shared release workflow installs it before every source build. The
snapshot is not committed back.
2026-11-05 17:23:30 +01:00
FredAppsandClaude Opus 5.5 4943e2c3da fix(ui): About credits table columns can be dragged wider or narrower (#11182)
* fix(ui): About credits table columns can be dragged wider or narrower

The Contributors and Models tables already open fitted to the dialog, the
name column taking the spare width, and scroll sideways only when there is
not room for every column. Their columns were fixed, though: no edge could
be dragged. They are resizable now, down to 28 points (the flexible column
to its minimum). Widening a column grows the table, as in a spreadsheet,
and only then does it scroll sideways; cells clip instead of overlapping.

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

* About: refit credits tables when what they measure changes

The columns became resizable, so the table keeps its own widths. A longer
name, a changed cell or a resized window left the old widths in place and
clipped text. The table now goes back to its fit when a column measure or
the clip rect width changes, which scrolling never moves.

Adds tests that drag every column edge, change the fit and scroll a
widened table, as CodeRabbit asked for the drag.

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

* Tests: a fitted credits table fills the window exactly

The table ends on the window edge, which the layout rounds to whole points,
so the check allows that edge.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:23:15 +01:00
David Mann 5e7a097d10 Merge commit '20d561715b' into integration-batch-17
# Conflicts:
#	crates/engine/src/cmd/transform_cmds.rs
2026-11-05 17:20:07 +01:00
David Mann 37f9d3b7d4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/codecs/src/codecs/png.rs
2026-11-05 17:18:28 +01:00
Khawar Zamman Wani 33e20b5707 Add recipe-driven checks for PSD exports (#10307)
* Add recipe-driven PSD export checks

* Exercise PSD recipe runner with the headless CLI

* Make PSD oracle tests isolate temporary paths

* Keep inspect output unchanged; PSD write runner as an xtask library

- info --detail adds text runs, anti-aliasing and full layer-effect settings;
  the control channel and MCP inspect output is unchanged.
- The CLI test uses xtask's new library target instead of #[path] includes.
- The runner records revision and dirty state as unknown outside a git checkout.

* psd-write-oracle: name the layer in each check, document versioning, add recipe tests

* Clean up PSD oracle test fixtures on failure with tempfile::TempDir and add docstrings
2026-11-05 17:18:03 +01:00
David MannandClaude Opus 5.5 7f151b6542 algo: filter admission counts no more workers than tiles
Since 42e92cb512 the Shape Blur admission also counts each worker's f64
prefix sums, but it charged every rayon thread even when the area has
fewer tiles. A 24 MP Shape Blur at radius 500 (24 tiles of 1024 px) was
charged for 32 workers and refused under the CLI's default 8 GiB policy,
which main still ran (5.4 GB peak measured). Workers are now capped at
the tile count, for every radius filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:14:19 +01:00
Khawar Zamman Wani f54d605e2c Add recipe-driven checks for PSD exports (#10307)
* Add recipe-driven PSD export checks

* Exercise PSD recipe runner with the headless CLI

* Make PSD oracle tests isolate temporary paths

* Keep inspect output unchanged; PSD write runner as an xtask library

- info --detail adds text runs, anti-aliasing and full layer-effect settings;
  the control channel and MCP inspect output is unchanged.
- The CLI test uses xtask's new library target instead of #[path] includes.
- The runner records revision and dirty state as unknown outside a git checkout.

* psd-write-oracle: name the layer in each check, document versioning, add recipe tests

* Clean up PSD oracle test fixtures on failure with tempfile::TempDir and add docstrings
2026-11-05 17:11:56 +01:00
834e253377 Canvas: a right-click that moved a few pixels still opens its menu (#10183) (#10351)
* Canvas: a right-click that moved a few pixels still opens its menu (#10183)

egui reports a secondary click only within its click distance of the
press, and a high-DPI mouse moves that far just from the button going
down: the right-click then counted as a drag, and the Brush Preset
picker, the canvas tool menus and the layer menu did not open. A right
press released over the canvas now opens them however far the pointer
moved, unless a gesture used that drag (Alt+right brush resize, a
right-erase stroke).

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

* Canvas: the Hand pans with the left and middle buttons, not the right (#10183)

The Hand tool panned on a drag with any button. Now that a right-click
that moved still opens the canvas menus, a right drag with the Hand
panned and then opened its view menu on release. In Photoshop the Hand
pans with the left button and its right-click is the view menu, so a
right drag now opens that menu without panning; the left button, Space
and the middle button pan as before.

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

* Canvas: a right-click moved and released in one frame opens its menu (#10183)

When the move past the click distance and the release reach egui in the
same frame (a slow frame), it reports neither a click nor a drag stop,
so the menu still did not open. The canvas now keeps its own right press:
one that went down over it and comes up over it that frame is a
right-click too, unless a gesture used it (Alt+right resize, a right-erase
stroke). The menus open where the button came up.

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

* Canvas: a right-click counts only when it was pressed on the canvas itself (#10183)

The canvas's own record of a right press checked only that the press
was inside the canvas's rectangle. A right press on a popup over the
canvas (the Brush Preset picker, a tool's canvas menu) that moved off it
and came up in the same frame reopened the popup where the button came
up, and every right-click on a canvas scrollbar opened the picker or the
tool's menu.

A right-click past egui's click distance now needs the press to land on
the canvas itself, as a still right-click does: under no popup or
window, and not on a scrollbar or another control drawn over the
canvas. The release must come up on the canvas the same way. A right
press that an Alt+right brush resize or a right-erase stroke used still
opens nothing.

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

* Canvas: a right button that took part in a pan is no right-click (#10183)

Making the Hand pan with the left and middle buttons only went too far:
Space and a right drag no longer panned with any tool, and the selected
Hand stopped panning with the back and forward mouse buttons. Only the
selected Hand's right button has another use, its view menu. Now the
Hand pans with a drag of any button, except a drag with the selected
Hand's right button on its own, which still opens the view menu where
the button comes up.

A right button that took part in a pan no longer counts as a
right-click when it comes up. Letting go of Space before the right
button at the end of a Space+right pan used to erase a dab (Erase
preference) or open the Brush Preset picker; it now just ends the pan.
A left pan with the Hand with the right button pressed in, either way
round, no longer opens the view menu.

A right-click is the right button on its own: a right press made while
another button is down, or one joined by another button, only counts as
the still click egui itself reports. A right press moved during a left
brush stroke opens no picker again, as before #10183.

The canvas now follows its right press on every frame, panning or not.

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

* Canvas: Escape while the right button is down cancels the right-click (#10183)

Pressing Escape during a right drag that had moved past the click
distance did not cancel it: when the button came up, the Brush Preset
picker or the canvas tool menu still opened. The same happened after
Escape closed a Shift+Alt+right HUD Color Picker and the button was
then let go somewhere else. Before #10183 nothing opened in either
case, because a moved right drag was never a click.

Escape pressed while the canvas follows a right press now cancels that
press, so its release opens nothing. The canvas reads the Escape from
the frame's own key events, so a shortcut or a popup that already took
the key does not hide it. A still right-click that egui itself reports
is unchanged.

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

* Canvas: Space and a right drag pan with the Hand selected too (#10183)

With the Hand tool selected, holding Space and dragging with the right
button opened the Hand's view menu instead of panning. Before #10183 it
panned, as Space and a right drag still do with every other tool.

Space is the pan itself, so with Space held a right drag now pans with
any selected tool, the Hand included, and the pan uses the press: no
view menu opens when the button comes up. Without Space, the selected
Hand's right drag still opens its view menu without panning. Ctrl or
Shift with the Hand's right button are unchanged.

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

* Canvas: a moved right-click points where the button went down (#10183)

A right-click on a guide with the Move tool that moved a few pixels past
the click distance, across the guide, opened the menu of the layers
under the pointer instead of the guide menu. The canvas menus found
their target where the button came up, and by then the pointer was off
the guide. Before #10183 such a click opened nothing.

A right-click that the canvas counts itself, past egui's click distance,
now points where the button went down: that is what was right-clicked.
Every canvas menu finds its target there (a guide, the layers under it,
the pasteboard, a pending crop) and opens there too, so the Brush
Preset picker, the canvas tool menus and the Hand's view menu open at
the press for such a click. A still click that egui reports itself is
unchanged and opens at the pointer as before.

The tests for moved right-clicks now expect the picker where the button
went down. The comment on right_click_at now spells out the whole rule,
including that a still press held past egui's click time counts when
the button comes up, as a system context menu does.

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

* Canvas: a right drag past a few pixels is no right-click (#10183)

The canvas counted a right press released on it as a right-click
however far the pointer had moved while the button was down. A right
drag of hundreds of pixels that no gesture used opened the Brush
Preset picker or a canvas menu, back at the press, far from the
pointer. Before #10183 such a drag opened nothing. With the Hand
selected, a fast Space+right flick, too quick for egui to see as a
drag, opened the Hand's view menu instead of panning.

A right press now counts as a click only while the pointer stays
within three times egui's click distance of it, 18 points by default.
That covers the few pixels a high-DPI mouse moves as its button goes
down, or a slow frame's move, and is well short of a deliberate drag.
Every pointer position between the press and the release counts, also
the moves inside one frame, so a drag out and back near the press is
no click either. Within that distance menus still open at the press.

The tests for moved right-clicks now move within that distance, about
14 points instead of 30 to 70. The tests where a gesture, Escape, a
popup or another button stops the click also move only a few pixels
now, so that the distance alone does not make them pass. The comment
on right_click_at states the limit.

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

* Canvas: letting go of Space mid right drag keeps the Hand panning (#10183)

With the Hand selected, Space and a right drag pan. Letting go of Space
while the right button was still down ended that pan: the view went on
gliding with the button held (flick panning), and moving the mouse no
longer panned. Before #10183 the mouse kept panning until the button
came up, because the selected Hand's right drag always panned.

A right drag that Space made a pan now stays a pan until the right
button comes up, as the held Hand already does for the left button.
Letting go of Space first keeps the mouse panning and starts no glide,
and the release still opens no view menu. Without Space, the selected
Hand's right drag still opens its view menu without panning. Other
tools are unchanged.

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

* Canvas: a scroll or zoom while the right button is down cancels the right-click (#10183)

A right-click that moved a few pixels opens its menu where the button
went down, and finds what was right-clicked there: a guide, the layers
under it, the pasteboard. The canvas kept that point on the screen.
When the view scrolled or zoomed while the button was held, a
different part of the image was under that point by the release, so
the menu was for whatever had moved there, not for what was pressed.

The canvas now notes its view when a right press goes down: the zoom,
the centre, the rotation, the flip and where the canvas is. If any of
it changes before the button comes up, by the wheel, a zoom or
anything else, the release is no right-click and opens nothing. A
still click that egui reports itself is unchanged.

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

* Canvas: the Hand's right drag past a few pixels pans again (#10183)

With the Hand selected, a right drag within a right-click's reach opens
its view menu without panning. Once the cap made a longer right drag no
right-click, that drag did nothing at all: it neither panned nor opened
the menu. Past the reach it is a drag, so it pans as it did before
#10183, and its release opens nothing.

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

* Canvas: each painting tool's Shift+right-click menu reads the moved right-click (#10183)

Main gave the Pencil, Clone Stamp, Eraser, Dodge, Sponge, Paint Bucket, Gradient, Mixer
Brush and Healing Brush their own Shift+right-click menu, and the HUD Color Picker now opens
with the current colour under the pointer. Both go through the canvas's right-click record:
a Shift+right-click that moved a few pixels opens the tool's menu where the button went
down and nothing else, a long Shift+right drag opens nothing, Spot Healing keeps its picker,
and a HUD press released after a small move, on the HUD or just off it, opens no menu.
These tests hold that on the rebased branch.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 16:29:42 +01:00
dde01384d2 perf(io): stream large PNGs row by row into the layer's tiles (#10259) (#10305)
* perf(io): build JPEG/PNG tiles straight from the decoded rows, in parallel (#10259)

Surface::try_write_rows_with fills tiles row by row on all cores; image_surface
converts from the borrowed decoded rows into it (no per-band copy, no staging
Image). Opaque sources skip the prune scan. import_flat_with checks cancel after
the decode and the tile build and reports progress.

* fix(io): store an F16 target as F32 so its opaque alpha is 1.0

An image without alpha converted to an F16 target got the alpha 0.0: the
opaque-alpha path copied two bytes of 1.0f32. The surface keeps 16-bit float
as 32-bit float anyway, so image_surface now converts an F16 target to F32.

* Format fused tile import integration

* perf(io): decode PNG rows straight into the layer's tiles (#10259)

* test(codecs): PngSuite rows test skips files without a PNG signature (#10259)

* perf(io): stream PNG rows only for images of 32 MiB or more (#10259)

* Update crates/codecs/src/codecs/png.rs

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>

* fix(io): name the image, not the failed reservation, in PNG/JPEG allocation errors (#10259)

A failed surface reservation is one tile or row band, so reporting its bytes as
"a N MB image" was misleading (usually "0 MB"). Report the image's pixel size.

* refactor(codecs): the caller picks the PNG row-group height (#10259)

decode_png_rows takes the rows per group instead of a fixed 256, and io
passes TILE_SIZE, so the groups can't drift from the tile rows they fill.
A group of 0 counts as 1.

* Complete PNG streaming integration with upstream TIFF imports

* Preserve F16 conversion and route default imports through interrupt wrapper

* fix(io): check free memory before PNG tiles, and cancel TIFF imports after the decode (#10259)

import_png_streamed refuses an image whose tiles and one row group the machine
can't hold before the first tile exists, as tiff_to_document does; the
whole-image path (the base PNG path among it) checks the layer's tiles before
allocating them, for images above the streaming size, so a small image still
opens where the reserve alone would have refused it.

import_flat_with now honours the interrupt after a successful TIFF import, as
the whole-image path does; a decode error is still reported as itself.

* fix(io): pass lazy memory probe to PNG importer

* fix(io): handle scratch read errors during flat import

* test(io): respect early TIFF import cancellation

* fix(io): thread ImportOptions decode limits into PNG row streaming

After merging main, the whole-image open path respects
ImportOptions::flat_max_pixel_bytes via decode_limits, but the
streamed PNG path still used DecodeOptions::default(). Pass opts
through so both paths share the same decoder limits.

* fix(io): silence clippy needless_update on DecodeOptions; rustfmt

DecodeOptions only has limits + keep_orientation, so specifying both
and ..Default::default() trips -D clippy::needless_update. Also apply
rustfmt to the decode_png_rows call that gate's fmt check rejected.

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <fred@apps.local>
2026-11-05 15:10:21 +01:00
3e27572e7a Artboards: clipping groups move between boards together; pastes off the boards and into masks land right (#11441)
* fix(artboards): move a clipping group between boards as one (#10512 follow-up)

Dragging the base of a clipping mask onto another artboard left its
clipped layers on the old board, released, covering that whole board
(auto_nest moved the base alone and clip_group::settle released the
layers that lost it).

auto_nest now carries the layers clipped to a moving base along, in
order, directly above it on the new board (or at the top level), so
settle keeps them clipped. A clipped layer never changes board on its
own, moved or not: it shows only over its base, so it goes where the
base goes. When any layer of the group can't go (Prevent Auto-Nesting,
the group depth limit), the whole group stays. Still one history step.

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

* fix(paste): keep a paste from off the boards visible (#10538 follow-up)

Copying a layer that isn't on any artboard and pasting with a layer on
board B selected put the paste inside B, beside that layer, outside B's
area, so B hid it. The clip has no source board, so board_shift doesn't
move it onto B, and layer_place::insertion_point only checked the
content centre when the anchor was the board itself.

insertion_point now applies the same centre rule beside a layer on a
board: a layer edited right after it is made (Place::AutoEditable:
Paste, Place, Type, Shape) whose content is centred off the anchor's
board goes above the board at the top level, as it already does with
the board itself selected. Content centred on the board (Paste at the
view centre there) still lands inside it. Place::Auto (New Layer, fill
and adjustment layers) is unchanged: those layers are empty or cover
the canvas.

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

* fix(paste): shift a mask or channel paste between artboards (#10538 follow-up)

Pasting into a layer mask, alpha channel or Quick Mask (paste_to_target)
still placed the pixels with paste_offset alone, so with the target
layer on another artboard they no longer landed where a layer paste
does: at the old spot on the source board (in place) or the middle of
the view.

paste_to_target now applies board_shift first, as paste does, for Paste
and Paste in Place alike; Paste Into / Outside keep centring on the
selection (board_shift skips clampToCanvas: false).

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

* fix(artboards): name the auto-nest plan entry type (clippy type_complexity)

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

* Keep the board shift for a mask paste into a position-locked artboard; an off-board paste above an artboard lands unclipped

- board_shift skips the position-lock guard when the paste adds no layer (a mask paste), since mask edits don't check position locks.
- insert_new: a layer placed directly above an artboard is never clipped to it, and goes past the clipped layers above the slot.
- Tests for both; each fails without its fix.

* Paste into an alpha channel or the Quick Mask keeps document coordinates; only new content above an artboard is unclipped

An alpha channel or the Quick Mask covers the whole document and is on no
board, so the selected layer's board no longer shifts a paste into it. The
unclip-above-an-artboard rule in insert_new now applies only to
Place::AutoEditable (new content placed off a board), so a clipped layer
put Place::Above a board (the layers after a pasted board, a duplicate)
keeps its flag as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 14:53:49 +01:00
a3ea9a4277 Guides: integrate edit-session previews and interaction-check scenarios (#9190)
* feat(ui): integrate edit-session guide drafts and interaction-check scenarios

* fix(ui): repaint guide previews after automation field changes

* fix(guides): retain drafts after command refusal

* Restore the upstream Git-source check and keep guide replay local

* Document pending source approval and shared replay workflow

* Clarify that shared CI support for guide replays is pending

* Vendor the guide edit dependency for source review

Keep the edit-session revision and runtime behavior. Track its source, licenses, checksums and lifecycle tests in-tree in response to the source review in #9190. Apply the workspace lint policy to the standalone crate.

* Move edit-session into ui-egui as a module (drop vendor/)

The edit-session code now lives in crates/ui-egui/src/edit_session.rs,
with its tests in edit_session/tests.rs, following the project's module
layout. This removes vendor/edit-session, the path dependency, the
workspace member, the NOTICE entry and the xtask layers entry.

---------

Co-authored-by: Anton Pepperok <perchneko.anton@gmail.com>
Co-authored-by: Perchenko Anton <perchenkoanton@iMac-Perchenko.local>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 14:50:22 +01:00
David Mann a4a10166df Merge remote-tracking branch 'origin/main' into integration-batch-17
# Conflicts:
#	docs/gaps.md
2026-11-05 14:33:32 +01:00
Khawar Zamman WaniandFredApps a38e544faf Make Select and Mask refinement and previews tile-sparse (#10732)
* Make Select and Mask refinement and previews tile-sparse

* Preserve finite mask extent when copying sparse pixels

* Keep solid preview coverage shared during conversion

* Preserve alpha sharing and document sparse refinement limits

* Refine mixed tiles in bounded parallel batches

* Skip guide sampling for constant refinement halos

* Balance sparse refinement batches using measured queue size

* Preserve default alpha and cancel sparse refinement cooperatively

* Address review follow-ups: cancellation after completed flush, stroke assertions, and docstrings

* Fix merge: bind tile bytes to avoid temporary borrow in matting

---------

Co-authored-by: FredApps <fredapps@users.noreply.github.com>
2026-11-05 14:22:03 +01:00
David Mann b613b411b8 Merge remote-tracking branch 'origin/main' into integration-batch-17 2026-11-05 14:11:31 +01:00
David MannandClaude Opus 5.5 88beb84064 Address CodeRabbit: same-named actions in Batch/Droplet, Save and Close format
- Batch and Create Droplet dialogs get a Set dropdown (each set once, each
  action name once) and send the selected action's set, so a loaded library
  with one name in two sets runs the chosen set's action; createDroplet now
  honours `set` with a named action (dialogs.rs:512 finding).
- Droplet capture rewrites an ordinary call to the action's name only while
  that name is unique in the exported list; otherwise it keeps the exported
  index, so a call to the second of two same-named actions still reaches it
  (outside-diff finding on actions_cmds.rs:337).
- Save and Close refuses a source whose bytes Batch would write in another
  format (a Cineon source is encoded as PSD), instead of writing PSD bytes
  under the source's .cin name (batch_run.rs:918 finding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:11:29 +01:00
Khawar Zamman WaniandFredApps 384ff664d6 perf: JPEG/PNG open bench with per-phase timings and peak memory (#10259) (#10271)
* perf: JPEG/PNG open bench with per-phase timings and peak memory (#10259)

Adds the flat_open_bench example (import, admission, decode, orient, tiles,
first composite and editable per case, each case in its own process for its
peak RSS), registers it and scenarios P83 to P86 (not enforced) in
perf/budgets.toml, and re-exports image_to_document (doc-hidden) so the bench
can time tile construction. No change to import behaviour.

* fix(compose): preserve special Fill semantics for CMYK adjustments

* Clarify flat-open benchmark RSS provenance

* Run flat-open benchmark provenance regression in CI

* Place benchmark regression module after production items

* chore: drop leftover CMYK proptest seeds from measurement PR

Keeps #10271 scoped to the flat JPEG/PNG open bench. The compose seeds belonged to the CMYK Fill work that already landed via #11001.

---------

Co-authored-by: FredApps <fredapps@users.noreply.github.com>
2026-11-05 13:49:37 +01:00
274f6d3e26 MCP bridge: waited commands poll the app's job, with progress and cancel (#8130) (#8134)
* MCP headless: waited commands run as jobs, with progress and cancel (#8129)

Headless command_run and command_batch steps that wait (the default) ran
job-capable commands inline while holding the session lock. Cancelling a
long Gaussian Blur did not stop it: it still added its history step, and
doc_inspect and jobs_list queued behind it. No progress was reported.

A waited job-capable command now starts as a background job
(Headless::command_begin) and the server polls it with the lock released
(Headless::job_poll), so other calls are served meanwhile. With a progress
token it sends notifications/progress (strictly increasing, at most ten a
second; for a batch, finished steps plus the current step's fraction).
notifications/cancelled cancels the job and leaves the document unchanged;
a drop guard does the same when rmcp drops the request future. Headless
keeps the outcome of each waited job as sync_jobs sees it end, since
jobs.list keeps only the last few. Both backends share one batch loop,
which runs no further step once the request is cancelled.

Tool hints: jobs_list is read-only, doc_select is non-destructive and
idempotent, and the scoped exports (which refuse to overwrite) are
non-destructive. doc_save's description lost a run of spaces.

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

* MCP headless: cancel a waited job whose request was dropped before it started

The Abandon guard was created only after command_begin returned. A request
dropped while the start still waited for the session lock left the job it
then started running, and its `waited` entry behind. The guard is now armed
inside the locked closure that starts the job, so dropping the unread output
cancels it. Review feedback on #8132.

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

* MCP headless: job_poll applies finished jobs before checking the job

A caller polling Headless::job_poll directly, without sync_jobs in between,
kept seeing Running after the worker finished, because the session only
drops a finished job from its running list when its jobs are polled.
job_poll now syncs first. The MCP path already synced in headless_op.

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

* MCP headless: a cancel that loses the race returns the job's result

job_abandon ignored whether Session::cancel_job cancelled anything and
always dropped the stored outcome, so headless_wait answered `Cancelled`
for a job another request had already applied to the document. It now
returns how the job really ended: Cancelled only when it cancelled the
job, the job's result when it had already ended, and an error when the
job cannot be cancelled (printing) and is still running.

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

* MCP headless: waited commands run as jobs, with progress and cancel (#8129)

Headless command_run and command_batch steps that wait (the default) ran
job-capable commands inline while holding the session lock. Cancelling a
long Gaussian Blur did not stop it: it still added its history step, and
doc_inspect and jobs_list queued behind it. No progress was reported.

A waited job-capable command now starts as a background job
(Headless::command_begin) and the server polls it with the lock released
(Headless::job_poll), so other calls are served meanwhile. With a progress
token it sends notifications/progress (strictly increasing, at most ten a
second; for a batch, finished steps plus the current step's fraction).
notifications/cancelled cancels the job and leaves the document unchanged;
a drop guard does the same when rmcp drops the request future. Headless
keeps the outcome of each waited job as sync_jobs sees it end, since
jobs.list keeps only the last few. Both backends share one batch loop,
which runs no further step once the request is cancelled.

Tool hints: jobs_list is read-only, doc_select is non-destructive and
idempotent, and the scoped exports (which refuse to overwrite) are
non-destructive. doc_save's description lost a run of spaces.

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

* MCP headless: cancel a waited job whose request was dropped before it started

The Abandon guard was created only after command_begin returned. A request
dropped while the start still waited for the session lock left the job it
then started running, and its `waited` entry behind. The guard is now armed
inside the locked closure that starts the job, so dropping the unread output
cancels it. Review feedback on #8132.

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

* MCP bridge: waited commands poll the app's job, with progress and cancel (#8130)

A waited command_run or command_batch step of an engine command now starts it
with engine.execute {wait: false} (sent once) and, when the app runs it as a
background job, polls jobs.list until it ends. A job can run past the bridge's
55s reply timeout, its progress is forwarded, and cancelling or dropping the
request sends jobs.cancel. UI command ids keep wait: true. A failed poll or a
job no longer listed is reported with its id rather than guessed.

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

* MCP bridge: keep the cancel guard armed until jobs.cancel returns; clarify the docs

The cancel branch disarmed the Abandon guard before awaiting jobs.cancel, so a
request future dropped during that await sent no cancel. The control-protocol
table now says polling applies only to a waited command_run. Review feedback
on #8134.

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

* MCP headless: job_poll applies finished jobs before checking the job

A caller polling Headless::job_poll directly, without sync_jobs in between,
kept seeing Running after the worker finished, because the session only
drops a finished job from its running list when its jobs are polled.
job_poll now syncs first. The MCP path already synced in headless_op.

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

* MCP headless: a cancel that loses the race returns the job's result

job_abandon ignored whether Session::cancel_job cancelled anything and
always dropped the stored outcome, so headless_wait answered `Cancelled`
for a job another request had already applied to the document. It now
returns how the job really ended: Cancelled only when it cancelled the
job, the job's result when it had already ended, and an error when the
job cannot be cancelled (printing) and is still running.

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

* MCP bridge: a refused jobs.cancel is not reported as a cancellation

bridge_wait discarded the app's reply to jobs.cancel and always answered
`Cancelled`. The app refuses to cancel a job that is no longer running,
so a job that finished between the jobs.list poll and the cancel was
reported as cancelled although its edit may be applied. A refused cancel
now answers that the job was not cancelled and points to jobs_list.

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

* MCP bridge: a cancel during a failed poll still cancels the job

When notifications/cancelled arrived while a jobs.list poll was in
flight, and the poll and its retry then failed, bridge_wait disarmed the
Abandon guard and returned the poll error without checking the waiter,
so no jobs.cancel reached the app and the edit ran on after the caller
cancelled it. That error path now sends jobs.cancel first when the
request was cancelled. A failed poll without a cancel still leaves the
job running and names it.

Reported in review of #8134.

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

* MCP bridge: a cancel doesn't wait for a slow jobs.list poll

bridge_wait checked for a cancel only between polls, so a cancel that
arrived while the app was slow to answer jobs.list waited for the poll,
up to the 55s call timeout, before jobs.cancel was sent. The poll now
races the cancel.

That needs BridgeClient to drop the connection of a call abandoned
before its reply: it kept the one cached connection, so jobs.cancel
queued behind the unanswered poll on the same socket (the app serves a
connection's requests in order), and a half-sent request could share it.
A call now takes the connection out of the cache and returns it only
once answered, as a timeout or lost reply already dropped it.

The poll-error check from the previous commit stays for a cancel that
races a failing poll; its test is replaced by one for the slow poll,
which covers the reviewed case. A BridgeClient test checks that a
dropped call's connection is not reused.

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

* fix(automation): retain cleanup after failed bridge cancellation

* chore: restart CI for PR #8134

* test(automation): classify the smart object replacement MCP tool

* test(automation): synchronize dropped bridge waits and accept reconnections

* Fix CI after merging upstream

* Fix CI after merging upstream

* Deduplicate Arabic closure messages after upstream merge

* fix(engine): bound active and cancelling background workers

* Fix cancellation and draining in bridge merge

* Test release-aware draining cleanup

* Fix draining regression warning

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Khawar Zamman Wani <wanikhawar@Khawars-MacBook-Air.local>
Co-authored-by: FredApps <fredapps@users.noreply.github.com>
2026-11-05 13:35:13 +01:00