Commit Graph
3466 Commits
Author SHA1 Message Date
David MannandClaude Opus 5.5 e7576cd997 Move tool: Ctrl/Cmd+arrow nudges the layer from any tool (#2474) (#2556)
In Photoshop, holding Ctrl (Cmd on macOS) is the temporary Move tool for
most tools, so Ctrl/Cmd+arrow nudges the selected layer 1 px, with Shift
10 px, and with Alt duplicates first. PhotoCraft only nudged with the Move
tool itself, and egui's consume_key never ignores an extra Ctrl/Cmd, so
Ctrl/Cmd+arrow did nothing anywhere.

- hold_keys::cmd_nudges: Ctrl/Cmd+arrow nudges wherever Ctrl/Cmd is the
  temporary Move tool, with the Move tool, and with the selection tools
  (moves the selected pixels). Not during Free Transform, text editing, or
  an in-progress polygon or Magnetic Lasso border.
- move_mods::arrow_keys adds Command to its patterns in that case.
- nudge decides on floating the selected pixels as the Move tool.
- Direct Selection keeps Ctrl/Cmd for itself, like Path Selection.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 13:08:45 -04:00
Valentin KleinandClaude Opus 5.5 87068253c6 Move tool: outline follows the moved layer (#2413) (#2565)
View > Show > Layer Edges was painted before the frame's tool events
were read, from the session document. Two bugs came from that:

- The frame in which Auto-Select picks a layer on the press still
  outlined the previously selected layer, because the pick ran after
  the outline had been drawn.
- During a live Move drag the moving pixels come from the preview
  document at the drag offset, but the outline used the committed,
  unmoved bounds. It stayed where the layer started until the release,
  so it trailed behind and was visibly off on fast drags.

The canvas now reserves the outline's z-slot where it was drawn and
fills it after the tool events, so it outlines the newly picked layer.
The rect is offset by move_ui::shown_offset, which is the offset the
image was drawn with this frame. That keeps the outline in lockstep
with the pixels. The moving set (the move targets plus their
descendants) is computed once per preview revision, so there is no
per-frame scan.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:08:40 -04:00
Valentin KleinandClaude Opus 5.5 4d7e5c1ef0 Marquee: keep one-pixel rows and columns on release (#2391) (#2562)
The live preview (ants and W/H readout) showed the whole-pixel rectangle
from marquee_px, but finish_gesture deselected any rectangle narrower or
shorter than 2 px. That threshold stood in for "was this a click?" because
floor/ceil turns a click between pixel edges into a 1x1 rectangle, so a
deliberate one-pixel column or row, and a thin drag replacing an existing
selection, ended with no selection at all.

Release now commits exactly marquee_preview_px, which decides a click by
screen distance (shape_dialog::is_click at the view zoom) and drops only
zero-area rectangles; the preview draws no ants when the release would
deselect. A plain click still deselects, as in Photoshop.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:08:36 -04:00
Harrison O'Connor-Hoover ac90ca2b1a Capture marquee selection modifiers at mouse press (#2558) 2026-10-10 13:08:31 -04:00
Harrison O'Connor-Hoover f55bc2d697 Fix snapping when drawing a new crop inside the default frame (#2549) 2026-10-10 13:08:27 -04:00
Nigel Ihza Firdausy 13d89378d8 fix(properties): expose equal-gap layer distribution (#2349) (#2398) 2026-10-10 13:08:22 -04:00
41d54036fc fix(selection): constrain Polygonal Lasso segments with Shift (#2352) (#2396)
* fix(selection): constrain Polygonal Lasso segments with Shift (#2352)

* Polygonal Lasso: make the Shift constraint helper visible to its tests; cargo fmt

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>
2026-10-10 13:08:18 -04:00
MarekSpodBiedry 8c2e16fb4a GPU canvas: re-upload a layer or mask texture when its default pixel changes (#2422) (#2446) 2026-10-10 13:08:14 -04:00
MarekSpodBiedry 0158fa82e1 Clip warp tiles at the inverse horizon so steep Distort commits keep every pixel (#2271) (#2500) 2026-10-10 13:08:10 -04:00
Max Murphy 8587c55c78 Free Transform: the Hand tool no longer steals a resize drag (#2302) (#2327)
* Free Transform: the Hand tool no longer steals a resize drag (#2302)

* Free Transform + Hand: only the selected Hand yields, and only in the primary canvas (#2302)

* cargo fmt
2026-10-10 13:08:06 -04:00
Max Murphy 3e000bc1d5 Canvas: UI Scale must not magnify the image (#2121) (#2333)
Interface > UI Scale is egui's zoom_factor, folded into pixels_per_point
with the display scale. The canvas divides View::zoom by the full
pixels_per_point (PhotocraftApp::point_zoom) and the GPU shader
multiplies it back, so 100% is one document pixel per physical display
pixel at every UI scale -- fixed by #1978 (Canvas: zoom is physical at
any display scale), which the report predates (v0.5.0). No behaviour
change; the report is stale.

Lock it down with a rendered-output regression test
(tests/ui_scale_canvas.rs): a black document is drawn through the real
app on the GPU and CPU canvas at UI Scale 100/125/150/200% and zoom
100/200%, and its on-screen size is measured in the pixels. It stays
60x60/120x120 physical px at every UI scale, and fails when the canvas
stops using point_zoom (75x75 at 125%) or when the GPU shader stops
multiplying by pixels_per_point (48x48 at 125%). The comments on
View::zoom, PhotocraftApp::ppp and canvas_view now name UI Scale too.
2026-10-10 12:22:49 -04:00
Brandon ThomasandClaude Opus 5.5 2b79c7c25d automation: test that root look-alike siblings and sub/../.. climbs are refused (#2216 follow-up) (#2528)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:53:35 -04:00
7722172585 Add OpenRaster (.ora) import and export (#2257)
Open and save .ora with pixel layers (any offset), nested groups, pass-through
groups, names, visibility, opacity, edit/alpha locks and blend modes (svg:* plus
Krita's krita:* names), at 8 and 16 bit. Save applies pixel masks, renders
text/shape/fill/smart layers to pixels and reports what OpenRaster can't hold.
Wired into File > Open/Save, the CLI, automation/MCP, the web picker and the
desktop file associations. Checked against 34 Krita 5.2.9-authored files
(opt-in krita_corpus test) and Krita re-opening our exports.

Co-authored-by: Scott Campbell <251750033+scottcampbelldata@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:40:35 -04:00
texjerandBrandon Thomas 05e0b1f8a5 kys import: first launch asks before importing; edit.keyboardShortcuts importKys (#1987 review) (#2251)
- The live set found beside Photoshop is offered, never applied unasked: an
  "Import keyboard shortcuts?" prompt names the set and how many keys it
  changes, with Import / Don't Import. Either answer is recorded in
  dialogs.photoshopShortcuts, so it is asked once; ui.inspect shows the
  pending offer as kysOffer.
- Parsing and label matching moved to the engine (photocraft_engine::kys), so
  edit.keyboardShortcuts {"importKys": <xml>} imports a set from agents, the
  CLI and MCP; the app's dialog uses the same plan with its menus' labels.
- "The keyboard shortcut set was not imported: {error}" is translated.
- .kys input over 4 MiB is refused before it is read as text.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 07:12:24 -04:00
4e8aaec643 Addition : Brush popup better UI (#2217)
* Brush name/tip/stroke cards

+ locale

* brush popup scale ui

scale brushes

* align fix + tests

* test fix

* ci fix

* ci fix 2

* don't scale text

* smaller width for tip+name

* scale + spacing + better UX

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: JJ <jj@dot.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:04:52 -04:00
AyanandBrandon Thomas d99562f038 Compute exact octagonal mask shifts with bounded workspace (#2154)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:46:06 -04:00
ae96037672 Expose native shape stroke controls with staged previews (#2218)
* Expose native shape stroke controls with staged previews

* Document shape stroke defaults in MCP control schema

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:42:52 -04:00
291f30da55 EXR: Cryptomatte layers list, decode and hash to the specification (#2180)
* EXR: Cryptomatte layers list, decode and hash to the specification

Cryptomatte (specification 1.2) carries per-pixel object and material
IDs with coverage in <layer>NN.red/.green channel pairs, announced by
cryptomatte/<key>/name|hash|conversion|manifest header attributes - the
ID channels Arnold, V-Ray, Redshift, Mantra/Karma and Cycles write next
to the beauty pass.

cryptomatte_layers lists every layer from the headers alone (name, key,
channels, the embedded manifest parsed and capped at 16 MiB, a sidecar
pointer); decode_cryptomatte returns the per-pixel (ID, coverage) pairs
above zero, sorted by descending coverage; cryptomatte_id hashes names
the reference way (MurmurHash3 x86-32, the exponent fixed away from
denormals/inf/NaN, read as f32 bits) - verified against the official
test vectors - and cryptomatte_key/cryptomatte_preview_color expose the
metadata key and preview colour. Files with deep parts are refused, like
the other flat part decoders.

* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)

* codecs: Cryptomatte decode checks the layer size without truncating casts

Count each pixel's pair list in the allocation check too.

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>
2026-10-10 06:40:49 -04:00
Sir StarchandBrandon Thomas a737281fad Distance fields on threads: bevel size changes 2-3x cheaper (#2119) (#2162)
* Distance fields: the Euclidean passes run on threads, results unchanged (#2119)

The chiselled bevel fields (#2006) and the precise glow fields are built
from an exact Euclidean distance transform, a few sweeps over the whole
map that all ran on one thread. They now run on scoped OS threads, in
groups of rows or columns handed out from a queue: edt_nearest (the
column pass, then the row pass), sharp_edges (rows), refine_edge_normals
(one edge pixel each) and the final per-pixel maps of the bevel and plain
Euclidean fields. Not rayon, as for the blurs (rows_par): maps are built
from inside rayon tiles, where a waiting worker steals other tiles (#276).
Maps under 65536 pixels stay on the calling thread, wasm always does.

Every unit is computed from its own inputs, in the same arithmetic as
before, so the fields are bit for bit the old ones whatever the thread
count: 216 fingerprints (bevel in and out, plain in and out, bevel_maps on
bars at 0 to 150 degrees, thin bars, discs, rounded rectangles, soft,
patchy and half-opaque masks, 40 stars, tiny and 1 pixel maps) are equal
at 1, 7, 16 and the default threads, and equal to the code before.
edt_nearest returns the nearest pixel as u32 (it was usize), which takes its
working memory from 20 to 16 bytes per pixel; a map past 2^32 pixels or a slice that does
not match its size gets "far" distances instead of a panic.

CPU compositor, 12 MP, bevel size change (ms, bevel_bench), main -> this:
ellipse 474 -> 217 (size 8), 990 -> 318 (250); text 747 -> 214 (8),
1110 -> 267 (250). A distance_field call on a 256x256 map goes from 2.2 to
1.8 ms, on 512x512 from 8.0 to 2.8, on 2048x2048 from 99 to 15.

Tests: par_units hands every unit to the closure once with its own
slices (short last unit, no second slice, units of different sizes, 1 to
64 threads); the nearest-pixel transform equals itself on 1, 2, 3 and 8
threads on awkward sizes and returns a seed at the distance it reports;
degenerate sizes; sharp edges and the four Euclidean fields are bit-equal
on 1, 2, 5 and 16 threads on discs, rounded rectangles, bars at several
angles, patchy opacity and an edge on the border. The nested-rayon
deadlock test (#276) now has a chiselled bevel layer in its document.

* GPU effect maps: the Euclidean fields are one window, not bands (#2119)

A bevel size change rebuilds the distance fields over the whole effect
region (the region grows with the size). The GPU path cut that into bands
of max(128, halo) rows, each computed from a window grown by the halo,
which for the bevel fields is reach + 10: at size 250, 262-row bands from
windows of about 786 rows. Every pixel went through the transform and the
edge refinement about three times, a 12 MP region gave about 12 tasks for
32 threads, and the two bevel fields ran one after the other.

The Euclidean fields (chiselled bevels, precise glows) now come from one
window, and distance_field spreads its own passes over the threads. A
rebuild is the whole region, which is also what the CPU compositor
computes, so the GPU canvas gets exactly the CPU's field: bands resolved
ties between equidistant edge pixels per window, and on a rebuild they
differed from the whole region by up to 0.07 px (ellipse) and 1.3 px (600
discs) at size 250, on a few dozen pixels in 12 MP. A partial update (a
brush dab) still computes the damage grown by the halo, from one window.
The chamfer fields (shadows, strokes, spread) are a sequential sweep and
keep the bands, and so does wasm, where there are no threads to spread
over and bands keep the memory small.

RTX 4080 SUPER, release, 12 MP, ms per size change on the GPU canvas
(bevel_bench from #2118, p50, median of 3 runs, before #2006 / main /
this), ellipse: 90 / 103 / 98 (8), 162 / 188 / 119 (50), 286 / 446 / 219
(250); text: 83 / 139 / 113, 140 / 215 / 130, 222 / 489 / 165. Alone in the
process (--no-cpu) ellipse at 250 is 407 -> 157 and text 468 -> 169.
36 MP ellipse at 250: 779 -> 382. Peak RSS: 12 MP at 250 1604 -> 862 MiB,
36 MP at 250 2987 -> 1726 MiB; at size 8 on 36 MP it is 163 MiB more
(1380 -> 1543). Precise glow fields on 12 MP: 46-84 ms -> 26 ms at reach
52-252 (32 ms at 12).

The window test now checks that the whole region is bit for bit the CPU's
field, and that a too small halo is noticed on a part of the region.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:38:33 -04:00
Harrison O'Connor-Hoover ec03c602b4 Apply Type formatting to selected text layers (#2266)
* Apply Type formatting to selected text layers

* Name the multi-layer formatting test case type
2026-10-10 06:09:54 -04:00
401abc48a9 Content-Aware tools: Photoshop's behaviour, measured (#2202)
* Content-Aware tools: Photoshop's behaviour, measured

Photoshop 25.4 was measured as a black box (synthetic and public-domain
images, results read back exactly) and the content-aware family follows it:

- Fill (inpaint): coarse to fine with the coarser level's matches scaled up
  and voted with the finer level's own pixels, the coarsest level filled
  from the hole's edge inwards, a shallower pyramid. The fill is pieces of
  the image copied pixel for pixel with narrow seams, as Photoshop's;
  before, it was visibly blurred. Pixels have roles (hole, context,
  source, void), so excluded areas stay context instead of being filled.
- Colour adaptation: Image Melding's per-patch gain and bias, limited per
  level, instead of a low-frequency membrane pull.
- Sampling window: Photoshop's square of side 4·√(max(w,50)·max(h,50))
  centred on the selection, slid inside the canvas; Edit › Fill,
  Rectangular, Auto, Delete and Fill and Spot Healing use it.
- Auto sampling area: what looks like the selection's surroundings (local
  colour and contrast, one min cut), fitted to Photoshop's overlay.
- Patch Tool: a Content-Aware mode (Structure, Color, Sample All Layers).
- Content-Aware Move: Structure 3..7 alike, a band about a patch wide;
  Color as a gain/bias limited to about 0.04 per step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Remove tool: keep its own sampling margin (fill_cmds::sampling_margin is gone)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:08:47 -04:00
Harrison O'Connor-Hoover e7df11d770 Load Thai fallback fonts for native UI text (#2280) 2026-10-10 06:06:39 -04:00
37985c0c61 Filters: preview at the scale OK applies (#2063) (#2207)
The filter dialog previewed large documents on a 1/k proxy and divided
pixel-sized params by k. Filters whose features have a fixed size in
pixels (Ripple, Diffuse, Oil Paint, Flame, Fibers, Mezzotint) came out
k times larger in the preview than in the result, and params divided
below their minimum were clamped (Pointillize 5 px cells -> 12 px).

filter_dialog::preview_factor picks the proxy factor per command: 1 for
the fixed-size filters, otherwise the largest factor up to k at which no
pixel-sized param falls below its range minimum.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:05:15 -04:00
Books 7198a94164 automation: accept absolute paths beneath the automation roots (#2176) (#2216)
doc_open, doc_save and the other automation file paths only took
forward-slash relative paths, so an agent passing C:/Users/me/a.png with
a C:/ root was refused. An absolute path is now compared with the
root's absolute path as text: if it lies beneath the root, the root's
components are stripped and the rest goes through the same checks as a
relative path and is opened through the same directory capability.
Nothing is resolved on the filesystem, so traversal, prefixes, device
names and link escapes are refused as before; absolute paths outside
the root, or with no root granted, are still refused.

Fixes #2176.
2026-10-10 05:53:23 -04:00
Sean LaveryandBrandon Thomas 478acf70cc Expand arithmetic expressions across numeric inputs (#2252)
* Expand arithmetic expressions across numeric inputs

* Test arithmetic and scientific notation in indexed color counts

* Drop the numeric_fields example (it wrote PNGs to /tmp; the widget tests cover the behaviour)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 05:50:58 -04:00
kubuntugamerandBrandon Thomas 4c06b9fe4d fix(engine): adjustments on a smart object record smart filters (FILE-215-4); strokes honour doc CMYK (#2229)
On a smart-object layer, Image > Adjustments now records an editable
smart filter instead of committing disaster.

  - new engine helpers:
    - adjust_cmds::has_adjustable / adjust_as_smart_filter route
      image.adjustments.<kind> onto the source, with the selection as the
      filter mask, through smart_cmds::add_smart_filter
    - brush/eraser/pencil/live preview strokes now render inside
      with_cmyk_space(doc_cmyk_space(session)), so painted colours follow the
      document profile the same way the compositor does
  - docs/scorecard.md: FILE-215-4 partial -> done
  - tests: adjust_cmds/tests + brush_cmds/tests, plus panic_hunt and
    the lib suite all green on the committed tree

Known follow-up, already filed:
  - FILE-215-5 rows: double-clicking an adjustment smart-filter row is still
    a no-op. smart_ui::open_editor only proceeds when
    filter_dialog::has_dialog returns true, and has_dialog accepts
    filter.*, a fixed non-adjustment set and PREVIEWED - but not
    image.adjustments.* (those open through adjust_dialog::open, which isn't
    wired into the smart-filter row).

This is one commit on top of the latest upstream/main (rebased to fix the
stale fork ancestry that was blocking PR creation).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 05:49:07 -04:00
Brandon ThomasandClaude Opus 5.5 29d5f791d5 Regenerate docs/scorecard.md (TOOL-213-19 from #2193) (#2404)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:20:57 -04:00
JohnnYoruandBrandon Thomas 897f9f37ad Add the Remove tool with non-local patch completion (#2193)
* Add the Remove tool with non-local patch completion

Tool::Remove (J flyout) paints a hole that paint.remove fills from its
surroundings as a background job, in one undo step. A stroke around an
object also removes what it encloses; the selection limits the hole.

The fill (photocraft_algo::nonlocal) follows Newson, Almansa, Gousseau and
Pérez (IPOL 2017): texture features in the patch distance, onion-peel
initialisation, match fields upsampled between levels and a best-patch
final copy, plus gradient-domain hiding of edge and interior seams.
Sparse holes are filled in tiles. inpaint::complete (Content-Aware Fill,
Spot Healing, Content-Aware Move) is unchanged.

On 20 holes in public-domain photos (remove_quality) the fill keeps 0.89
of the texture it replaces, against 0.54 with the Wexler/PatchMatch
completion. 24 MP (remove_bench): 100 px ring 120 ms, 1000 px ring 2.5 s.

* Translate the Remove tool's label and tighten comments

Adds "Remove Tool" to every UI catalog, following each one's pattern for
tool names and its existing word for Remove. Corrects comments that no
longer matched the code: the benchmark's description, the tile and margin
minimums, and the reasons given for two parameters.

* Put the Remove tool first in the J group

The J flyout's slot now shows Remove until another J tool is picked, and
J from another group selects it; repeated J goes on to Spot Healing Brush,
Healing Brush, Patch, Content-Aware Move and Red Eye.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 05:19:11 -04:00
414b7b9e4b fix(ui): complete system appearance selection and styling (#2168)
* feat(ui): follow system appearance with a System theme

Follow the OS appearance through the existing egui input while retaining System as the saved theme choice. Apply the resolved palette to both egui styles and custom surfaces, and keep document surround settings independent.

Cover launch and live transitions, persistence, manual themes, menu checks, the Interface picker and Home contrast. Validate engine/UI suites, adversarial commands, wasm, layering, parity and the AutoReview gate. Strict Clippy reports pre-existing findings documented in the PR.

Refs: storytold/photocraft#2074

* Keep the document surround on the displayed theme in Sync with system (drop the dark-surround pinning)

---------

Co-authored-by: coygeek <coygeek@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 05:14:41 -04:00
Harrison O'Connor-HooverandBrandon Thomas 607a8542f5 Keep duplicated Smart Objects linked to shared contents (#2222)
* Keep duplicated Smart Objects linked to shared contents

* Share Smart Object source update code

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 05:05:56 -04:00
Jason XiangandBrandon Thomas e05deac124 fix(engine): reject invalid core-filter parameters (#2228)
* fix(engine): reject invalid core-filter parameters

* test: cover filter validation through CLI and automation

* fix(ui): preserve filter preview minima during downsampling

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:57:56 -04:00
b203e7c295 Window edges: easier to grab, theme-safe cursors, and no lost hover after a resize (#1581) (#2205)
With the custom title bar the app draws its own resize zones. On Linux
they were hard to use:

- Edges asked for `ew-resize` / `ns-resize`, which some cursor themes
  (capitaine-cursors) don't ship: X11 fell back to the core font's big
  arrow and KDE Wayland showed the plain arrow, so the edge was hard to
  find. Edges now use the one-sided cursors (`w-resize`, ...), as GTK
  and KDE window borders do; corners keep the diagonal ones, which those
  themes draw in the same style.
- The edge zone grows from 5 to 8 pt (about a native resize border).
- KDE Wayland keeps the button release of an OS resize, so egui kept the
  edge pressed: hover stopped until the next click, and egui-winit never
  re-sent the resize cursor the compositor had replaced. The cursor came
  back only on every other press. After the hand-off the press is now
  ended in raw_input_hook, and the first motion afterwards shows the
  default cursor for one frame so the resize cursor is sent again.

Tested live on KDE Plasma (Wayland and XWayland).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:54:10 -04:00
Sir StarchandBrandon Thomas 7b3ded0f8b Layers menu and layer commands follow the selection, not the active layer (#2244) (#2254)
* Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243)

Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

* Layers menu and layer commands follow the selection, not the active layer (#2244)

Availability of Rasterize, Create/Release Clipping Mask, Add Layer Mask and Paste/Clear Layer
Style was decided by the active layer, so one selection was greyed or not depending on which of
its layers happened to be active (with the Background active, Create Clipping Mask and Add Layer
Mask were greyed although they would have run on the other layers), and Rasterize Layer was
enabled for layers it then refused ("nothing to rasterize"). Several of these commands also only
ever acted on the active layer, whichever layer the menu was opened on.

- Add Layer Mask (Reveal/Hide All, Reveal/Hide Selection), Paste Layer Style and Clear Layer Style
  run on every selected layer that can take them (not the Background, layers that already have a
  mask, layers without a style), in one history step. An explicit `layer`, or a single selected
  layer, behaves as before.
- `enable_rules::selection_targets`: availability of those commands, Create/Release Clipping Mask
  and Rasterize is judged by the selection.
- Rasterize Layer is enabled only for type, shape, fill and Smart Object layers, which is what the
  command converts.
- Items with no single target stay greyed for several layers: Blending Options, Copy Layer Style,
  Quick Export, Export As and the per-layer mask items. A multi-layer menu has one stable mask item.

Tests: engine integration (mask, paste and clear on a selection, errors, unchanged single-layer
paths) and a property test over 20 selections x clipboard state x 7 commands: an enabled item runs,
and for several layers a greyed one would have changed nothing. It fails on the old rules for
["R1", "BG"] with the Background active, the case from the bug report.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:18:42 -04:00
dd2ce3da33 GPU startup: CPU mode on Linux falls back to Vulkan to present the window (#1337) (#2206)
CPU rendering mode limited the window's wgpu instance to GL on Linux.
With NVIDIA's EGL the GL adapter can't present to the window (wgpu:
"EGL says it can present to the window but not natively") and llvmpipe
isn't offered, so the adapter selector found nothing and every start
exited with "no graphics adapter found", on X11 and Wayland alike. The
user couldn't reach Preferences to undo it.

CPU mode now creates the instance with GL and Vulkan. GL adapters still
rank first (llvmpipe, then hardware GL), so nothing changes where GL can
present; Vulkan is used only to draw the window when no GL adapter can.
Image compositing stays on the CPU.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:12:17 -04:00
UnemarsandClaude Opus 5.5 2507f07ec2 Format: one rustfmt change main needs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:11:15 +02:00
Unemars 7a5795bfcb Merge remote-tracking branch 'origin/main' into content-aware-workspace
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-10 10:06:08 +02:00
UnemarsandClaude Opus 5.5 4d7db6dabe Content-Aware Fill workspace, as Photoshop's
Edit › Content-Aware Fill… opens Photoshop's workspace (measured on 25.4)
instead of the generic dialog: a full-window dialog like Liquify's.

- Tools: Sampling Brush (B), Lasso (L), Hand (H), Zoom (Z), each with its
  options bar.
- Document view: the sampling area tinted green at 50 % and the fill area's
  marching ants.
- Preview panel: the fill computed in the background after each change,
  opening framed on the sampling window.
- Content-Aware Fill panel: Sampling Area Overlay (Show, Opacity, Color,
  Indicates), Sampling Area Options (Auto, Rectangular, Custom, Sample All
  Layers), Fill Settings, Output Settings, and the reset buttons.
- OK, Apply, Cancel.

As Photoshop's:
- The Sampling Brush subtracts in Auto and Rectangular and adds in Custom,
  which starts empty; Alt reverses. Its first size is a tenth of the
  window's side. Painting keeps the option, and added strokes may lie past
  the window.
- The lasso edits the fill area (New, Add, Subtract, Intersect, Expand,
  Contract); the window and the Auto area follow.
- Apply fills and stays open; Cancel keeps what was applied. Ctrl+Z undoes
  a stroke or an edit. Output opens on New Layer; settings are remembered.

The workspace holds plain data. `edit.contentAwareFill` takes it as
`samplingStrokes`, `fillEdits`, `sampleAllLayers` and `layer`, and both
the preview and OK run one plan (`content_aware_plan`), so the preview is
what gets written. New strings are translated in every catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:05:40 +02:00
TropicalandBrandon Thomas d4ec2db0a7 feat(ui): allow toolbar to scroll (#2238)
* feat: allow toolbar to scroll

* fix: assign the toolbar ScrollArea "toolbar-columns"

* chore: remove unused slots variable

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:04:26 -04:00
8d23c62272 Pen pressure curve: Preferences › Tools shapes how pen pressure reaches every brush (#2195)
* Pen pressure curve: Preferences › Tools shapes how pen pressure reaches every brush

Brush phase 6a (TOOL-213-11, #610). Every pen paints its pressure straight through, so artists
with a heavy or a light hand, or a tablet whose response feels off, had no way to adjust it.

- `tools.pressureCurve`: [input, output] points, linear by default, settable with `prefs.set`
  (validated: 2..=16 pairs within 0..1).
- `prefs::PressureCurve` reads it as a monotone cubic (Fritsch–Carlson), so a firmer press never
  gives less pressure. It sanitises the points itself (finite, clamped, sorted, one per input,
  capped, outputs made non-decreasing; flat beyond the ends), because a whole-section update or a
  hand-edited file can store anything.
- The stylus maps every pen sample's pressure through it before any brush sees it, so the live
  stroke, the commit and the journal agree. A mouse stays at full pressure.
- Preferences › Tools draws a curve editor under Use Tablet Pressure: drag a point, click to add
  one, right-click to remove one, with Linear, Soft and Hard presets. Translated in all 16 catalogs.

* Regenerate docs/scorecard.md

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>
2026-10-10 04:00:10 -04:00
265f10b91d Fix SVG selection and import detection (#2224)
* Fix SVG import detection and configurable group depth

* Fix malformed SVG error routing and formatting

* Refresh scorecard for SVG depth preference

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:58:08 -04:00
Sir StarchandBrandon Thomas 1b6150e3fc Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243) (#2253)
Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 03:55:43 -04:00
TropicalandBrandon Thomas 071fe4590b fix(ui): mode buttons respect two-column toolbar (#2241)
* fix: mode buttons respect two-column toolbar

* fix: draw menu indicator on window mode button

* fix: syntax mistake in `paint_toolbar_menu_indicator`

* fix: pass tokens to paint_toolbar_menu_indicator

* refactor: rename paint_toolbar_menu_indicator to paint_toolbar_flyout_indicator

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 03:49:22 -04:00
Brandon ThomasandClaude Opus 5.5 fc42f81f42 Web: leave the JPEG XL DNG decoder out of the wasm (25.1 MB -> 24.3 MB) (#2233)
* Web: leave the JPEG XL DNG decoder out of the wasm (0.88 MB, 23.1 MiB now)

The 0.6.0 web build went over packaging/web/package.sh's 24 MiB gate (26,980,503 bytes on
782bcb67). Today's landings added 1.34 MB; the biggest single item was jxl-oxide (#2047, JPEG
XL-compressed DNG): ~0.6 MB of jxl_* code plus brotli-decompressor and tables, 0.88 MB after
wasm-opt. 0c72d954 brought the wasm back to 25,144,413 bytes (21 KB under the gate) by deflating
catalogs and fonts; this takes it to 24,262,088 bytes (23.14 MiB, 904 KB of headroom).

jxl-oxide is now a native-only dependency of photocraft-raw. On wasm32 a JPEG XL DNG segment
returns RawError::Unsupported ("can't be opened in the browser version of PhotoCraft; open it
in the desktop app"), like the other cfg-gated web features; every other DNG compression and
raw format still opens in the browser. Native builds are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* xtask i18n-coverage: read the deflated catalog! registry entries

0c72d954 changed the LANGUAGES registry from include_str!("xx.tsv") to catalog!("xx"), so
`cargo xtask i18n-coverage` failed with "language `ja` has no include_str! catalog". Parse the
new form (the macro argument is the catalog's file stem).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:40:35 -04:00
Nigel Ihza FirdausyandBrandon Thomas 91a2b58725 fix(ui): remove the gap beneath Spectrum document tabs (#2265)
* fix(ui): remove Spectrum document tab-to-canvas spacing (#2188)

* scrollbars test: compare the centred view within float error

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 03:40:16 -04:00
6a39da98c8 Move tool on a locked layer: Photoshop's message (#2249)
* Move tool on a locked layer: Photoshop's message

Dragging with the Move tool (or Cmd with another tool) on a layer it can't
move showed nothing: the Background or a position-locked layer with no
selection, or a locked layer whose selected pixels can't float. Photoshop
puts up "Could not use the move tool because the layer is locked." when
the drag starts (a plain click says nothing); move_lock.rs does the same
with the Error dialog, translated in every catalogue. The float lock rule
moves to float_cmds::locked_for_float so the UI can ask before the press
goes anywhere.

Also in Dutch and Ukrainian. The Cmd-with-a-painting-tool case lands with
the Cmd-as-Move change, whichever merges second.

* Fix merge: close the lock-message test

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:39:40 -04:00
Sir Starch 1f84f19caf Hand tool: an open hand and a fist as the pointer on Windows, and the fist while panning everywhere (#2196) (#2221)
* Hand tool: an open hand and a fist as the pointer on Windows, and the fist while panning everywhere (#2196)

Windows: winit maps CursorIcon::Grab and Grabbing to the four-arrow move cursor, so the Hand
tool never showed a hand. tool_cursor::hand now hands the OS an outlined bitmap hand (open) and
fist (dragging), drawn from signed distance fields like the brush tips, through the same
set_cursor_image path. macOS and the Linux themes keep their own grab cursors.

All platforms: the tool-cursor block of canvas_view is inside `else if primary`, which the Hand's
own drag branch skips, so Grabbing was dead code and the pointer was a plain arrow while panning
(Hand tool, Space and the middle button). The drag branch now sets the fist.

Also used by the Hand pointer under a dialog and the Camera Raw preview's pan.

* tool_cursor tests: the Hand's pointer on Windows is now a bitmap (#2196)

brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes pinned the old
behaviour (no bitmap, CursorIcon::Grab once the Hand is selected), which is the four-arrow move
cursor on Windows. It now expects the open hand's bitmap.
2026-10-10 03:15:00 -04:00
texjerandBrandon Thomas b37bff987c Cmd held is the Move tool with the painting tools (Cmd+Alt duplicates) (#2248)
Photoshop switches to the Move tool while Cmd (Ctrl off the Mac) is held
with every tool but the Hand, Rotate View, Zoom, Crop, Slice, Path Selection,
shape, Pen and Type tools. #896 did this for the selection tools per press; this
does it for the rest (Brush, Eraser, Gradient, Clone Stamp, Eyedropper, ...).

hold_keys gains Temporary::Move (a modifier, not a rebindable key); the
canvas sets PhotocraftApp::tool_override for the frame and the tool state
machine, the Move tool's selection check (move_ui::moves_selected_pixels)
and the tool modules that gate on the tool read active_tool(). The held-tool
latch now lasts through the release frame, otherwise the Move's Up went to
the selected tool. So Cmd-drag with the Brush moves the layer, Cmd+Alt-drag
duplicates it first (move_mods), Cmd-drag with a selection floats the
selected pixels, and Cmd-click picks the layer under the pointer.

Off the Mac, Cmd+Alt is Ctrl+Alt, which the brush tools keep for the
on-canvas resize (brush_resize, also Alt+right-drag there): with a brush
tool Ctrl+Alt-drag resizes, with the other tools (Gradient, Eyedropper...) it
duplicates. Tests pin both layouts on any host. docs/control-protocol.md
describes `command` on ui.pointer events.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 02:44:27 -04:00
Harrison O'Connor-Hoover 935c94b5d3 Add pixel mask density and feather controls to Properties (#2240) 2026-10-10 02:43:34 -04:00
Krzysztof CieślikandBrandon Thomas a0c18f5f75 Tools: double-clicking a layer mask launches Select and Mask with preference (#204) (#2161)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 02:35:51 -04:00
texjer 95006995f6 Selection cursors: Photoshop's badges for cutting, copying and dragging the selected pixels (#2250)
Over a selection the cursor now says what a press does, as in Photoshop:
the move cursor where a marquee press moves the outline (#1428, unchanged),
the arrow with scissors where Cmd (or the Move tool) cuts and moves the
selected pixels, the arrow with a second arrow where Alt lifts a copy, a
hollow arrowhead while they are dragged, and a plain arrow over the floating
piece. canvas::selection_cursor decides, drawn as icon badges beside the OS
arrow (icons::cursor_badge), white with a dark outline.
2026-10-10 02:24:22 -04:00