* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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.
* 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>
* 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
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>
* 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
* 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>
* 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>
* 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>
* 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>
From review: the engine tests check resolution and removal; the UI dispatch test alternate_keys_run_their_command now covers the two new keys as well.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- 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>
The reference application gives View > 100% a second key, Alt+Ctrl+0 beside Ctrl+1, and Window > Actions a second key, F9 beside Alt+F9. Both are now rows in ALTERNATE_SHORTCUTS (Cmd+Alt+0 in our notation, which is Ctrl+Alt+0 off the Mac), so the menus keep showing the main key and Edit > Keyboard Shortcuts lists and removes them like F2 for Cut.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* 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>
* 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>
An unclipped adjustment layer in a special blend mode at zero Fill still
round tripped its backdrop through brightness. In 32-bit float documents,
Linear Burn and Linear Light moved the inks by a rounding error (0.7 became
0.70000005), and an ink sharing the source's brightness took the source's
value. Zero Fill is the backdrop, so return before blending.
- A direct Batch in a script or a CLI command list stops it when it is cancelled,
cannot write its error log, or ran out of script steps; per-file errors alone
still continue (CodeRabbit, outside-diff findings on automate_cmds.rs and
photocraft-cli document.rs).
- Recorded Save steps count against the script's step budget, and a Batch whose
files failed with the budget spent carries scriptLimitError, so Browse reports
the failure instead of ok: true (yoriswind).
- actions.rename rewrites a named Batch, nested Play or Conditional call only if
it resolved to the renamed action before the rename; a Batch with explicit
steps is left alone (yoriswind: two same-named actions in different sets).
- Shape Blur's run admission counts each worker's f64 prefix sums and, for a
custom shape, the kernel mask kept for the run plus its worst-case coverage
runs (yoriswind).
- Every sampler that takes the primary release (Color Picker, Curves/Levels and
Properties eyedroppers, Curves Cmd-click, Color Range) ends a transform handle
drag and a Move drag begun before it, not only the Color Picker (#11210 review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main fixed the exposure formatting itself in be9f0c250, so what is left
here is the regression coverage the report asked for, asserted through a
real EXIF block: the two reported values (0.031714 s -> 1/32 s,
0.0215 s -> 1/47 s), a reciprocal that rounds to a whole denominator
(1/3 s), and the three-significant-digit decimal fallback for a time too
far from any whole denominator (0.2731 s -> 0.273 s).
Fixes#6958
Co-authored-by: highperfocused <highperfocused@pm.me>
main's #5595 split `split_selected` the same way — `a.min(k)` lifted,
`(a - k).max(0.0)` left behind — so its version stays and this branch's
goes. The two differ only in clamping a negative coverage, which the
selection mask cannot hold.
The regression test is not in main and still passes against its code: a
move, then a transform, with the soft edge taken whole and nothing left
at the old place. 119 float and transform tests pass.
Thanks @wanikhawar — merging.
**Summary:** In headless MCP, waited `command_run` / batch steps run job-capable commands as engine jobs (lock released while polling), with progress notifications and cancel/abandon on request drop. Fixes the “cancel does nothing / inspect blocks” behaviour for long filters. Cap of 16 background jobs; docs updated. Stack base for #8134.
Thanks @wanikhawar — merging.
**Summary:** Sampled brush tip mip pyramids are reused across strokes and symmetry through a bounded LRU (64 entries / 256 MiB); Edit ▸ Purge All releases the cache. Bench + purge/render tests lock the behaviour.
Thanks @wanikhawar — merging.
**Summary:** Grow and Similar now match selected colours via a BVH `ColorMatcher` (L∞ per channel) instead of one AABB that falsely included intermediate hues; only exact-zero alpha is the transparent wildcard. Contiguous Grow seeds from the selection frontier rather than every selected pixel. Tests + bench included.
Thanks @wanikhawar — merging.
**Summary:** While the Home screen owns the canvas this frame, hide the tool strip, options bar, status bar, docks, properties panel, and floating editor windows without mutating saved panel prefs. `home_screen_up()` is the single gate (opening jobs / docs win over Home). Adds `home_chrome_tests` for auto-show + toggle round-trip across themes.
Thanks @wanikhawar — merging.
**Summary:** Aligns CMYK special Fill / Posterize with the RGB compositor’s clipped vs unclipped split from #9501 (`fill_special=true` for unclipped adjustments so Fill changes the blend; clipped keeps Fill as coverage). Posterize goes through `posterize_in` with sample depth. Regression test covers Levels/Posterize across pass-through, isolated group, clipped-on-base, and combined stacks at 8- and 16-bit.
Prefer this over overlapping #10985.
A paused Stop continues only on its own document whether or not Create a
single history state is on: the outermost playback captures the history
continuation in both modes, and its resume refuses another document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the panel-menu test collected no output, so a hardcoded or missing shortcut text would have passed. It now gathers the text shapes the menu paints and checks the default key, a changed key and a removed binding.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>