The reference application's New Guide dialog has Orientation, Position
(a text field that takes typed units such as "2 cm") and Color, but no
unit dropdown; measured on 2026-11-06. Typed units already work on main,
so the PR only adds something the reference lacks.
This reverts merge 22e03d0bcb.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Linux runner has no GPU, so the GPU tests ran on Mesa llvmpipe over
GL, which can't render Rgba32Float: 57 GPU checks (50 GPU parity, 3
canvas, 2 adjustment preview parity, 2 effect-map recovery) returned
early and passed without comparing anything. A new step after Test
installs mesa-vulkan-drivers and runs the nextest `gpu` group on
lavapipe (Vulkan), reusing Test's build, and fails unless the parity
tests report the Vulkan backend and no test printed a skip.
No code change: photocraft's tests take whatever adapter wgpu returns,
software adapters included. Follows the CI half of lightcraft#1148.
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add an opt-in WinTab backend for pen pressure on Windows
Windows Ink stays the default. Preferences > Tools > Pen Tablet has a Tablet
input dropdown (Windows Ink (default) / WinTab). WinTab opens a WinTab 1.4
system context on PhotoCraft's window and polls pressure and the eraser end
(tilt on devices that report orientation axes; rotation only with a pen and
driver that report twist) into the stylus feed. The row says to turn Windows
Ink off in the tablet's software utility; the choice applies at next launch.
- crates/tablet: Windows-only wintab/wintab-sys backend. Devices with
orientation axes use the orientation packet; pressure-only devices open a
packet without it and report neutral tilt. Leaving is the driver's hardware
proximity message (WT_PROXIMITY) and the packet's out-of-context status, with
a long fallback for drivers that never report either. Overlap priority
follows window focus, and the message hook requests a frame for each WinTab
message so pressure-only strokes update without cursor movement.
- apps/photocraft: install the backend when the preference is on; poll it at
the start of each frame; the installed wake repaints the UI.
- ui-egui: TabletPollFn service; the Windows-only Pen Tablet row with the
Tablet input dropdown and the note.
- engine: tools.tabletInput (windowsInk/winTab), default windowsInk.
- docs: hardware-parity WinTab gap entry, qualifying tilt to orientation axes
and rotation to twist-reporting pens.
Tested locally on a Wacom tablet with Windows Ink off: pressure, tilt and the
eraser end reach the brush (rotation where the pen reports twist); the default
path is unchanged.
* WinTab: a message that finds the state borrowed is skipped, not a panic in the hook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* WinTab: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Scorecard: count the Tablet input setting
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>
* PSD write recipes: keep every exported candidate and pin the base file
A case that failed after export dropped out of manifest.json, losing the
file a reference-editor check most needs. The CLI also reopened the corpus
path after the hash was taken.
- Keep the candidate and its hash once export succeeds.
- Run the CLI on a copy of the hashed bytes, so it never opens the corpus.
- Compare numbers by value, so 17 equals 17.0.
- tracking-split also checks runs 0 and 2 and all three run lengths.
Part of #6130.
* PSD write recipes: compare large integers exactly, reject case-only id clashes
Converting to f64 made 9007199254740993 equal 9007199254740992. Ids that
differ only by letter case name one output directory on a case-insensitive
filesystem, so they are now rejected as duplicates.
* PSD write recipes: test that the CLI receives the hashed bytes
* PSD write recipes: reject the contour library commands
Their path param can read or write any file, including the pinned base and
the corpus, and they edit no document.
A stuck close no longer holds up the files queued behind it (up to four
closer threads), and a query identifies the current file by the configured
folder. Tests don't read the room and the status across a late answer, and
the growth-check step proves its query is stalled.
The whole folder change now waits 2 s at most, old files close on one
background thread, the cap counts query threads until they finish, and
writes during a query aren't counted twice. Capacity is refused if a sweep
paused spilling meanwhile, a query opens a file unless this folder has one,
and on macOS an unrelated dead mount only costs the purgeable figure.
Review fix-ups on #11249 after merging main:
- A midpoint outside 5..95 %, transparency stops out of range or out of
order, and a Gradient Fill Set without transparency stops stay
reference-application steps instead of playing clamped or keeping the
layer's own transparency.
- An inverted Blend If range stays a reference-application step; the
command rejects it at play.
- The Layer Style dialog's OK records `Lefx` = {Scl} beside Blend If on a
layer without effects (measured). That Set is still the dialog's one
step and saves back with the `Lefx` it was recorded with.
Co-Authored-By: NaufalDzz <90562913+NaufalDzz@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main added Effect, Attribute, Color, Smart Object and Artboard to the Layers
panel filter. Group is now the last type in the list, and works like Artboard:
`Filter::with_selection` fills the listed rows once per frame from
`group::listed`, so the panel, the deselect-on-apply step (#10879) and the
control channel all see the same rows; `shows_in`/`group_ids` and the
layers.rs change are gone. This also removes the duplicate `mod group;` and
`mod tests;` lines (E0428) and the rustfmt issues from the review.
- `group::listed` walks the tree with a stack instead of recursion and
returns `LayerId`s.
- `Filter::show_all` is documented: Group keeps each group open or closed.
- Tests go through `Filter::of_app` and `panel_rows`, as the panel does,
plus a deselect-on-apply case and a document without groups.
- control-protocol.md: `group` added to main's `layerFilter` entry.
Co-Authored-By: Nurana100 <Nurana100@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening and measuring the folder ran under the config lock, which the UI
takes every frame, and could run on the UI thread itself. They now run on
a short-lived thread, one per folder and question and 16 overall; callers
wait at most 2 s, and a late answer applies if the folder is still current.
No answer counts as unmeasured, or pauses spilling if nothing is open yet.
With 1 GiB or less of free RAM, or 512 MiB or less of commit headroom, the
hard limit was exactly 0, so even a small file was refused with a message that
didn't say memory was low.
The reserve now shrinks with the measurement: up to a quarter of free RAM opens
automatically, the rest goes to approval, and an approved import always leaves
at least 256 MiB free. Limits are unchanged for automatic opening above 4/3 GiB
free and for approval from 4 GiB free. A refusal set by measured memory now
gives the free amount and says to close other apps and try again, and the
approval prompt warns when little memory would be left.
An unreadable cgroup still measures Some(0) and stays refused.
Windows network folders reported unknown because sysinfo doesn't list them, and an unlisted Linux
NFS or CIFS mount used the numbers of its listed parent. GetDiskFreeSpaceEx (Windows) and statvfs
(Unix) on the directory fix both. macOS keeps sysinfo's figure, which counts purgeable space, when
the listed disk is this volume. A mount that reports no space is flagged in Preferences, in the
hard-limit refusal and in spill::stats, with a hint to set a Scratch Size Limit or use a local disk.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
The 32-bit message ended mid-phrase. It now says the cap applies or less if
less memory is free, and separates the cause from the next step. Windows
scratch tests check the returned numbers belong to the temp directory's volume.
PngImport decoded the whole image and then copied it into a second, tile surface (about
+400 MB for a 100 MP RGBA8 file, enough to kill a browser tab). A PNG whose rows come in
final order now streams: PngDecodeRows::step_stream hands each group of rows on, they
become tiles at once (the PngStream the one-shot streamed import also uses) and are
dropped, then the prune scan runs in batches. decode_rows is the same step_stream driven
to the end, so there is one path. Interlaced, animated and early-eXIf-oriented files are
decoded whole as before; a late eXIf sends a streamed import back to a whole decode.
The turn upright of an EXIF orientation 2-8 was one whole-buffer pass in the poll of the
last rows; OrientSteps (photocraft-codecs) does it in bands of 64 rows through the same
band writer as Image::oriented, and PngImport runs it as a stage of its own.
Memory-budget errors keep their type and text: the stream is checked as the one-shot
streamed import is, and, like the whole-image path, a layer under 32 MiB is not.
Docs for the stepped place updated.
The remaining commits of the stack, applied on top of the squash-merged #10147:
- PNG decodes in resumable steps of rows (PngDecodeRows, shared with decode_rows)
- PNG imports as a document in resumable steps (io PngImport, text metadata and
document build in bounded steps)
- Jobs can run a slice at a time where there are no threads (StepWork,
cooperative sessions, Session::poll_jobs)
- Place decodes, flattens and resizes in slices (PlaceSteps), reading a path
and keeping the frame, memory gate and typed memory-budget errors main has
- The web build places dropped and picked files a slice per frame, with a
bounded place inbox
- A measurement of the stepped place; docs for jobs without threads
* 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>
* 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>