layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became
inf (hard edge, null in info, .pcraft save refused) and any huge value was
accepted. It now takes the same finite 0..=1000 px range and error as
layer.layerMask.edit, checked before the edit so a bad value leaves the mask
and history untouched. The params doc says so.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The font menu's preview samples, family list and localized names were
cached on fonts::generation(), which any FontDb moves, and the previews
also on the served catalog's generation. Other engines registering fonts
(tests running in parallel) re-rendered every visible sample each pass,
and families covering Arabic asked for a repaint, so Harness::run never
settled: options_font_family_preserves_peer_faces_and_batches_layer_flags
and the font picker tests failed intermittently with "exceeded max_steps".
- photocraft-text: fonts::shared_generation(), moved only by the shared
engine's FontDb (FontDb::mark_shared from new_shared).
- ui-egui: families(), localized_families() and the preview cache key on
it; previews no longer key on the served catalog (a served family's
sample appears when its fonts register, which moves the generation).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The warning triangle on a comp row sent layerComp.updateWarnings
{"clear":true}, which dropped the stale states of every comp. The
command now takes an optional "comp" (id or name) that limits both the
report and the clear to that comp; an unknown comp is an error and
changes nothing. Without "comp" it still acts on every comp (Clear All
Layer Comp Warnings). The row icon sends its own comp id.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
32-bit samples can lie outside 0..1. The RGB readout clamped them, but
device_cmyk did not, so a pixel at 1.5 read K:-50% and negative values
gave C/M/Y above 100%. Clamp each channel to 0..1 (NaN reads as 0) before
the conversion; this fixes both the Color Sampler and the Info panel.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#3132 refused type.edit on a Lock All layer, but type.insertText,
edit.checkSpelling "change", Paste Lorem Ipsum (and the other
type_extra_cmds::with_text edits) and Find and Replace Text "change"
on a layer locked after the find still changed its text and recorded
history. The check is now one pub(crate) helper,
type_cmds::check_text_unlocked, called by every one of these paths
with the same "...is locked" error as type.edit.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(ui): add GRD import option to gradient preset picker
* Translate the GRD import strings into Vietnamese, Turkish and Hungarian
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* WIP: Object Selection hover preview (#4087)
* Object Selection: Object Finder hover preview and click-to-select (#4087)
Hovering with Object Selection highlights the object a click selects,
computed off the UI thread after a short rest and cached per document
pixels; a click commits it via select.object {point} honouring
add/subtract/intersect; dragging keeps the rectangle mode. Adds an
'Object Finder' options-bar toggle (default on, persisted) with
translations in every catalog.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298)
An adjustment layer has no pixels, but once its icon thumbnail was clicked (or it was
reached with Alt+[ / Alt+]) the Layers panel no longer targeted its mask, so Edit > Fill
(Shift+F5) and the fill keys were greyed out, and the Paint Bucket and brushes failed with
"not a pixel layer". As in Photoshop, painting and filling an adjustment layer now always
edit its mask; the panel's target (brackets, trash, Delete) is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the web read_dropped doc comment on its function
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(ui): keep brush settings section lock icons permanently visible (#4278)
* test(ui): verify brush settings locks stay visible and toggle on click (#4278)
* Format the lock-icon test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): add Brushes button at top of Brush Settings control list (#4265)
* test(ui): verify Brushes button at top of Brush Settings opens Brushes panel (#4265)
* fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172)
* test(ui): verify Gradient tool icon rasterizes a left-to-right ramp (#4172)
* fix(ui): make clickable tab and radio labels non-selectable with pointing hand cursor (#3972)
* test(ui): verify New Document preset tabs show pointing hand cursor instead of text caret (#3972)
The options bar's Maintain Aspect Ratio link only affected typed W/H values; dragging a
corner or edge handle still scaled proportionally, so unlinking seemed to do nothing. As
in Photoshop CC 2019 and later, the link now also sets the drag default: unlinked, corner
and edge drags stretch freely and Shift keeps the proportions. Linked (the default) and
the legacy preference are unchanged. The link state moves from egui temp data into
UiState (transform_unlinked), next to the transform session.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
In the Studio-layout themes (Studio, Studio Light, Breeze, Adwaita,
Solarized) Properties is a floating card over the canvas. The card
returned early unless the active layer was an adjustment or fill layer
or a mask was targeted, so for a pixel layer, the Background, type or a
group nothing was drawn, although Properties was on (the rail button
showed it). The Pro theme's docked panel shows all of those.
The card now renders the docked panel's body (properties_body), so both
show the same content: the Document properties for the Background or no
selection, layer, adjustment and mask properties. Its title bar, hide
button, position and drag are unchanged.
Tests: floating_properties_card_shows_document_properties_for_the_background
and floating_properties_card_shows_pixel_layer_properties (Studio and
Breeze Dark) failed before the fix.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The section list indented every row without an enable box by the box's
width, except the first, so Brush Tip Shape started in the checkbox
column while Smoothing, the other box-less row, lined up with the names.
Photoshop indents Brush Tip Shape too. Every name now starts in the
same column.
Test: section_names_line_up_whether_or_not_they_have_a_box checks the
painted x of every section name (Brush Tip Shape was 20 px left before).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Canvas: a tool drag past the edge auto-scrolls the view (#4183)
Dragging a marquee, lasso, stroke or crop off the visible part of a zoomed
image now scrolls toward the pointer, as in Photoshop, and stops at the
scroll limits. An axis where the image fits does not scroll, and an
overscrolled view is not pulled back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFAjTZQw6MTyhfpQqjAfoW
* Fix rotated canvas auto-scroll limits
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
* Control: a timeout says whether the command ran (#3662)
A request still queued when its deadline passes is dropped before
dispatch, but it answered with the same text as the transport giving up
on a command that may still apply. The two now read differently: "timeout:
not run ... safe to re-send" and "timeout: no reply in 60s ... may still
apply", both keeping the `timeout` prefix machines match on.
* Control: a transport that gives up knows whether the command was dispatched (#3662)
The transport and the UI thread share the reply deadline, and the
transport's wait runs out first, so the UI thread's "not run" reply
almost never reached a client. A request now carries a dispatch flag
settled once between them: the UI thread claims it before running, the
transport abandons it when it stops waiting. The transport then replies
"not run" for a request that will never run, and "may still apply"
only for one that was dispatched; a request given up on is never run.
returned_non_graphics_errors_do_not_trigger_backend_recovery failed at
random in full `cargo test` runs: "gpu-error: ...", left: false,
right: true. Its gpu-error case drops the sentinel, which leaves the
marker file in place, and calls `Sentinel::begin` again for the same
directory. Tests in monitor_profile.rs spawn processes in the same
binary. A child process started while the marker is open gets a copy
of its file descriptor, and the marker stays locked through that copy
until the child's exec closes it. When `begin` runs in that window, it
returns `Previous::Busy` instead of the crashed marker, and the
assertion fails.
sentinel_detects_a_crash_and_clears_on_success has retried `begin` for
this since 3b7870be (#289). The retry moves into a test helper,
`begin_when_unlocked`, which also runs where
returned_non_graphics_errors_do_not_trigger_backend_recovery and
a_failed_cpu_start_does_not_pin_later_starts start again after
dropping or finishing their sentinel. In
a_failed_cpu_start_does_not_pin_later_starts the CPU plan finishes the
sentinel, which removes the marker, so the helper's first `begin`
finds the lock free; the helper covers that test's branch for a plan
that keeps the marker.
A new test, begin_when_unlocked_waits_for_a_briefly_held_lock, takes
the marker's lock through a second handle after the sentinel is
dropped, releases it from a thread after 50 ms, and checks that the
helper returns the crashed marker. It fails when the helper calls
`begin` only once.
* feat: resize Save for Web previews and added palette editing for Gif/PNG8
* Save for Web: add Turkish and Hungarian rows for the preview navigation hint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#4093 changed the Auto-Select default to Group, as in Photoshop. The two
layout_perf tests that click layers inside groups with Auto-Select on
expected the layer under the pointer, so they now select the group and
fail on main. They set the target to Layer explicitly, which is what
they test.
layer.layerStyle.globalLight cast its params straight to f32, so an angle
of 1e39 became inf and rem_euclid turned it into NaN, which was stored,
recorded as a history step and fed to every effect using global light.
Angle and altitude are now read as f64 and rejected with BadParams when
non-finite or beyond f32 range, before the edit. In-range wrapping and the
altitude clamp are unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The writer capped names with encode_utf16().take(1024), which could end
on a lone high surrogate; it now uses io::utf16_units, like .aco/.ase,
so the cap falls on a character boundary.
The reader's "count left out the null" check looked at the last unit
kept, not the last unit read. For a name of 1024+ units the counted null
is read but not kept, so a following 0x0000 (an empty contour's point
count) was swallowed and the stream shifted. It now checks the last unit
actually read.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With a selection, Edit > Transform > Warp warped only the selected pixels
but the whole linked layer mask, so the unselected mask no longer lined up
with the unselected pixels. Warp now limits the linked mask to the selection
through transform_cmds::move_gray_selected, as Free Transform and
Perspective Warp already do (#3083); layers that move whole still take their
whole mask along. Adds a regression test driven by edit.transform.warp.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every video-layer edit ran `work_end = work_end.max(duration)`, so
Show Altered Video, Restore Frame, Interpret Footage and the other frame
edits reset a narrowed work area to the full duration. A new
`grow_duration` helper, used by every video edit and by New Video Layer
from File, grows the timeline and moves the work-area end only when it
reached the old end; a narrowed work area stays where the user put it.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
paint.bucket and paint.gradient read `opacity` as f(p,"opacity",100)/100
with no range check, so 200 became a coverage of 2 and overshot the blend
(#808080 on white painted near-black). Both now use the Magic Eraser's
param reader: the value is clamped to 1..100, and a non-numeric opacity is
BadParams before any edit or history step.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root cause: the JSON-lines server's `index_of` read `index` with `as_u64`, so a
string, negative, fractional or boolean index became None, which means "the
active document". `doc.close {"index":"0"}` therefore closed the active
(possibly unsaved) document instead of failing. The MCP server types the param
as Option<usize> and already rejects these values.
Fix: `index_of` returns Result<Option<usize>, AutomationError>. A missing or
null index still means the active document; a non-negative integer is used;
anything else is a bad request ("`index` must be a non-negative integer").
All doc.* call sites propagate the error with `?`.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root cause: the layer row's reserved width and indicators, and the Studio
themes' lock button, treated a layer as locked only for transparency,
position or all. A "Lock image pixels" or artboard lock left the row
without its lock icon, and the Studio button drew "unlocked" although
clicking it unlocked the layer (simple_lock_toggle checked all five).
Fix: add Locks::any() in photocraft-doc and use it in all four places,
so every lock shows the icon, as in Photoshop. Tests cover each lock on
the row (reserved width and Indicator::Lock) and the Studio button state.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root cause: the options bar sent markerSize/labelSize as f32 JSON floats
(8.0) and count.setGroup read them with as_u64, which is None for floats,
so the command returned Ok and changed nothing.
Fix: count.setGroup accepts whole numbers sent as floats (UIs, agents and
actions send them), rejects fractions, negatives and non-numbers with
BadParams, and clamps in u64/f64 so huge values no longer wrap through
`as u32`. The options bar now sends integers.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root cause: run_on_layer in distort_cmds.rs added the smart filter and
returned for a smart object before it called check_locks, so a smart
object with Lock All or Lock Pixels (its own or a parent group's) took
the distortion and a history step.
Fix: check the pixel and full locks (not position) before recording the
smart filter, as regular filters do, and use the filters' error wording.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After Replace Text the find cursor collapsed to the end of the replacement.
A backward search takes the last match ending at or before the cursor, so
when the replacement still matched (e.g. "cat" -> "Cat", case-insensitive)
it found the replaced text again and never reached earlier matches.
Collapse the cursor at the start of the replacement when searching backward
(the end when forward), mirroring how forward search skips it.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
layer.pickAt (Auto-Select, the canvas right-click layer list and the Move
tool's rollover highlight) read the raw layer-mask pixel, while the
compositor uses LayerMask::value, which applies density (1 - d*(1 - v)).
A hide-all mask at density 0 shows the layer in full, yet the click went
through to the layer below.
Read the mask through LayerMask::value, as the compositor does (it also
returns 1 for a disabled mask). Regression test covers the click and the
list form.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root cause: layer_identity, the effect-map cache key, hashed pixels, masks,
fill opacity and effects but not layer.advanced or layer.blend_if. Turning
Transparency Shapes Layer off (which makes the whole layer the effect shape)
or changing a styled group child's Blend If (which changes the composite the
group's shape comes from) left the key and region unchanged, so stale maps
were reused: a stroke kept rimming a hole until the cache was purged.
Fix: hash layer.advanced and layer.blend_if in layer_identity (recursing into
group children). Regression tests compare a warm render after each edit with
a cold render after purge_effect_cache.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root cause: SwatchColor::from_param checked that each value was finite as
an f64, then cast it to f32. A value like 1e39 became inf, and an HSB hue
then wrapped (rem_euclid) to NaN. swatches.add stored Hsb([NaN, ..]), the
prefs file wrote it as null, and on the next launch load_presets_json
failed to parse the whole presets blob, so every user gradient, style,
shape, tool/document preset and swatch fell back to the defaults.
Fix: reject values whose magnitude exceeds f32::MAX with BadParams, for
every colour model.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root cause: parse_entries sets the cursor to start + len after each Anno
entry. An entry length below 8 (its own u32 plus the type) left the cursor
on the same entry, so a count of 0xFFFFFFFF re-read it about 4.3e9 times,
pushing an entry each time until allocation failed and the process
aborted (not a panic, so the import guard could not catch it).
Fix: reject an entry length below 8, so the block is reported unreadable
and kept verbatim (the #944 behaviour); every entry now advances at least
8 bytes. Regression test covers lengths 0/4/7 for sndA and txtA entries
plus the hostile 16-byte block.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Releasing a Quick Selection stroke on a 24 MP photo froze the canvas for
0.2-0.5 s: the growing-window loop cut a window, found the selection
touching its side, doubled the margin and started over, and on real photos
every stroke needed 2-4 sizes.
The window sizes are independent, so the next 4 are now cut at once (rayon)
and the first in growing order that stays clear of its sides is kept: the
same result as one after another. Once one is kept, larger windows of the
batch stop through the cancel hook grid_cut_impl / maxflow_with already
have, now also polled before the graph is built, before the max-flow and
between the coarse and fine levels of cut_levels. wasm32 keeps the
sequential loop.
36 strokes on a 6000x4000 photo: masks byte-identical to main, 21.9 s ->
13.9 s (-37%); small 30 px clicks +8 ms (setup of larger windows).
Co-authored-by: Mohammed <24667144+syncroot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
In Photoshop, holding a tool's letter gives a temporary tool: releasing the
key returns to the tool you had. A tool letter still switches tools when
pressed; if the key then stays down for 300 ms or more, or the canvas is
used while it's down, the switch was temporary and the release gives the
previous tool back with its options. A quick tap stays a permanent switch.
- Shift+letter (cycling the group) and Alt+letter never spring; nor do
letters while typing in a field, editing type or under a dialog.
- The current group's own letter cycles the group, as before.
- A drag in progress when the key comes up finishes with the temporary
tool; the previous tool returns once the button is up.
- The newest held letter wins; the original tool returns when all are up.
- A tool picked another way while a letter is down (the toolbar, a menu,
automation) stands.
- Key repeats of tool letters are dropped instead of cycling the group.
- Z is one case: hold Z to zoom, add Alt to zoom out.
The state is UiState::spring, readable over the control channel and never
restored from settings. Tests that pressed tool letters without releasing
them now release them, as a keyboard does. docs/ui-parity.md and
docs/gaps.md mark #2672 done.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Unlocked rectangle corners now each use their own radius and type (top left,
top right, bottom right, bottom left) instead of all following the first one,
and straight corners are cut across instead of rounded. Same fix as
VectorCraft's copy of the reader (storytold/vectorcraft#1362).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Affinity import: pie shapes with huge angles no longer hang the reader
The reader raised the end angle of a pie shape (`ShPi`) by `TAU` in a
loop until the end angle reached the start angle. Both angles come from
the file. The loop ran about `(AngS - AngE) / TAU` times, and it never
ended once adding `TAU` no longer changed the end angle: with
`AngS` = 1e308 and `AngE` = -1e308, reading the document did not return.
The reader now computes the pie's angles in constant time. The start
angle is `AngS` modulo `TAU`. The sweep is `AngE - AngS` capped at one
turn, or its remainder modulo `TAU` when `AngE` is below `AngS`. When
`AngE` is below `AngS` and that difference overflows to negative
infinity, the sweep comes from the remainders of the two angles.
The change and its two tests come from Riccardo Balloch's commit
79c5d673 in storytold/vectorcraft, which fixed the same loop in
VectorCraft's copy of this reader. Here the tests read the document on
a thread of their own and fail when reading takes more than 20 seconds.
Co-authored-by: Riccardo Balloch <riccardo.balloch@gmail.com>
* Contributors: add dotCooCoo (Robert Lee)
---------
Co-authored-by: Riccardo Balloch <riccardo.balloch@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#3467 traces a shaped tip's outline but scales it by the screen radius
alone, so on a rotated or flipped canvas (R, View › Flip Horizontal) the
cursor kept the document's orientation while the stroke did not.
The outline now goes on screen through the view's own axes:
ViewXform::map_vec maps a document vector (pixel aspect, rotation, flip,
zoom), and brush_cursor::tip_axes gives one tip radius along the
document's x and y. A plain view gives the same points as before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(algo): reject oversized Magic Eraser masks before multiplying dimensions
* Format the oversized Magic Eraser mask test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Type: Space inserts a space while editing type
Space was grabbing the Hand tool during canvas type edit even though
Photoshop keeps Space typing a space. Skip temporary-tool holds when
ui.text_edit is active.
* Credit @Hevarh1 with sync_github_name in About
* Format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Use checked mask indexing so incomplete or extreme regions return zero coverage instead of panicking or wrapping.
Validated with the full algo suite, independent native and Wasm regressions, and green PR checks.
* Refresh Liquify's zoomed preview when a stroke is undone.
Above 100% zoom the dialog shows a source-resolution crop. Undo rebuilt the proxy and left that crop untouched, so the stroke stayed on screen until the next zoom.
Refs #4100
* Format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Stylus: a pen held still is not a press-and-hold right click
On Windows a pen reaches egui as touch events, and egui takes any touch
contact held still past max_click_duration (0.8 s) for a touch screen's
press-and-hold: a secondary click (context menu) that also drops the drag.
Holding the pen still for a second opened a context menu.
PenContacts tells a pen contact from a finger in raw_input_hook (only a
pen hovers, and a pen reports pressure) and turns the press-and-hold
timeout off while one is down, through the frame it lifts in, so a long
still press releases as a tap. Fingers keep press-and-hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Releasing a Clone Stamp or History Brush stroke rendered the whole
stroke again, although the live stroke had already drawn exactly those
pixels. The canvas now offers the live stroke to its commit as the Brush
does (#2131), through the same LiveCommit: a commit with the same
command, document revision, params and points takes the live surface
and reports fromLive; anything else renders as before.
LiveRetouch renders only the position and pressure of its points, which
is all the commands parse, and the offer is refused when the brush, layer
or clone source changed during the stroke (the revision doesn't cover
them). The Healing Brush, which blends on release, is left out.