* Add a toolbar side toggle and dock-edge resizing
- Let the toolbox sit on the canvas' left or right, persisted in the panel
state and switchable from the toolbar header and preferences.
- In the compact arrangement, stack dock, rail and toolbox on the dock's
physical edge so the toolbox cannot cover the rail.
- Make the dock panel's inner edge the single resize handle: it drives the
panel width and the dock total (split size and column widths) in the same
frame, so the rail and dock content stay bound with no reserved room and
no second handle. The docking layout's own column-edge handle is covered;
the bar is the theme separator at rest and the accent on hover/drag.
- Persist the layout, keep the split size in sync, handle RTL, and test the
toolbar side state.
* Toolbar side: dock-edge only with a lone canvas; test every theme
- Use the between-dock-and-canvas layout only when nothing is docked beside the canvas: that view drops the canvas from craft-ui's split, so with a panel there the standard layout keeps it visible and dockable.
- Add canvas_alone_layout/canvas_alone and a unit test for a lone canvas versus a split canvas side.
- Add a kittest that toggles the toolbar Left/Right across every ThemeKind::ALL entry, asserting the panel lands on the requested physical side.
- No dependency changes: Cargo.toml and Cargo.lock are identical to main, so the repository's exact pins are untouched.
* Toolbar side: keep the canvas a drop target during dock drags
While a panel is being dragged, draw the standard layout even with the toolbar at the dock edge. It keeps the canvas in craft-ui's split, so a panel can still be dropped beside it; after the drop the canvas is no longer alone and the standard layout stays. The between-dock-and-canvas layout resumes for a lone canvas once the drag ends.
* Toolbar side: stable drag layout, unwind-safe dock flag, clippy
- Restore the dock-only thread-local through a drop guard, so an unwinding show does not leave a later standard layout canvas-less.
- Do not switch the dock layout mid-drag: craft-ui records its areas on the press, and changing the shape cancels the drag. The layout stays stable; a panel beside the canvas still selects the standard layout before the drag begins.
- Fix the CI clippy errors: explicit auto-deref in end_dock_node/resize_end_dock and field reassignment in the toolbar_side test.
* Toolbar side: anchor the Edit Toolbar flyout; borrow the canvas layout
- more_slot_flyout takes the resolved ToolbarSide instead of the text direction, so a right-hand toolbar in LTR (and a left-hand toolbar in RTL) anchors the flyout away from the canvas like the tool flyouts.
- canvas_alone borrows the saved layout and treats the native-dock legacy layout as canvas-only, avoiding a per-frame clone and traversal.
* Toolbar side: only a horizontal canvas split uses the dock-edge layout
A saved workspace can put the canvas above another panel in a vertical root split; canvas_alone_layout now requires SplitAxis::Horizontal so that shape keeps the standard layout. The guard test covers the vertical case.
* Toolbar side: document the functions this PR adds
Covers ToolbarSide::resolve/other, canvas_alone_layout, the DockOnlyGuard drop, and the two new tests. Docstring coverage of the PR's own additions is now complete; no behaviour change.
* docs: refresh the scorecard after merging main
The merge left docs/scorecard.md stale against the combined settings table; regenerated with cargo xtask scorecard, which is what the gate's scorecard step checks.
* Toolbar side: honor the workspace lock, empty docks and the end-dock shape
- The dock-edge resize handle is inert while the workspace is locked (hover sense, no highlight) and set_end_dock_total refuses to write then; the drag test covers a locked workspace.
- An empty dock takes only the rail's width instead of a clamped blank column; dock_is_empty reports it and a test empties the dock.
- canvas_alone_layout now requires the second child to be the Dock itself, and end_dock_node/resize_end_dock follow horizontal splits only, so a layout the handle cannot resize keeps the standard layout instead of snapping back.
- Tests: DockOnlyGuard restores the flag on drop and unwinding; the theme test clicks the toolbar's move button instead of setting state; a dock-edge harness drags the edge, checks it persists and that a locked workspace refuses it; axis tests and a two-column ui.set {dockWidth} test.
- The flyout indicator follows the resolved toolbar side, the rail keeps its chrome fill and 4/8 inset with the separator inside it, ATTRIBUTION counts the new icon (116), and docs/control-protocol.md describes dockWidth as the whole end dock.
* Toolbar side: floating windows on empty docks, iconic widths, dockWidth columns
- An empty dock still runs the docking pass on the full remaining area, so Studio's floating Properties window keeps rendering with or without the rail; the dock column itself takes no width. A Studio regression builds an empty dock with a floating Properties group and asserts the pass runs and the window is drawn.
- Dock width now uses each column's displayed width (iconic_width while collapsed to icons), and resizing/scaling keeps iconic widths in step. A collapse-to-icons regression checks a 36-point column not reserving 290.
- ui.set {dockWidth} scales every column to the requested total directly instead of resizing only the first column and then finding the sum already equal. The two-column test also covers a target smaller than the other column's expanded width (250 from [300, 180]) and asserts per-column widths.
* Toolbar side: iconic widths, edge release and modal handling, side persistence
- An iconic-only dock keeps its narrow displayed width instead of being raised to the expanded
minimum, and the dock-edge panel records its laid-out width for a geometry regression.
- The release frame applies its final pointer position before dropping the drag, so the dock keeps
the width the user released at.
- The dock-edge handle is inert (and any stored drag is dropped) while a modal dialog is open.
- set_display_width changes iconic_width while iconic and leaves the columns expanded width for
expanding again; the regression resizes an iconic column and checks the restore.
- Clicking the toolbar side persists the panels snapshot immediately through persist_panels, even
when the dock is not drawn, while Remember Workspace Changes is on.
- The locked-drag test compares against the pre-drag width instead of only settled equality.
* Toolbar side: defer persistence while moving, translate the side labels
- persist_panels defers while the dock persistence pass sees a native window move/resize (the
same native_moved signal), so a click during an OS resize cannot save intermediate geometry;
it requests the usual 100 ms repaint and leaves the dock pass to save when the geometry is
stable.
- The toolbar-side tooltip/accessible labels now pass both literals directly to tl!, and all 34
registered catalogs carry translations for Move Toolbar to the Right/Left.
* Toolbar side: keep in-progress dock geometry out of toolbar saves
- native_moved caches its answer for the frame, so the dock pass and persist_panels see the same
movement result even though only the first call observes the change.
- persist_panels defers while a dock-edge drag is stored, a pointer is down or a native window
moved; it stores a pending flag and requests the usual 100 ms repaint.
- persist_pending runs in the frame tick (before preferences save) and retries the deferred save
once geometry is stable, so the toolbar side is not lost even when the dock was not drawn.
- The dock persistence pass also skips while the dock-edge drag start is stored, covering a
synthetic/accessibility click with no pointer button down.
* Toolbar side: keep a same-width dockWidth request a no-op
The dockWidth rewrite scaled and committed the legacy layout even when the requested width
already matched, so a later `ui.set {dockTabs}` took the customized-workspace path and expanded
the Properties group. Restore the early return for an unchanged displayed width and cover it:
setting the current dock width keeps `app.ui.docking` None.
Fixes the `a_new_adjustment_layer_brings_properties_to_the_front` failure on CI.
* Toolbar side: recheck native bounds, expire stale dock-edge drags
- native_moved keeps movement already seen in the frame but compares the bounds again on every
call, so a later dock save is not fooled by the early persist_pending check caching false
before the docking UI updates a floating window.
- dock_panel records the frame it rendered; persist_pending drops a stored dock-edge drag start
once the panel stopped rendering (Shift+Tab hides the dock mid-drag), so deferred saves resume.
- The same-width dockWidth test also selects a tab in a collapsed group and asserts the tab,
the collapsed state and that no workspace was customized.
* Toolbar side: draw the release width, clear drags when the dock empties
- The release frame draws the panel at the final pointer width just stored, instead of the width
read before the drag ended, so the panel and toolbar match the saved dock width.
- The empty-dock path clears a stored dock-edge drag start, so a dock that empties mid-drag cannot
leave deferred saves blocked.
- Tests: nested DockOnlyGuard (flag found set stays set), and a queued `ui.set {dockWidth}`
through the rendered dock pass checking a two-column dock totals 420 points.
* Toolbar side: cancel the dock-edge drag when resizing is unavailable
- A disabled parent UI (screen picking) never delivers drag_stopped, so dock_panel now clears a
stored dock-edge drag whenever resizing is unavailable: locked, modal, empty dock or disabled
UI. The same condition makes the handle hover-only, so it cannot start a new drag while
disabled.
- Regression: start a resize, disable the UI, release, re-enable and move the pointer; the drag
is cleared, the dock does not jump and persistence can resume.
* Toolbar side: bound the dock-edge layout and enforce column minima
- The dock-edge panel leaves the toolbar and the 200-point canvas minimum room, so a narrow
window shrinks the dock instead of the canvas.
- The empty dock runs the docking pass in a child ui that does not consume the parent layout, so
the toolbar and canvas keep their space.
- Column scaling keeps the 220-point panel minimum: minima are allocated first, the rest is shared
proportionally, and requests below the sum of minima stop there.
- The dock metrics (empty, iconic-only, width) come from one layout snapshot per frame.
- Removed dead empty-dock branches, narrowed the content-edge interceptor to one point either
side, corrected the toolbar target comment, used the drag press origin, and documented the
Side::End assumption.
- Tests: narrow-window canvas minimum, empty dock composed with toolbar and canvas, minimum-aware
two-column widths, and the empty compose regression.
* Toolbar side: bound the dock-edge layout, enforce panel minima, match iconic widths
- The dock-edge panel leaves the toolbar and the 200-point canvas minimum room, so a narrow
window shrinks the dock instead of the canvas.
- The empty dock runs the docking pass in a child ui that does not consume the parent layout, so
the toolbar and canvas keep their space.
- Column scaling allocates each column's 220-point panel minimum first and shares the rest
proportionally; requests below the sum of minima stop there.
- Iconic columns use craft-ui's renderer rule: iconic_width.unwrap_or(36).clamp(36, 240), including
the real collapse case that leaves iconic_width unset.
- Dock metrics come from one layout snapshot per frame; dead empty-dock branches are gone; the
content-edge interceptor is one point either side; the toolbar target comment is corrected and
the Side::End assumption documented.
- Tests: narrow-window canvas minimum, empty dock composed with toolbar/canvas, minimum-aware
two-column widths, collapse via Action::SetIconic leaving iconic_width unset, and the 36..240
clamp. The press-origin change was reverted: Sense::drag starts immediately.
* Toolbar side: distribute refused width, draw actual widths, invalidate stale drags
- scale_columns moves the total toward the request in passes, freezing columns that hit a bound
(iconic 36..240, expanded minimum 220), so width refused by the iconic cap reaches columns that
can still grow.
- set_end_dock_total returns the width actually stored; the dock panel uses that width during the
drag, on release and after, instead of forcing the requested or minimum width.
- The custom drag stores docking_generation; a workspace replacement clears the stale gesture
before any width mutation.
- Clamp bounds are normalized against the available maximum (including iconic columns) and
resizing is disabled when the window cannot fit the column minima, so narrow layouts cannot
panic.
- Tests: iconic-cap surplus redistribution, actual-vs-requested panel geometry during and after a
drag, workspace replacement during a drag, and a 300pt window resize without panic.
* Fix dock-edge resize bounds and cancellation
* Preserve explicit dock width across legacy migration
* Document dock code, dedupe widths, harden toolbar_side load
- Add doc comments to the functions and tests this PR touches (Docstring Coverage).
- Share the toolbar width (toolbar_width/toolbar_columns), the rail widths and
CANVAS_MIN_WIDTH instead of repeating the values; no values change.
- Document dockWidth as the requested model width, which a narrow window may draw
narrower in either layout, and test model 520 / drawn <= 364 / canvas >= 200.
- Document that a dockWidth change is shared equally among columns within their
minimums and iconic caps; tie ICONIC_WIDTH/ICONIC_MAX_WIDTH to craft-ui's
DockStyle defaults (36/240) in a test.
- Replace the DOCK_ONLY thread-local and its guard with show_layout(dock_only).
- Move the test-only probe writes behind panel_docking::record, with no-op stubs
outside tests.
- Read an unknown or malformed saved toolbar_side as Start instead of dropping
the whole panel state; ui.set still refuses it.
* Test visible dock chrome in narrow windows; name panel body minimum
- The narrow-window dockWidth test now checks the docking controls craft-ui drew:
hit_rects are full widget rects (before clipping), so the visible part of each,
intersected with the dock, must stay inside the dock and off the canvas and
the toolbox, with at least one control visible.
- Name the 200-point guard for drawing a panel's body PANEL_BODY_MIN_WIDTH,
separate from CANVAS_MIN_WIDTH; no value change.
* test(ui-egui): assert dock controls are clipped at the panel edge
The narrow-window test intersected each hit rect with the dock and then asserted the
intersection lies inside the dock, which always holds. Check each dock control's clipped
interact_rect against the dock instead, and add a squeezed-column test where a control's
full rect runs past the panel edge, so the clip is actually exercised.
* ci: retrigger pull_request workflows after resolving the merge conflict
No changes. GitHub did not queue Actions for 3652ede, the push that
resolved the conflict with main.
* test(ui-egui): assert saves resume after a disabled UI cancels a dock-edge drag
The test's doc says saves must resume once a disabled UI drops the held drag, but nothing
checked it. Assert that the held drag is not saved, that its width is saved as soon as the
drag is cancelled (while the UI is still disabled), and that a width change made after
re-enabling reaches the saved layout too.
* test(ui-egui): cancel a dock-edge drag on a window shrink and check the dockWidth column split
constrained_width_does_not_panic_while_resizing now starts the drag in a wide window and
shrinks the window below the column minima while it is held: the drag must be cancelled and
the dock must stay within the window. Both ui_set_dock_width tests now check how the change is
split between the columns, not only the total.
* test(ui-egui): tighten dock tests per CodeRabbit review 5824274070
- disabling_the_ui_cancels_a_dock_edge_drag: assert the drag is cancelled on the first disabled
frame, before the pointer is released.
- all_iconic_totals_stop_at_both_bounds_without_losing_expanded_widths: check the stored column
widths sum to the expected total after each request.
- explicit_width_request_preserves_queue_and_native_state_until_invalid_workspace_recovery: add
a changed-width (520) case next to the no-op one.
- the_toolbar_side_toggle_works_in_every_interface_theme: with the toolbar on the right, a grouped
tool's flyout opens to the left of its button.
* test(ui-egui): assert stored widths and native rect per CodeRabbit review 5824824938
- constrained_width_does_not_panic_while_resizing: the stored column widths captured before the
window shrinks are unchanged after the cancel and after the release.
- explicit_width_request_preserves_queue_and_native_state_until_invalid_workspace_recovery: the
native state keeps the exact rectangle the test saved until recovery, not just some state.
* Keep the toolbar side when a workspace saved none
A workspace saved before the toolbar could move has no panels.toolbar_side,
so applying it reset a right-hand toolbar to Start. Keep the current side
when the saved value is missing or unreadable; a valid saved side still wins.
* Cover a saved start side and the other panel flags on workspace apply
---------
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
* Perf: the web keeps slow frames too; its clock is performance.now() (#9328)
now_ms() reads web_time::Instant, which is performance.now() on wasm, so
the web build records slow frames, reports perf.slowFrames and logs them
to the browser console. The stroke trace is off on the web for another
reason: it has no environment, stderr or files to write to.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Perf: one frame stage record across egui's passes (#9328)
egui runs a frame's UI again when a pass asks for a discard (a sizing
pass). The second pass opened a new stage record after the first pass
closed its own, so a frame of two 30 ms passes was never slow and the
stroke trace gained a 0-move row per extra pass.
finish_frame now adds each pass's UI time to the record and leaves it
open while ctx.will_discard() says egui will run the pass again; the
last pass closes it. ui_start keeps the first pass's input time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Perf: a frame's stroke flag covers every pass
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hover highlight filled the footer button with the opaque hover colour over the icon. It is
now a 1.5 px accent outline. control-protocol.md's Actions panel paragraph mentions the two drags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove and wrote
CodeRabbit found three things after the last fix-up:
- `engine.execute` and action steps without `name` replied `selected: []`
while the panel listed the layers its kept Name text matches. `run` now
fills in the panel's Name text for every path, as the menu did.
- With Filter Options > Hide, Find Layers left a focus request that took
the caret once the row was shown again. In the reference application the
row stays hidden and showing it later does not focus the field, so the
request is only made for a drawn row.
- The control protocol said the selection never changes; the Name filter
deselects a selected layer it hides (#10879). The doc now says so and
describes each entry point and the reply.
Tests for the first two fail without the fix.
Co-Authored-By: NaufalDzz <NaufalDzz@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved discard_ui.rs to dialogs/discard_ui.rs and its tests to
dialogs/discard_ui/tests.rs; the change and its tests now live there.
Co-Authored-By: devstivi <devstivi@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unset or null follows Preferences › File Handling: Always and Ask When Saving
write the real merged image, Never the placeholder, and app.save opens no dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PSD writer always writes the real merged image for those modes (Bitmap and
Indexed are saved without layers; Duotone is exempt), so maximizeCompatibility
false does not give them the placeholder. The MCP params, control protocol and
CLI help now say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop's Maximize Compatibility checkbox, for one save: `maximizeCompatibility`
(true or false) on Save, Save As, Save a Copy, `app.save`, serve `doc.save`, MCP
`doc_save`/`doc_export`, Batch, Image Processor and Layers to Files decides the
merged image over Preferences > File Handling > Maximize PSD and PSB File
Compatibility; unset, the preference decides. Anything but a boolean is refused
before anything is written. The CLI takes `--no-maximize-compatibility`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Draw the Crop task bar only on the active document's tile: a click into
another tile activates its document mid-pass, and the tile drawn after it
put the new frame's bar on itself for that frame.
- ui.inspect reports the bar hidden (and no rect) as soon as it can't show,
not only after the next draw.
- ui.set merges panels.task_bar field by field: {"at": [x, y]} keeps a pin,
{"pinned": true} keeps a dragged place; unknown nested fields are refused.
- Tests for each (each fails with its fix reverted), plus the task bar's
preset dropdown picking 1 : 1 in sync with the options bar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Crop tool gets the reference application's contextual task bar: a small floating
bar centred 11 pt under the crop frame with a grip, the preset dropdown (the options
bar's own, so both stay in sync), Straighten, a ⋯ menu (Hide bar, Reset bar position,
Pin bar position) and, while a crop is pending, Cancel and Done. It follows the frame,
stays inside the canvas, and stays put once dragged or pinned. Window › Contextual Task
Bar (after Tools, as in the reference) shows and hides it.
The bar itself is a reusable widget (widgets/task_bar.rs): position rule, grip, ⋯ menu
and the hide/pin/place state in Panels (kept with the panel layout across restarts,
read by ui.inspect › taskBar, set by ui.set {panels}). A context supplies only its
controls and where ⋯ goes; the Crop contents are tools/crop_task_bar.rs. Generative
Expand stays out until #41.
Also: Lucide pin and rotate-ccw icons, six new strings in all 27 catalogues, parity
661/661 (FLOOR raised), control-protocol docs. Three tests about the options bar's
Ratio dropdown now hide the task bar, which shows the same dropdown.
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>
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
* 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>
* 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>
main added the turning grid and draws the guides for the visible part of the frame (current_guides_for_frame). Kept from both: the canvas calls main's guides with this PR's frame look, and this PR's FrameLook functions sit beside main's visible_frame and turn_grid. The overlay still waits for the crop's first change, except while a turn or a straighten line is in progress, whose guides show from the start as main's tests expect.
* Add headless GPU backend foundation for composition and flat export (issue #8062 PR 1)
* Fix headless GPU readback bounds, API compliance, and error preservation (#8062)
* Fix GPU readback bounds, context thread spawn error, and test parity (#8062)
* Fix BandRenderer Send bound, PNG reborrows, and clippy lints (#8062)
* Fix effect shadow test construction and remove dead code wrappers (#8062)
* Use short reborrows for Headless renderer without lifetime escaping (#8062)
* Fix clippy collapsible_if and ensure multi-band geometry in flat export tests (#8062)
* Fix automation Rect import, add renderer diagnostics, and harden flat export retries (#8062)
* Expose optional GPU rendering in photocraft-cli (#8062)
* Fix GPU feature test APIs and native target gates
* Fix CLI locked metadata, bounded benchmark params, and protocol diagnostics (#8062)
* Verify unsupported GPU effects before testing band seams
* Fix CLI renderer borrows and GPU-enabled test compilation
* Use compositor buffer ownership API after upstream memory accounting change
* Fix GPU CLI lint failures and validate run environment policy
* Update export renderer test helper for owned compositor buffers
* Fix needless benchmark layer name borrow
* Fix headless GPU backend review findings (#10842)
- release a closed document's GPU caches through a BandRenderer hook
- bound the monitored readback wait and report a stalled GPU as device loss
- reject 32-bit float documents before band readback so exports fall back to CPU
- keep previews at 8-bit RGBA and report the palette/Duotone renderer bypass
- retain renderer diagnostics when encoding fails
- strengthen renderer, save-path and GPU diagnostics tests
* Use valid batch arguments and inspect method in GPU CLI tests
* Add GPU close-hook and 8-bit preview regression tests (#10842)
* Fix GPU CLI review findings (#10912)
- guard the benchmark's modulo divisors for one-pixel dimensions
- count a cold export as GPU only when the renderer context was used
- report every batch file's export warnings, deduplicating only GPU diagnostics
- drain renderer diagnostics before propagating convert and run save errors
- cover renderer use in the GPU precedence, bridge and serve tests
* fix(io): correct renderer binding mutability
* test(cli): verify narrow benchmark inputs and GPU diagnostics
* Fix remaining headless GPU review findings (#10842)
- release a document's GPU caches when file.close, file.closeAll or
file.closeOthers removes it through a command
- honor cancellation while band readback waits, polling in 100 ms slices
- fault the device after a readback timeout so later exports skip it
- report single-layer, CMYK ink-mixing, ORA and PDF CPU paths to a
requested renderer
- keep fallback warnings in their export instead of leaking them into the
next one
- requeue renderer diagnostics when a renderer-aware save's final write
fails
- write MCP/serve diagnostics without panicking on a broken stderr
- document the actual colour-space contract and drop F32 from the GPU
depth list
* Fix remaining headless GPU review findings (#10842)
- release a document's GPU caches when file.close, file.closeAll or
file.closeOthers removes it through a command
- honor cancellation while band readback waits, polling in 100 ms slices
- fault the device after a readback timeout so later exports skip it
- report single-layer, CMYK ink-mixing, ORA and PDF CPU paths to a
requested renderer
- keep fallback warnings in their export instead of leaking them into the
next one
- requeue renderer diagnostics when a renderer-aware save's final write
fails
- write MCP/serve diagnostics without panicking on a broken stderr
- document the actual colour-space contract and drop F32 from the GPU
depth list
* Fix remaining GPU CLI review findings (#10912)
- reject non-UTF-8 PHOTOCRAFT_GPU values instead of silently using the CPU
- release a batch item's document caches before taking the renderer to the
next item, and report fallbacks once
- count warm benchmark runs that fell back to CPU in the summary
- write serve diagnostics without panicking on a broken stderr
- assert that the batch test's --gpu policy reaches the renderer
* Use question mark for cancellable readback errors
* Use question mark for cancellable readback errors
* Keep GPU export diagnostics with their attempt and stop bands on cancel
Encode native .pcraft bytes before draining renderer diagnostics, and
requeue export warnings when a workspace write fails, so callers still
report the adapter or fallback reason with the error.
Move a failed GPU attempt's queued diagnostics into its CPU retry result
instead of leaving them for a later successful export to report.
Stop GPU band readback at the next band after cancellation, and check the
interrupt before each CpuRenderer sink delivery.
Band readback uploads half-precision textures, so reject U16 alongside
F32: their samples cannot survive the upload. Tighten the 8-bit CPU/GPU
parity checks to 1/255.
* Keep GPU export diagnostics with their attempt and stop bands on cancel
Encode native .pcraft bytes before draining renderer diagnostics, and
requeue export warnings when a workspace write fails, so callers still
report the adapter or fallback reason with the error.
Move a failed GPU attempt's queued diagnostics into its CPU retry result
instead of leaving them for a later successful export to report.
Stop GPU band readback at the next band after cancellation, and check the
interrupt before each CpuRenderer sink delivery.
Band readback uploads half-precision textures, so reject U16 alongside
F32: their samples cannot survive the upload. Tighten the 8-bit CPU/GPU
parity checks to 1/255.
* Report partial warm fallbacks and CPU fallback on stderr
The averaged GPU Warm row now says how many warm runs fell back to the
CPU and were excluded from the average, through the existing helper.
The single-layer convert diagnostic test checks that the CPU fallback is
reported on stderr, in GPU-enabled and CPU-only builds alike.
* Keep headless export warnings when a workspace write fails (#10842)
A capability-scoped save encodes the export, then writes it; when the write
fails, the export warnings (folded together with renderer diagnostics) were
pushed back through `BandRenderer::push_diagnostic`, whose default is a no-op.
A custom renderer that overrides only `drain_diagnostics` therefore lost the
adapter or fallback reason that belonged with the error.
Keep them in `Headless` instead and return them from the next
`drain_diagnostics`, alongside the renderer's own queue. The regression uses a
renderer without a `push_diagnostic` override and checks the warnings survive
the failed write and drain exactly once.
Also name the actual source of the depth limit in the headless GPU matrix:
layer content is uploaded as half-precision textures, not band readback.
* Stop CPU bands and CPU retries on cancellation (#10912)
The CPU retry after a retryable GPU error composes through
`FlatSource::bands`, whose shared `emit` sink did not poll the export
interrupt: a cancel was only observed after the whole composite finished.
Check it once per band, as `CpuRenderer` does, for both RGB and CMYK ink
bands. The flat and streamed-PNG retry branches also check before starting
the full CPU composite again, so a cancelled export returns
`IoError::Cancelled` instead of re-rendering the document.
Tests: band-sink cancellation for RGB and CMYK inks, a renderer call counter
proving a second export reaches the renderer, and a pending `wait_slices`
poll observing cancellation set mid-wait.
* Merge origin/main into GPU foundation (#10842)
* Keep GPU-path diagnostics on every failure path, honour cancel in native copy
- save: export warnings of a failed local write stay in Headless pending diagnostics
- render: preview fallback warnings are returned, drained, and added to the doc.render reply
- io: renderer-neutral fallback wording; native single-layer copy checks cancellation per band
- gpu: gate wait_slices tests off wasm32 (#10842)
* Address CodeRabbit review: keep fallback reasons on failed retries, report native-copy and quick-export CPU use, guard diagnostics drain (#10912)
* Accept render diagnostics from the doc.render reply in the serve GPU test (#10912)
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>