* fix(apps): a non-Unicode argument is a usage error, not a startup panic (#1108)
Both binaries read `std::env::args()`, which panics on an argument that is
not valid Unicode: `photocraft-cli info $'caf\xe9.png'` died in
`library/std/src/env.rs` before printing anything, and a file manager
handing the desktop app a Latin-1 file name would do the same before any
window opened.
CLI: `photocraft_cli::run_os` takes the raw `OsString`s, and an argument
that does not convert is a usage error naming its position and a lossy
rendering (exit 2, with the usage text), like any other bad argument.
`main` calls it; `run(&[String])` is unchanged for in-process callers.
Desktop: the loop matches on `OsStr::to_str`. Flag values that are paths
(`--control-token-file`, `--automation-*-root`) become `PathBuf`s directly
and so keep working with any name; a non-Unicode `--control-token` is a
control-argument error like a bad `--control` port; non-Unicode file
arguments are collected and reported in a "Could not open" notice once the
app is up, so the launch still succeeds.
Test: `a_non_unicode_argument_is_a_usage_error_not_a_panic` (unix) runs
the binary with a `caf\xe9.png` argument for `info` and `convert` and
asserts exit 2, the message, and no panic output.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* photocraft-cli: put run()'s doc line back above run, not run_os
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Fix#1271, fix#1316: Preferences › Interface › System Title Bar
Since #986, Windows and Linux draw PhotoCraft's own one-row title bar
with its own window buttons. On tiling window managers those buttons do
nothing, and some Linux desktops look better with their own decorations.
The new System Title Bar preference (off by default) gives the window the
system's title bar and buttons back; it is read when the app starts.
macOS always uses the system's. Translated in every shipped language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
* Scorecard: count the System Title Bar preference
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Color Range: Photoshop's Sampled Colors model and the eyedropper on the canvas
Sampled Colors now compares colours in Lab (through the document's profile) the way
Photoshop does, reconstructed from Photoshop 2024's own masks (observation only):
per-axis distance outside the samples' Lab box in 8-bit units, a/b weighted x3, the
largest of the three, and a quadratic B-spline falloff of width fuzziness*256/255.
On 8-bit sRGB images it matches Photoshop to under one level of 255 on average
(the previous per-channel RGB distance was off by about 12). Several samples span
one Lab box, like Photoshop's Minimum/Maximum.
While Color Range is open, the pointer over the free canvas is its eyedropper:
a click picks that pixel, Shift adds to the sample, Alt subtracts (also through
ui.pointer on the control channel).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Color Range: Photoshop's Subtract from Sample, click order, 8-bit Lab grid
Measured through Photoshop's real Color Range dialog (eyedropper clicks):
- Subtract from Sample reshapes the samples' Lab box per axis: inside it the
nearer edge moves onto the subtracted value; on an edge or within the axis
width w it moves in to the first of m + k*w/5 beyond it (k <= 5); further out
nothing changes; an edge never crosses the other. Matches every measured box
to +-0.05. The click order matters, so select.colorRange takes an optional
`order` list and the dialog records it.
- The a/b centring shift of the previous change was an artefact of scripted
samples (Photoshop's descriptor Lab scale); with the eyedropper the sampled
colour is fully selected at any fuzziness. Removed.
- 8-bit documents compare pixels and samples on the 8-bit Lab grid, where an
a/b step above the samples' range counts half a unit more and one below half
a unit less (lab_range_coverage_8bit).
On Photoshop's eyedropper masks of a photo the mean error is ~0.46 levels of
255 at fuzziness 40/80 and ~0.02 at 5/10 (previous change ~0.9).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Toolbar: the header chevron switches between one and two columns (#1197)
The Pro theme's chevron was painted with a hover-only sense, so clicking it
did nothing. It now toggles Panels::toolbar_double (serde default, so older
state still loads); the toolbar is two columns when that is on or when one
column doesn't fit, and the chevron points left while two are shown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Toolbar: the Pro two-column toolbar fills every row (#1197)
Rows were cut per tool group, so a group with an odd number of tools left
a half-empty row (Move, Eyedropper). Pro draws no group dividers, so its
two columns now flow across groups; the other themes keep their per-group
rows between dividers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`write_note` built the icon rectangle as `[y, x, y + 20, x + 16]` with plain
i32 arithmetic on a position that comes straight from the file's `Anno`
block (or from `notes.set`). A stored icon top at or above `i32::MAX - 19`
overflowed on every PSD save, because `export_blocks` regenerates the
comparison block before deciding whether the stored one can be kept
verbatim: a panic in debug (surfaced by `file.saveACopy` as an internal
error, no file written), a wrapped bottom edge in release.
The far edge now uses `saturating_add`, matching the cast that already
saturates the origin, so the written rectangle is always representable and
the note survives a round trip at its stored position. No signature change:
`write_note` stays infallible and unchanged notes still compare byte-exact.
Test: `a_note_icon_near_i32_max_saves_with_representable_geometry` builds
the issue's `Anno` fixture at `i32::MAX - 5` (and a far-left and
`i32::MIN` variant), imports, exports, checks the written icon rectangle
and re-imports; also covers a model position beyond i32 and NaN, and the
ordinary `[42, 28]` control. Panicked at `annotations_map.rs:183` on main.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* raw: decode Nikon compressed NEF (lossless + lossy)
Nikon's default NEF (TIFF compression 34713) opened only as the embedded
8-bit JPEG preview. Decode the Huffman-coded strip: lossless and lossy
type 1 / 2, 12 and 14 bit, with the seeds and curve of maker note 0x0096
in the maker note's byte order. "Lossy after split" files stay
unsupported and keep the preview fallback.
Clean-room: format facts from prose write-ups (Clévy, Claff, ExifTool tag
names, T.81). The fixed Huffman tables are not in the files; they come
from LightCraft's black-box analysis of CC0 raw.pixls.us samples
(storytold/lightcraft#86, verified there on ~45 files from ~35 bodies).
No decoder code is shared: nefc.rs is written against this crate's TIFF
reader.
Also read Nikon BlackLevel (0x003d) in 14-bit units for 12-bit data, as
measured for LightCraft (D750 / D850 / Z 50).
Refs #50.
* raw: Nikon NEF without a BlackLevel tag is black-subtracted in camera
A D3200 NEF's darkest samples are 0-4 (noise around zero): assume 0
without the "black level not recorded" warning when the Nikon maker
note is present but has no 0x003d tag.
* raw: trim Nikon's right-edge padding columns
A D3200 NEF pads every row with 46 columns of 256 (exact in 97.5% of the
rows, lossy-coding noise elsewhere) after its 6034 image columns; they
developed as a pink stripe on the right. Trailing columns (at most 128)
that hold the last column's value in at least 90% of their rows, when
that value is dark, are left out of the active area and crop. A white-
clipped edge is never trimmed.
Auto forced at least 200% on any 4K monitor, overriding a fractional
system scale such as 125% or 150%, so Linux users on 4K got either
100% or 200% and nothing between. Auto now follows whatever scale the
system reports and uses the 200% fallback only when the system reports
no scaling (100%), which is the case #225 was fixing.
Claude-Session: https://claude.ai/code/session_01QcbChgBDVrdhjfsFA5D9nt
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: resolve edge-case crashes, unbounded allocations, and UI sync defects (#915, #916, #917, #918, #920, #921, #923, #924, #925, #926)
* Review fixups: drop hunks covered elsewhere, simplify shortcut conflicts
- Drop the brush preview spacing_enabled hunk (#941), the vanishing.rs
overflow guards (#869) and the artboard_cmds.rs Custom background change
(conflicts with #1032); ui.window.open already landed in #1133.
- Revert the Measurement Log export change: an empty selection keeps
exporting the whole log instead of an empty CSV.
- Drop the reach().min(1024) cap: effects still sample past a clamped reach,
which would give tile seams.
- Shortcut dialog: one pass, newly assigned shortcuts first, taking theirs
from any holder (overrides included); unchanged overrides only clear
defaults. More test cases (no-op OK, giving a shortcut back).
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>
* Desktop: write log records to standard error and a rotating log file
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: GPU startup fallbacks, a lost wgpu device,
the crash guard's panic report. A launch from a desktop menu has no
terminal either, so there was nothing to attach to a bug report.
apps/photocraft/src/logging.rs (no new dependency):
- standard error plus <settings dir>/logs/photocraft.log; each launch
rotates the previous log to photocraft.1.log and .2.log, so the log of
a run that crashed survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
(bounded) and written once the file is attached
- default levels: info for photocraft*, warn for everything else;
RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
error as the only sink
The logger is installed first in main (so the panic hook is recorded) and
the file is opened after argument parsing (--version and usage errors
leave no file). Documented in docs/development.md (Logs).
* Contributors: credit switch87 as Gert Pellin
* Desktop: report an unusable log folder through the logger
eprintln! panics when standard error is a broken pipe (a GUI launch on
Windows); log::warn! reaches standard error the same way once the file
sink has given up, and never panics. Same fix as storytold/vectorcraft#465.
Autosaver::new discarded the spawn result with .ok(): under thread or
resource exhaustion the recovery channel pointed at a dead receiver,
every send failed silently and last_result stayed None - crash
recovery was off while looking on. The failure is now recorded where
last_result/flush report it (via the same note helper the worker uses),
so health checks and flush surface it.
Default placement added the 100 px gap to the rightmost board's right edge in i32,
editing a board subtracted its old position from the new one in i32, and exporting
negated the board's origin, so a board at an i32 extreme overflowed (a panic in checked
builds, a wrapped position or content offset in release). Rects were also built with
Rect::from_xywh, which clamps the far edge and silently shrank boards past i32::MAX.
Board rects are now built in i64 and checked, so a rect with any edge outside i32 is a
bad-params error naming it; default placement with no room on the right asks for an
explicit x; a move whose offset needs more than 32 bits is rejected unless
moveContents is false; and export reports a board at i32::MIN instead of negating it.
All checks run before the document changes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- A layer about the canvas's size keeps its whole effect region even where it overhangs the
edge, so the region keeps its size while the layer is dragged and the GPU moves its effect
maps with it instead of rebuilding the shape, distance fields and every effect each frame.
- Surface::translated moves a surface by copying encoded tile bytes tile to tile (no float
round trip, no full-size intermediate; whole-tile moves share the tiles): 2.3x faster than
to_interleaved + write_interleaved. Used by layer.translate, Move tool drags and
algo::resample::translate_surface.
- perf_scenarios: Move tool drag frame rows (styled layer, full-canvas layer with and without
a drop shadow).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The bridge branch forwarded only the path to the running app's
app.save, silently dropping quality, format, tiffLayers and index (the
headless branch honours all of them): a client asking for a
quality-50 JPEG got an ok reply and whatever the app's current
settings produce. Those params now fail fast with the reason - naming
headless mode - before anything is written.
Comp positions were computed with unchecked i32 arithmetic on values from
files: import added a layer's origin to the cmls `Ofst` (and truncated wider
integers with `as i32`), export subtracted the origin from the stored
position, and applying a comp subtracted the current position. Extreme values
panicked with overflow checks on and wrapped otherwise.
Import now keeps offsets at i64 and drops a position whose sum does not fit,
with an import warning; export omits an `Ofst` that does not fit; applying a
comp skips a move that does not fit. Visibility of the same entries is kept.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Selection drags: ⌘⌥-drag floats a copy; ⌘-drag with the Polygonal Lasso and Magic Wand
- `select.float {"copy": true}` floats a duplicate of the selected pixels
and leaves the originals in the layer; ⌘⌥-drag inside the selection uses
it (⌘-drag still cuts).
- ⌘-drag inside the selection now also floats it with the Polygonal Lasso
and the Magic Wand (not while a polygon is being drawn); a plain press
there still places a point / samples, so only ⌘ moves the selection.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* ⌘ / ⌘⌥ with a selection always cut / copy and drag it, never combine selections
With a selection tool and an active selection, a ⌘ or ⌘⌥ press anywhere (not
only inside the ants) floats the selected pixels (cut, or copy with ⌥) and
drags them; ⌥ no longer turns it into a subtract. The cursor shows no
selection-mode badge while ⌘ is held over a selection.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* ⌘ with a selection tool moves the layer outside the selection; ⌘⌥ duplicates it
With a selection tool, ⌘ acts as the Move tool for the drag: inside the
selection it cuts the selected pixels (⌘⌥ copies them), and outside the
selection, or without one, it moves the whole layer; ⌘⌥ duplicates the
layer first and moves the copy. ⌘ never combines selections, so the cursor
shows no add / subtract badge while it is held.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* ⌘ outside the selection moves the layer, not the selected pixels
Only a ⌘ press inside the selection floats its pixels; outside it the layer
moves (`command_moves_layer`). Drops the duplicate badge check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* ⌘⌥-drag on a floating piece drops it and drags a copy
A floating piece (from a ⌘ cut-drag) dragged again with ⌘⌥ is dropped where
it is and a copy of it floats with the pointer, as ⌘⌥ does on any selection.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The deep EXR decoder (#642) was verified against the OpenEXR project's deep test images, but those
lived in a hand-copied `corpus/exr/` and the test skipped when they were absent, so CI never read
them. `cargo xtask corpus --exr` (and `--all`) now fetches the five `*.deep.exr` files of
openexr v3.5.2 `src/test/bin/test_images`, sha256-verified against `xtask/exr-corpus.sha256`.
`real_deep_files_decode` is behind the `corpus` feature and fails when the corpus is missing. It
checks each file's sample total, largest per-pixel count, cumulative counts and channel lengths,
and that the decoded u32 ID channels hold exactly the IDs listed by the upstream `.txt` sidecars
(an independent oracle), plus the flattened decode. FILE-216-3 moves to partial (EXR read in CI;
TIFF and RAW still are not).
* fix(cli): report missing font substitutions in run, info, and convert (#1251)
* test(cli): verify missing fonts in run/info/convert and no duplicate or false warnings
* docs(cli): document missing-font fallback warnings in convert, info and run
* style(cli): apply exact rustfmt format for multi-command warning test
* CPU effect maps: key fill layers by their cached pixels, drop opacity/visible
The layer-effect map cache keyed a Fill layer by its Fill spec alone.
But what shapes the maps is the layer's cached pixels whenever they
still match the fill (what a PSD import stores and Image > Transform /
crop / canvas ops rewrite in place, without touching the spec): after
such a rewrite the CPU compositor kept serving the pre-rewrite maps, so
flattens and the CPU canvas showed the drop shadow of the pre-rewrite
shape while the GPU canvas was correct - until budget eviction or a
purge. The key now hashes the cache surface's tile fingerprint while it
matches the fill, like the GPU's fx::shape_key/group_key already did.
layer.opacity and layer.visible leave the key: the maps depend only on
fill_opacity (opacity is applied when the maps are composited,
visibility only skips the lookup), so an opacity slider drag or a
visibility toggle rebuilt every shadow and glow map over the whole
layer for nothing. Again matching the GPU key.
* Effect-map key: keep child visibility/opacity/blend for groups
A styled group's maps come from its children's composite, so hiding a
child or changing its opacity (or blend, clipping) must change the key;
only the top layer's own visible/opacity stay out. Adds a warm-vs-purged
render test for a group with a drop shadow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Effect-map test: name the edit tuple type (clippy type_complexity)
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>
* Option flips Dodge/Burn and Blur/Sharpen for a stroke
Holding Option (Alt) when a stroke starts paints with the opposite tool:
Dodge burns, Burn dodges, Blur sharpens and Sharpen blurs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Tests for the Option flip of Dodge/Burn and Blur/Sharpen
A unit test for alt_flipped and a stroke test through tool_event with
Option held that checks the journal runs the opposite command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Eyedropper cursor: a pipette instead of the crosshair when sampling
The Eyedropper tool and ⌥-sampling with a painting tool now show the same
pipette pointer as the Color Picker's eyedropper (its tip on the sampled
pixel). Preferences › Cursors › Other Cursors = Precise keeps the crosshair.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Keep alt_eyedropper's doc comment on alt_eyedropper
pipette_cursor was inserted between the comment and its function.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple
Grayscale deep files (Y + A + Z) flattened wrong: the tail of the
composite loop wrote a fixed [R, G, B, A] quadruple, so a GrayA image's
alpha slot received an un-premultiplied luminance copy (alpha 1.0 for a
0.5-alpha file) while the real alpha fell past the end of the row. The
values are now written by layout: RGB(A), gray(+A), gray - with the
alpha in the slot it belongs to.
* Deep EXR flatten: no per-pixel Vec allocation
Build a fixed [f32; 4] plus a length instead; this runs once per pixel.
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>
* feat(ui): edit the Color panel's hex and RGB readouts
The Color panel's hex readout was a static label. It is now an editable
field: type a colour with or without the leading '#', and it applies as
you type. The R, G and B readouts beside it become value fields too, so a
channel can be set directly.
Both use the dock's themed field surfaces, so they match the rest of the
panel. Partial or invalid input never changes the colour and snaps back
when focus leaves. Editing updates the foreground (or background) and
keeps the panel's hue memory for greys.
Tests cover hex with and without '#', partial input, the RGB fields and
the pro panel's readout.
* rustfmt panels.rs
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>
- Debug/test builds use line-tables-only debug info: the workspace test
build is 1.9x faster (509 s -> 271 s, measured on Windows) and its
target dir 31 GB -> 13 GB, which also fixes the Linux job's craft-fonts
build running out of disk.
- The corpus job's --release build drops thin LTO and the single codegen
unit: 2.7x faster to build (744 s -> 273 s); it was 23.5 of 37 minutes.
- Only main saves rust-cache entries (PRs restore main's), with
cache-on-failure, so per-PR copies no longer evict main's caches from
the 10 GB budget.
- The Linux job deletes unused image toolchains in the background.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Tools leave hidden layers alone: painting or moving one is refused (#571)
Photoshop refuses to paint on, fill or move a hidden target layer ("the
target layer is hidden"). The painting, retouching, Paint Bucket, Gradient
and Move commands now fail with that message when the target layer, or a
group around it, is hidden (alpha channels and Quick Mask still paint), and
the canvas refuses the press so no live stroke draws first. Auto-Select
already skipped hidden layers; a test now pins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Hidden layers: the live Gradient still works over a hidden target
Review on #872: the live Gradient adds a Gradient Fill layer above the
target rather than painting it, which Photoshop allows over a hidden
layer. Only the classic Gradient is refused now, and a test covers the
live one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop masks a new adjustment or fill layer with the active selection, so the
adjustment acts on the selected area only. `layer.newAdjustmentLayer.*`,
`layer.newFillLayer.solidColor`, `.gradient` and `.pattern` inserted the layer
without reading `doc.selection`, so select → New Adjustment Layer gave an
unmasked adjustment over the whole image. The Gradient tool's fill-layer
command already did the right thing; its inline code becomes the shared
`commands::selection_mask`, used by every creator. The selection stays active,
as after Layer › Layer Mask › Reveal Selection.
Tests: each command at 8, 16 and 32-bit gets a linked, enabled mask that reveals
the selection and hides the rest, the composite outside the selection is
untouched, undo removes the layer; and no mask without a selection.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
As in Photoshop, the wheel scrolls vertically, Shift or Cmd/Ctrl + wheel
scrolls sideways and Alt + wheel zooms; Zoom with Scroll Wheel swaps the
wheel and Alt + wheel. egui no longer turns Cmd/Ctrl + wheel into zoom,
so only a real pinch (Event::Zoom) zooms. Windows delivers a touchpad
pinch as Ctrl + wheel in fractions of a notch: the raw input hook turns
those back into pinch events, so pinch-zoom keeps working there while
Ctrl + a notched mouse wheel pans.
The canvas scroll bars are always shown in Standard Screen Mode (none in
the full screen modes, as in Photoshop). With Overscroll on, an image
that fits the window can be scrolled edge to edge with them.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Holding Alt (Option) over the line between two layers in the Layers panel
shows the clip cursor; a click runs Layer > Create Clipping Mask on the upper
layer, or Release Clipping Mask when it is clipped already, as in Photoshop.
The line only counts between siblings that are next to each other in the
stack (not inside an open group or across filtered-out rows), and stays
clear of the eye column and the effects triangle. The cursor is the Lucide
corner-right-down icon.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
opacity_keys::handle ignored egui's `repeat` flag, so the first auto-repeat
of a held digit arriving within the 800 ms two-digit window read as a
second typed digit: holding 4 set 44%, and holding 0 flipped between 100%
and 0%. Repeats are now consumed without being counted.
vector::Paint::from_fill discarded every gradient field beyond stops,
angle, scale and style, so Photoshop files whose shape fills use opacity
stops, colour midpoints, a centre offset (Ofst), dither or no
"Align with layer" rendered with the wrong ramp, alpha, position or
banding. Paint::Gradient now carries the full ramp (sorted stops,
midpoints, opacity stops, dither) and the compositor's geometry
(centre offset, whole-pixel end-point snapping, pixel-corner sampling,
the align frame choice: shape bounds or canvas), with shape strokes
painting through the same path.
The vector crate cannot depend on compose, so the ramp and geometry
mirror effects::gradient_units/gradient_t, fill_layout::gradient_layout
and gradient_fill::{Ramp, midpoint_remap, dither}. A parity test in
compose (which sees both) renders each non-default field, style and
frame as a shape layer and as a fill layer and requires equal pixels;
from_fill binds every Fill::Gradient field — no ".." — so a new field
fails to compile until the shape renderer honours it.
`Mapping::offset()` saturates its `as i32` cast and `Rect::translate` saturates
each edge, so a far `source` or `offset` (3e9, i32::MAX) produced a sample
window narrower than the stroke bounds. `clone_sample` then relabelled that
region as the full-size rect and `Region::px` indexed past the buffer:
`range start index 120 out of range for slice of length 92` at
`paint/src/retouch.rs:54`, which the dispatch guard reported as an internal
error. #716 fixed the rotated/scaled branch (`source_rect`); this is the pure
translation branch.
`clone_sample` is now fallible: when the translated window does not keep the
stroke rect's size, the command returns a bad-params error naming the offset
and the document and history are untouched. A source far outside the canvas
but inside the i32 range still clones transparency, as before.
Test: `clone_and_heal_with_a_far_source_are_plain_errors` (both tools, the
issue's inputs, a negative far source, the untouched-layer and no-history-step
assertions, and the in-range far source that must keep working).
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
connection() scaled the PCS by src.media_white() / dst.media_white() with
no check on the divisor, so a v4 profile whose wtpt tag holds zeros (parsed
and stored verbatim) gave a NaN scale, and every colour through a direct
absolute transform or a paper-simulating proof came out NaN. The transform
is now refused with CmsError::Invalid when either white has a zero,
negative or non-finite component; the other intents never divide by it and
are unchanged.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
With the layer mask targeted (its thumbnail clicked, or Alt-clicked to view
it), Edit > Paste, Paste in Place, Paste Into and Paste Outside now paste
the clipboard's luminosity into the mask, placed as a paste places it, over
the mask's values (the pasted pixels' transparency, and the selection for
Paste Into / Outside, let the mask show through). One history step, no new
layer, at 8, 16 and 32 bits. A targeted alpha channel or Quick Mask takes
the paste the same way, as in Photoshop.
The paste commands now follow the target like the fills and filters do
(channel_cmds::routed), and accept a "target" param.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Edit Contents switches to the already-open contents document
Opening a smart object's contents a second time opened another copy.
It now activates the document already linked to that smart object.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* rustfmt smart_cmds.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Fixes#933. With no selection the whole document is measured, and
`selection_mask` sized its mask with `r.width() * r.height()` in u32. A legal
65536×65536 document (2^32 pixels) overflowed the product, so `run` panicked
and dispatch reported an internal error; in release the product wraps to 0,
`gray_image` still renders the full region (~64 GiB of f32 RGBA) and
`measure_features` then indexes the empty mask.
`selection_rows` now computes the pixel count in u64 before anything is
allocated and refuses more than `MAX_MEASURE_PIXELS` (400 MP) with an error
that says to select a smaller region; the mask is sized from that checked
count. The measurement label counter is also bumped only after the rows are
computed, so a failed measurement no longer skips a number.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
With Spacing unchecked, dabs are spaced by pointer speed, and a segment
without timestamps got a single dab at its end point. The canvas sends no
timestamps, so a Shift-click straight line with such a brush (the Eraser
with 10% smoothing gives start, 90% and end points) left three separate
dabs instead of a line. Untimed segments now fall back to distance spacing.
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tools.setBrush and stroke brush patches accepted any Scatter amount. The paint path
multiplied it by the radius in f32, so 1e38 overflowed the dab centre to infinity, and
dab_rect's unchecked i32 arithmetic then panicked (wrapped to an empty rect in release).
Brush validation now rejects primary and Dual Brush Scatter outside 0..=MAX_SCATTER (10,
the slider's 1000 %) with a BadParams naming the value, before the session brush changes
(also between setBrush's two merges, so infinity is named rather than reported as null).
bounded_for_render caps both amounts for direct paint API users (NaN scatters nothing),
and the dab rectangle, now one helper shared with the retouch tools, saturates.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
In the Photoshop themes (the default ProMedium included) the Brush and
Eraser options bar drew three icon buttons that did nothing when clicked.
The engine already supports everything behind them:
- Airbrush toggles the brush's Build-up (lit while on).
- The gear beside Smoothing opens Photoshop's smoothing options: Pulled
String Mode, Stroke Catch-up, Catch-up on Stroke End and Adjust for
Zoom. They edit the brush like the Smoothing % does, so they stay per
tool and reach the live stroke and the commit.
- The symmetry button opens a menu with Symmetry Off and every path the
Paths panel lists (saved paths, the Work Path, the selected layer's
shape path or vector mask), running paint.symmetryFromPath /
paint.symmetryDisable. It is lit while symmetry is on; with no path it
says how to make one.
The three buttons get accessible names (brush_picker::named), and
"Symmetry Off" is added to the twelve complete catalogs.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Median gathered every sample of the disc (about pi r^2 values) for each pixel and channel and selected the middle one, so the work grew with the square of the radius. For radius 2 and up, rank each channel's window samples by total_cmp (8-bit samples by level), keep a histogram of the disc's ranks that slides in a serpentine over the tile (one end of each disc row or column out, the other in, per step), and track the median rank as it moves (Huang), with block counts so it crosses large empty rank ranges a block at a time. The value picked is the sample the selection picks, so output is bit-identical at every depth. Radius 1 keeps the direct selection (now median_direct), which is also the test oracle.
layer.linkLayers picked the new link group as max(link_group) + 1, computed even
when unlinking. A .pcraft or PSD can store u64::MAX, so the addition panicked in
checked builds and wrapped to 0 in release, where it could merge the selection
into an existing group 0.
Add Document::unused_link_group: max + 1 when it fits, otherwise the smallest
free id from 1, never wrapping. Link Layers uses it only when linking and returns
an error if no id is left. Also read the first selected group and the member
counts without indexing.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The midpoint action checked `i + 1 >= v.len()` on the raw `index` param,
so an index of usize::MAX overflowed (a panic with overflow checks on, a
silent no-op success with them off). The shared index parser now takes
the number of addressable entries and rejects an index past them before
any arithmetic, for every action (move, delete, color, opacity, midpoint)
and both kinds, so all of them validate the same way.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>