Copy refused fill and adjustment layers ("the active layer has no
pixels"). With no pixel selection they are now taken whole, like smart
objects, type, shape and group layers, and Paste recreates them in this
or another document. An adjustment layer's clip spans the canvas, so
paste placement and the OS-clipboard mirror stay consistent.
Scorecard UI-217-23 note, gaps.md G9 and target-app-parity.md updated;
the row stays partial (a single pixel layer still copies pixels only).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Type: right-click on committed text opens the type layer menu (#7324)
With the Type tool, a right-click only opened a menu while text was being
edited; over committed type nothing opened. The menu was keyed on the edit
session alone (`editing_text`).
Over committed type, a Type tool (not a Type Mask tool) now opens
TYPE_LAYER_MENU: the type edit menu's layer rows, without the edit rows
(Undo, Cut, Copy, Paste, Select All), which would act on the document, and
without Faux Bold/Italic, which act on the characters being edited. Type
outside the selected layers becomes the target first, as a click with the
Type tool makes it. The control channel's right button takes the same path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Type layer menu: the clicked type becomes the active layer in a multiselection (#7324)
The structural rows (Rasterize Type, Convert to Shape, Create Work Path, the
paragraph/point conversions) act on the active layer. When the right-clicked
type was already selected but another selected type layer was active, they
acted on that other layer. Make the clicked type the active layer while
keeping the selection (layer_multi_cmds::set_selection), so the formatting
rows still act on every selected type layer. Found by CodeRabbit on #7430.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The Mixer Brush could not be emptied. MixerState::clean dropped only
the pickup well, paint.mixerBrush refilled the reservoir from the
foreground before every stroke unless loadAfterStroke was false, and
the app always sent the defaults. A stroke on a new layer over an image
always carried the foreground color, and the options bar had no Load
Brush or Clean Brush control.
Cleaning now empties both wells, so a clean brush lays down only the
color it picks up from the canvas. The new commands brush.mixer.load
and brush.mixer.clean load and empty the brush. paint.mixerBrush reads
loadAfterStroke and cleanAfterStroke from the brush and applies them
after the stroke. Both are on by default, and a stroke with both on
paints as before. A stroke whose color differs from the one recorded
by the last stroke, load or clean loads that color first. A load or
clean records the foreground, so choosing a new foreground loads the
reservoir.
The options bar has the Current Brush Load swatch, whose menu holds
Load Brush and Clean Brush, and two toggles for the automatic Load and
Clean. Brush Settings shows the same two options. The new strings have
rows in every catalog.
The live preview only covered selections up to 256x256 px; larger ones (the
command takes up to 16 M) errored, the UI remembered the failure and showed
nothing until release.
Engine: a preview whose selection (or landed area) is over 256x256 is now
solved on a scaled-down copy of the area around the selection and where it
lands (area-average resize of the target, or of the composite with Sample All
Layers, and of the selection), on a one-layer document of its own, then
enlarged back (bilinear, float samples, so any depth) over the selection's
shape where it lands and, for a move, where it left. The landed content is
sampled through the scaled placement, so rounding the scaled offset does not
shift it. ContentAwarePreview::refine gives the full-resolution job, with the
command's own margin, so the refined preview is the command's result bit for
bit. Every step checks the JobCtx; the session is never touched; bad params
and selections over the command's limit still error. Selections within
256x256 take the unchanged path (same output as before, checked by hash
against main). The bilinear resampler used by Transform On Drop now runs its
rows in parallel (same results).
Measured (release, 24 MP 8-bit document, Ryzen 9 7950X with other builds
running): coarse preview 600x400 0.26 s, 1500x1000 0.38 s, 3000x2000 0.69 s
(256x256 full-resolution preview: 0.21 s); the exact refine takes as long as
the command (0.8 s, 4.1 s, 9.4 s).
UI: cam_preview shows the coarse result, then starts the refine as a second
cancellable job and swaps it in; a failed refine leaves the coarse result
shown, and a failed first job is still not retried.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Lightness slider's track in the Hue/Saturation editor (Properties
panel and dialog, which share it) ran black -> the current hue -> white.
Photoshop's Lightness track is a fixed black -> mid grey -> white ramp
whatever the Hue. lightness_track() now returns that ramp and no longer
takes the hue; the Saturation track still follows the hue. The UI-217-16
scorecard row describes the new track.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With Preferences > General > Animated Zoom on (the default), a Zoom tool
press held still past 0.3 s zooms continuously about the press point
(2 doublings a second, frame-rate independent, within the zoom_levels
range) until release; Alt zooms out. The release is then no click step.
A quick click still steps, Scrubby Zoom and the zoom rectangle are
unchanged, and with the preference off a hold is one click step.
The hold logic lives in the new zoom_hold.rs; canvas.rs only calls it.
general.animatedZoom leaves HIDDEN_UNTIL_IMPLEMENTED; prefs that do
nothing 25 of 166.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Dragging layers onto another document's tab landed in #851 (#589), and the
pasteboard colour menu in #2633 (#2462); the TOOL-214-4 note and the
context-menu parity row still listed both as open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tool options and the selected tool were already remembered (tool_memory, #2814), but
each painting tool's own brush (UiState.tool_brushes) was lost at quit, and the tool that
owned the session brush restarted as the Brush, so after quitting with the Eraser its
brush became the Brush's.
- Preferences.toolBrushes {owner, brushes}: saved change-driven when the pointer is up and
on exit, restored at launch with stored tips as references (loaded on swap).
- brush_cmds::remembered_settings / restored_settings: shared with lastBrush; restored
brushes pass the tools.setBrush checks and are clamped (size, hardness, opacity, flow,
smoothing).
- tool_memory::sanitize clamps restored tool options to their options-bar ranges.
- Reset Tool / Reset All Tools clear the persisted state through the same path.
- Scorecard UI-217-1 note updated (stays partial: font recents).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab
Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.
- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
importer), with its own Lab <-> sRGB conversion (same formulas as
photocraft-color; codecs stays standalone) for formats without Lab,
which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
tiff crate as RGB and labelled Lab; a* and b* are converted from the
file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
layer is written to TIFF in Lab (other formats still use sRGB).
Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.
Fixes#4936
* PSD: a smart object's filter mask shows when the file opens
Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.
- compose::filter_mask holds the mix, moved from the engine, and its
inverse.
- Import decodes the unfiltered pixels from FEid
(smart_map::unfiltered_from_item) and keeps the mixed result as the
smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
back to what the document showed. Transparent pixels stay empty.
Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)
DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.
The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.
testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))
Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.
* File > Save As / Save a Copy / CLI can write PDF: one flattened page with the embedded profile (#3401)
* Codecs: open Cineon (.cin) film frames read-only
Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.
* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)
- flatten: a path's flattened points are capped at 1 << 20; past it every
curve keeps a proportional share of its pieces (at least one), so hostile
handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
dash_polyline keeps the dashes made so far and draws the rest solid
instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
slope (vanishingly short) are skipped, so huge finite coordinates no
longer produce NaN coverage. fill_rasterizer sorts edges once instead of
after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
shared mask render body, map_knots helper, shared straight-segment test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)
- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
every pattern of a block, a .pat file and the pattern blocks of one
document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
coordinates clamp to i32, and a v6 trailing entry without a usable id
attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
use the bounded preallocation; ImageData::decode_channel (decoded every
plane to return one, unused outside its test) is removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Vector: bound stroke outline points (#5211)
- stroke_polygons dashes all lines first, then counts round caps and
joins. When their full-resolution tessellation would exceed 1 << 20
outline points, every round cap and join gets the same smaller segment
count (down to 4 per circle, 1 per join; area-exact radius kept).
Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
instead of per polyline, so many subpaths cannot multiply the dash
(and cap) count. Past it the rest is drawn solid, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: write tiff blocks without copies, inflate merged ZIP once
- ImageSourceData::to_bytes writes the global mask and global blocks
straight from the model with 4-byte alignment instead of cloning each
block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
buffer (taken and handed back, error or not) instead of building their
own output and copying it up per nesting level. Bodies that fail or are
not transcoded are truncated before being copied as stored or dropped.
Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
when asked: a ZIP stream is inflated once (the decode's stream check is
the parse's); the parse-only check runs only when the decode fails, to
keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)
Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.
A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.
Refs #1366
* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers
Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(history): count snapshot-only tiles against the history budget
History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)
Two more causes of ghosts when removing strings and the small knobs
selected with them:
* Auto sampling summarises the selection's surroundings by a few
clusters. Around a long selection those come from the strings'
surroundings, so a small part elsewhere (a tuning knob in a differently
coloured corner) lost its own surroundings and was filled from far away
in another colour. Around a thin selection the Auto area now always
reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
the anti-aliased fringe or halo that a selection drawn close to the
object leaves outside it.
Refs #1366
* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)
Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.
Refs #1366
* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open
Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.
Two changes in crates/algo:
- A second GrabCut pass whose background model only knows the backdrop. A
colour model fitted to the image frame (minus a margin around the
salient blob, in case the subject runs out of the picture) marks every
pixel it explains as probable background; the blob and whatever the
backdrop cannot account for start as probable foreground. Only
components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
mask) always fill, larger holes up to 5 % fill only when their pixels
fit the foreground model better than the background's.
Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.
* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)
Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.
edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.
* .kys import: tool keys and sets saved on Windows (K3)
Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).
A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.
* cargo fmt the extension lists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: timothydaly1 <tadaly04@protonmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Fabio Lissi <fabio.lissi@gmail.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
A shape layer's path is its vector mask in Photoshop (Properties > Masks),
with its own vector-mask density and feather. PhotoCraft's ShapeLayer had
neither, so soft shapes could not be made, and stroked soft shapes from PSDs
lost both on import.
- doc: ShapeLayer gains `density` (0..1, default 1) and `feather` (px,
default 0).
- vector: the shape renderer applies them to the fill as a soft vector mask
(fill shows beyond the path at 1 - density; the feather is the vector
mask's Gaussian), so the shape's own pixels, the GPU (which samples them)
and the stroke/fill split all follow. The stroke stays crisp. The feather
blur moves from compose::masks to vector::feather and both share it and the
feathered area, so a shape and an equal vector mask render the same pixels.
- compose: soft shapes' bounds reach their fill, their layer effects follow
the soft alpha, and the split cache key includes density and feather.
- engine: `layer.vectorMask.edit` on a shape layer sets density/feather in one
history step (path/enabled/linked/invert are refused there); density is now
validated (finite 0..100) instead of clamped. `layer.vectorMask.info`
reports a shape's values.
- ui: Properties shows the Vector Mask Density and Feather sliders for a
selected shape layer in both themes (live drag, one undo per gesture).
- .pcraft: manifest and convert carry both with serde defaults (older files
load at 100 % and 0 px).
- PSD: export writes them as the vector density/feather mask parameters;
import reads them into the shape (re-rendered, as the stored pixels lack the
fill beyond the path). Soft unstroked solid/gradient shapes now import as
shapes instead of fill layers with a vector mask; pattern-filled ones stay
fill layers.
Photoshop's 10 px feather on a 100 px black square, row 100, x = 30..72:
ours is within 1 level of every measured value (8, 16 and 32 bits);
density 50 % shows 127/128 beyond the path and 0 inside.
Scorecard: TOOL-214-12 done.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Before this change, Export As always resampled the exported copy with Bicubic, and the preview
used Bilinear. Pixel art and screenshots could not be enlarged with hard edges. Photoshop's
Export As has a Resample menu next to Width/Height/Scale for this.
- engine export_preset_cmds: RESAMPLE lists Photoshop's six choices (Bicubic Automatic, the
default, then Bicubic, Bicubic Smoother, Bicubic Sharper, Bilinear and Nearest Neighbor)
and export_resample() checks a value. An unknown value is now an error. Before, Image Size
quietly treated it as Bicubic.
- Export presets save and restore resample. Older presets load with the default.
- layer.exportAs takes a `resample` param, checked even at 100 %.
- Export As dialog: a Resample dropdown under Image Size. It is remembered with the other
settings, and a bad saved value falls back to the default. export_document passes the
choice to image.imageSize for the exported copy only.
- Preview: the proxy keeps its fast Bilinear path, except for Nearest Neighbor, which is
just as cheap and is what the user wants to see. The preview texture also uses NEAREST
magnification then, so small exports look blocky instead of blurred.
- Translations for the three new labels in all 20 catalogues.
- Scorecard FILE-215-18 is now done. Also fixes a stale doc comment on VectorMask::feather,
because the compositor does feather vector masks.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Re-checked every row this PR flips against current main: the cited
tests still exist and none of the rows was flipped meanwhile.
BUG-208-4 is done too: shift_alt_before_the_drag_intersects_the_selection
(marquee_tests.rs) covers the marquee Shift+Alt intersect.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Arrow-key nudges: a run of nudges is one History state, with Tools preferences (#2259)
Tapping or holding an arrow key nudged the selected layers one pixel per
History state, so one Undo took back one pixel. Nudges of the same thing
that follow each other now share one `coalesce` key (the engine already
folds edits with the same key into one step), so a run is one state and
one Undo takes it back.
- nudge_bundle.rs decides which run a nudge belongs to: the previous nudge
was of the same thing (layers, selection outline), came within the
pause, and left the document as it is now (no other edit, Undo/Redo,
layer selection or save in between).
- move_mods.rs runs layer.translate and select.transformSelection through
it; Alt-nudges (a copy each) never join a run. Selected pixels float
until dropped, which is one state already.
- Free Transform keeps its own session history; its box nudges fold into
one session step (transform_tool.rs).
- Tools preferences: Bundle Arrow-Key Nudges (default on; off gives a
state per press as before) and Nudge Bundle Pause (ms) (default 1000,
100..10000, hidden while bundling is off). Labels translated in all
catalogues.
The two #1428 tests that asserted a state per press for the selection
outline now cover both settings.
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Shapes: on-canvas corner radius widgets for live rectangles (TOOL-214-12)
As in Photoshop, with Path Selection or the Rectangle tool the active shape
layer's live rectangle shows a round widget at the centre of each corner arc
(a few screen points inside a sharp corner). Dragging one rounds every corner
alike, Alt drags that corner alone; the radius follows the move along the
corner's diagonal from the press, within half the shorter side. The drag
previews the new outline and commits one shape.edit with radii on release,
one history step; a click changes nothing.
Docs: ui-parity row done, TOOL-214-12 note updated (still partial: shape
feather), target-app-parity, regenerated scorecard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Corner radius widgets: every selected rectangle, Move tool, cursor and readout
As in Photoshop:
- every selected live rectangle shows its widgets, also with the Move tool
when Show Transform Controls is on;
- dragging any widget gives every selected rectangle the same radius (each
capped at half its own shorter side); Alt changes that corner on each;
all the shape.edit calls share one history step (coalesce);
- the pointer over a widget is the arrow with a rounded-corner badge, and a
drag shows an "R: 25.0 px" readout;
- while dragging, the transform box and the Move rollover highlight hide, and
the Move tool's snapping started by the press is dropped.
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>
* Pen tool: Shift constrains segments and handles to 45° (TOOL-214-10, #3751)
Shift-click puts the next Pen anchor on the nearest 45-degree ray from the
previous anchor (at the pointer's projection onto it); Shift held while
dragging out direction handles turns them to 45-degree steps, read live
per move. The first anchor, closing on the first anchor and the last-anchor
break are unaffected; after Alt breaks the pair Shift constrains the
outgoing handle alone. The rubber band previews the constrained point.
Reuses stroke_constraint::snap45. Flips scorecard TOOL-214-10 to done.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Regenerate docs/scorecard.md after merging main
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Brush Settings: a response curve per dynamic control (#3294)
Every brush Dynamic (Size, Opacity, Flow, Roundness, Scatter, Count,
Depth, Foreground/Background...) now carries its own response curve for
its control (Fade, Pen Pressure, Pen Tilt, Stylus Wheel, Rotation),
applied after the global Preferences pen curve. It is part of the brush
JSON, so tools.setBrush sets it (e.g. shapeDynamics.size.curve), actions
record it and presets keep it; linear by default, so existing brushes
paint unchanged.
- paint: ResponseCurve, the global pen curve's monotone cubic moved down
from the engine as a fixed-size Copy type (no allocation per dab);
serialises as its point list. dynamics::dynamic_value applies it.
- engine: prefs::PressureCurve is now that type.
- ui-egui: each Control row gets a curve button (lit when the curve isn't
linear) that shows the same editor as Preferences > Tools.
- scorecard: TOOL-213-11 done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n: Response curve for hu, tr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fix i18n after merge: Serbian Response curve, drop duplicate hu row; regenerate scorecard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md after merging main
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>