Commit Graph
3466 Commits
Author SHA1 Message Date
BFlat 8e8e9f2c68 Merge pull request #1778 from storytold/community/1607-affinity
AF.3: native Affinity import (layers, artboards, vectors, text, images), carrying #1607 by @callmeBalloch
2026-10-09 18:00:53 +07:00
Elvis33LE 6cd2d61398 CR2: read ColorData's as-shot white balance as words (#1640)
* CR2: read ColorData's as-shot white balance as words

CANON_COLOR_DATA is an UNDEFINED entry: uints() hands its bytes back one
by one, but the WB_RGGBLevelsAsShot offsets count 16-bit words. Reading
word i at entry offset i*2 (file byte order, bounds-checked) finds the
quadruple at 0x47 in the corpus PowerShot SX50 HS: [1613, 951, 951, 1605]
-> [1.70, 1.0, 1.69], matching the white balance its own DNG conversion
neutralises (AsShotNeutral -> [1.58, 0.955, 1.666] through the color
matrix). With this the file's balance is used instead of the grey-world
estimate; the regression test pins the words and the missing estimate
warning. (The remaining brightness gap between the CR2 and the DNG of
the same camera is the CR2 path's exposure normalisation, to be looked
at next; the estimate was never what brightened the file.)

* cr2: drop the unused glob import in the corpus test

* CR2 WB: synthetic word-offset oracle, entry bounds, corpus test behind the feature

testgen writes ColorData as Canon does, UNDEFINED bytes that are 16-bit words
with the PowerShot's WB_RGGBLevelsAsShot at word offset 0x47, and the new
color_data_wb_as_words test checks the decoded balance in every CI run (the
corpus oracle stays behind the corpus feature and now panics when its file is
missing instead of silently skipping). word() reads stay inside the entry's
own byte count, and the shared inspect example moves back to the corpus PR.
2026-10-09 18:00:50 +07:00
McClabe c58d20bd39 Fix small caps rendering across fonts (#1644)
* fix: synthesize small caps across fonts

* fix: use native small caps when available
2026-10-09 18:00:47 +07:00
llduyll10andClaude Opus 5.5 efca9b270d Layered TIFF: keep blending ranges as bytes in Intel-order files (#1712)
Photoshop writes the per-channel blend-if ranges byte for byte in either byte
order (an II TIFF holds the defaults as 00 00 ff ff). The transcoder swapped
them as u32s, turning every range into ff ff 00 00, which hides every pixel of
the layer: a Photoshop layered TIFF opened with an empty canvas.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:00:43 +07:00
ericandhideoneric 60018dea69 fix(compose): don't clip Add and Divide at 1 in 32-bit documents (#1722)
- Linear Dodge (Add) and Divide keep values above 1 in 32-bit float documents, clipped only at f32::MAX. Integer depths still clip at 1.
- CPU: add psblend::HDR and generic::blend_rgb_hdr / blend_channel_hdr; render sets the flag per document depth.
- GPU: add the F_HDR pass flag, and compose.wgsl uses it for the same clip bound.
- Non-separable modes are unchanged.
- Tests: colour unit tests, compose depth tests, and the GPU parity test float_documents_blend_past_white.

Co-authored-by: hideoneric <hideoneric@users.noreply.github.com>
2026-10-09 18:00:39 +07:00
MarekSpodBiedry 68c2e21da8 docs: README tool count and list match Tool::ALL (#996) (#1710) 2026-10-09 18:00:32 +07:00
MarekSpodBiedry 088dbba090 fix(engine): prefs.set and prefs.reset apply the text blending gamma (#987) (#1709) 2026-10-09 18:00:29 +07:00
MarekSpodBiedry 66ab5012be fix(compose): a Precise inner glow from the centre is no longer blank (#966) (#1708) 2026-10-09 18:00:26 +07:00
MarekSpodBiedry 57be0f58d8 fix(ui): Keyboard Shortcuts dialog rejects a malformed overrides map instead of wiping shortcuts (#956) (#1707) 2026-10-09 18:00:22 +07:00
MarekSpodBiedry 2feaf055cc fix(xtask): the scorecard's performance summary no longer counts not-run scenarios as tracked (#947) (#1705) 2026-10-09 18:00:19 +07:00
MarekSpodBiedry 1fd600d3ad fix(automation): bridge doc_save without path writes back like headless (#946) (#1704) 2026-10-09 18:00:16 +07:00
MarekSpodBiedry 4f33c81524 fix(io): keep a PSD Anno block that cannot be read instead of deleting it on save (#944) (#1703) 2026-10-09 18:00:12 +07:00
MarekSpodBiedry 8a31f2431b fix(io): a CMYK Channel Mixer using the black source stays raw in CMYK documents (#940) (#1702) 2026-10-09 18:00:09 +07:00
MarekSpodBiedry 1db0dde9a4 fix(engine): Move tool Auto-Select clicks through fully locked layers (#1641) (#1701) 2026-10-09 18:00:05 +07:00
MarekSpodBiedry a4c4a02364 fix(ui): the Navigator keeps its image during a live paint stroke (#1631) (#1699) 2026-10-09 17:59:58 +07:00
MarekSpodBiedry 549baa778f fix(ui): the Photoshop-style status bar shows messages with no document open (#1567) (#1698) 2026-10-09 17:59:55 +07:00
MarekSpodBiedry 5cfbf89917 fix(engine): layer bounds drop the area a selection transform moved pixels out of (#1097) (#1697) 2026-10-09 17:59:51 +07:00
MarekSpodBiedry 1433c3c520 fix(engine): Layer via Copy and Cut deselect, as Photoshop does (#1096) (#1696) 2026-10-09 17:59:48 +07:00
MarekSpodBiedry 3e3e6278d7 fix(control): ui.inspect reports the documented view preferences (#1003) (#1695) 2026-10-09 17:59:45 +07:00
MarekSpodBiedry 864021994c fix(scorecard): mark the #204 preference rows done; their readers, allowlist and test exist (#1002) (#1694) 2026-10-09 17:59:41 +07:00
Claude Opus 5.5 acc27e0fd0 Merge remote-tracking branch 'origin/main' into fix/1112-lab-mix-restore
# Conflicts:
#	crates/compose/src/lib.rs
#	crates/compose/src/tests.rs
2026-10-09 06:57:07 -04:00
Gert Pellin 65f30dd9e3 feat(eyedropper): Sample Size, Sample layers and Show Sampling Ring (#1649)
The Eyedropper options bar now has Photoshop's Sample Size (Point Sample,
3 by 3 ... 101 by 101 Average), Sample (Current Layer, Current & Below,
All Layers, All Layers No Adjustments, Current & Below No Adjustments)
and Show Sampling Ring.

- engine: new `document.sampleColor {x, y, size, sampleLayer}`
  (sample_cmds.rs). Averages the square centred on the pixel, clipped to
  the canvas, alpha-weighted, in the document's colour model (Gray, Lab;
  CMYK documents use their composite RGB, since the profile round trip
  is not exact) and rounded to the document's bit depth.
- ui: eyedropper_ui.rs options bar; state.rs ToolOptions
  eyedropper_size / eyedropper_sample / eyedropper_ring (serde). The
  size is shared with the painting tools' Alt-click eyedropper, the
  Curves / Color Picker eyedroppers and the Info panel readout.
- control: ui.set eyedropperSampleSize / eyedropperSample /
  eyedropperRing; documented in control-protocol.md and the MCP schema.
- i18n: new strings in all 14 catalogs (plus the missing Russian hint
  the tl! scan had not reached).
- scorecard: TOOL-1649-1.
2026-10-09 12:28:14 +02:00
Claude 20275685b1 io/affinity: as_chunks for the mask's grey extraction (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:19:17 +00:00
Claude 30aa0f974f affinity: as_chunks for the constant-size chunk loops (clippy 1.99 chunks_exact_to_as_chunks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:18:57 +00:00
MarekSpodBiedry 6ba1419286 Layers panel fx drag test: measure the drop target before the mutable drag borrow (#1719) 2026-10-09 12:18:32 +02:00
MarekSpodBiedry b692ed451c Layers panel: drag layer effects onto another layer to move them, Alt-drag copies (#1719)
Effect rows only took clicks and the fx badge was part of the layer row, so
dragging an effect or the fx badge picked up nothing (or the whole layer).

As in Photoshop, dragging the fx badge or the "Effects" row onto another
layer moves all its effects there (replacing that layer's style, like Paste
Layer Style), dragging one effect row moves just that effect (replacing an
effect of the same kind), and holding Alt on release copies instead. The
badge takes the drag the way the eye does, so clicks still select the row.
The drop is one history step through a new engine command,
layer.layerStyle.transferEffects (no menu entry, like Show All Effects).

Tests: the engine command (copy, move one, move all, undo, bad params) and a
Layers panel test that Alt-drags an effect row and drags the fx badge.
2026-10-09 11:54:19 +02:00
MarekSpodBiedry fee28f3a1b xtask corpus: pass the pinned corpus without an explicit deref (clippy) (#948) 2026-10-09 11:34:11 +02:00
Claude 2515fa7cee Linux display server: compile read_display_server on Linux (and tests) only
macOS and Windows clippy rejected it as dead code (-D warnings): its only caller in main.rs is
#[cfg(target_os = "linux")]. Also puts read_rendering_prefs' doc line back above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:12 +07:00
Claude 770f6d49b8 scorecard: regenerate for the display-server preference (141 settings)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:12 +07:00
Gert Pellin a132e37ee1 Linux: display server preference (X11 / XWayland) so file drops work
On a Wayland session dropping a file on the window does nothing: winit
0.30 (eframe 0.36) has no Wayland drag and drop (#386). The only way
out was launching with `WAYLAND_DISPLAY= photocraft`.

Add Preferences › Performance › Linux display server (auto | x11,
applies at next launch), direction 2 in #386. With x11, main.rs asks
winit for its X11 backend through eframe's event_loop_builder hook
(EventLoopBuilderExtX11::with_x11), so the window runs through XWayland
and drops work. No environment is modified (no unsafe). It only applies
when an X server is reachable (DISPLAY set); otherwise winit's default
is kept, so a missing XWayland can't stop the app from starting. Other
platforms ignore it.

The Wayland drop notice now points at the preference instead of the
environment variable (all catalogs updated).

Tests: the startup reader (x11, auto, unknown value, wrong type, broken
JSON, missing file), the notice text, prefs_usage, label translations.
Verified on KDE Plasma 6 Wayland: with x11 the window is an X11 window
(XWayland) and dropping a NEF from Dolphin opens it.
2026-10-09 16:06:12 +07:00
Claude 165226b390 fmt: import order in paint_mouse.rs (#1629)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:07 +07:00
Adam Gincel 35c323c9de feat(ui): keep a brush per painting tool, as in Photoshop (#218)
The brush was one global session setting (only its smoothing was per tool), so
switching tools carried the size, hardness, mode, opacity, flow and dynamics
over. sync_tool_smoothing becomes sync_tool_brush: it saves the session brush
for the outgoing tool and loads the new tool's on every switch. UiState's
smoothing_tool/tool_smoothing become brush_tool/tool_brushes (serde default, so
saved UI state still loads), and the session's own fields (colours, erase flag,
stroke seed) are never carried across a swap.

Called from the frame start, the options bar before the brush chip, both canvas
stroke paths, ui.set {tool} (so tool + brushSize in one call sets the new tool's
size) and select_tool_preset. A tool seen for the first time inherits the brush
in hand at Photoshop's 10 % smoothing.

Tests: each_tool_keeps_its_own_brush, the renamed per-tool smoothing test,
per-tool coverage in the ui.set brushSize test, and pencil_tests switching tools
before setting the smoothing as the options bar does.
2026-10-09 16:06:07 +07:00
McClabe 9b7349d0e5 Keep legacy PSD text origins visible when editing (#1560)
* fix: keep legacy PSD text origins visible when editing

* fix: normalize legacy TySh origins on import
2026-10-09 16:06:03 +07:00
Brennan StehlingandClaude Opus 5.5 22ba13b47b macOS: quit with terminate:, not by closing the window (#1575, #1458) (#1624)
Since the native menu bar (#894), Quit runs file.exit, which closes the window through eframe
while AppKit's run loop is still going. AppKit's Touch Bar finder then removes a KVO observer from
the already-deallocated view in the next display cycle (`-[_NSTouchBarFinderObservation
invalidate]`): an uncaught exception that aborts on macOS 27 (#1458) and leaves a windowless app
on macOS 26 (#1575). The window's close button took the same path before #894; ⌘Q used winit's
default menu, which calls terminate:.

Every way of leaving (Quit, ⌘Q, the close button, the OS quit event, app.quit) arrives at
guard_window_close as a close request. Once leaving is settled (nothing unsaved, or the prompt
answered) it now runs Services::quit when the platform has one, keeping the window open for it.
On macOS that is -[NSApplication terminate:], scheduled on the main queue so winit is out of its
event handler and eframe's exiting hook still saves the window layout.

Reproduced in a macOS 26.6.2 VM with XCUITest: on main, Quit, Quit with a document, ⌘Q and the
close button abort with the #1458 stack; with this change all of them exit, write no crash report
and save ui.ron.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:05:58 +07:00
UnemarsandClaude Opus 5.5 a273d3b27d Layers panel: ⌥-click on the line between layers, measured against Photoshop (#1642)
#1066 added the gesture; this makes it behave as Photoshop 25.4 does (measured by sweeping
⌥-hover and ⌥-click a pixel at a time across each line, results read back by script):

- the line takes the pointer from 3 px above it to 5 px below it (was ±3);
- a release frees the layer above the line together with the clipped layers stacked above it,
  in one history step, and makes it the active layer (was: that layer alone); a clip keeps the
  active layer, as before;
- clipping and releasing have their own cursors: PhotoCraft's original clip-below (a hooked
  arrow onto the layer below) and clip-release (the same arrow with a small "no" badge),
  replacing Lucide corner-right-down for both.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:05:52 +07:00
StreetblockandGPT-6 576811263e Fix combined-mask cache invalidation after pixel edits (#1645)
Co-authored-by: GPT-6 (Codex) <noreply@openai.com>
2026-10-09 16:05:48 +07:00
MarekSpodBiedry a5d92c231c fix(xtask): corpus --all --update-manifest refetches and rewrites every manifest (#948) 2026-10-09 10:42:10 +02:00
callmeBallochandClaude Opus 5.5 d10d782bc4 AF.3: say which sample set the semantics were fitted on
The previous commit put 189 in both places, but the thumbnail fitting
was done on 176 documents; the set grew to 189 afterwards and the
reader opens all of them. State both, so neither number is overclaimed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 07:44:22 +00:00
callmeBallochandClaude Opus 5.5 6deb1e0934 AF.3: attribute the Affinity corpus, one sample count, run it on --changed
From the review on storytold/photocraft#1607:
- ATTRIBUTION.md gets a row for corpus/affinity: the four upstreams at
  their pinned commits, with authors and licences (CC0-1.0, and MIT by
  Nick Beeuwsaert, Jeremy Cantu and DAIKI).
- The README said 176 sample documents in the provenance and 189 in the
  limits. It now says 189 distinct public documents in both places; the
  reader opens all of them (298 files with duplicates, no failures).
- crates/affinity/ joins CRITICAL, so test-corpus --changed runs the
  corpus when the reader changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 07:41:49 +00:00
callmeBallochandClaude Opus 5.5 2b279ba61b Merge storytold/main into AF.3 native Affinity import
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:38:46 +00:00
callmeBallochandClaude Opus 5.5 a8d7c6ef3f AF.3: put the Affinity fuzz targets and corpus back in CI
The maintainers approved the new crate, its fuzz targets and the corpus
upstreams in principle (storytold/photocraft#1607), so this undoes the
interim opt-in: ci.yml fuzzes preview and import_preview again, corpus
--all fetches the Affinity set, and its test runs under the normal
corpus feature. The provenance section stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:38:40 +00:00
shmuelbiller1 ec350d64ae fix(engine): finish background job bookkeeping on its document (#1572)
* fix(engine): finish background job bookkeeping on its document

* test(engine): cover Fade state after switching tabs during background job

* style(test): format background job regression assertions

* style(engine): follow rustfmt for background-job regression test
2026-10-09 13:24:33 +07:00
shmuelbiller1 83d5c5436d fix(autosave): report background write failures and retry unchanged revisions (#1577)
* fix(format): expose asynchronous autosave completion and enqueue errors

* feat(ui): accept nonblocking background autosave outcomes

* fix(desktop): pass worker autosave results to shell

* fix(ui): confirm autosaves only after successful writes and retry failures

* test(desktop): retry failed background autosave with unchanged revision

* test(format): report async autosave write failure and successful retry

* style(ui): compact autosave iterator chains under project rustfmt width

* style(format): apply rustfmt for autosave completion iterator

* fix(format): simplify async autosave completion types for Clippy

* test(format): allow slow Windows autosave worker time to finish

* test(desktop): use bounded wall-clock deadlines for background autosave checks

* style(format): match rustfmt for autosave worker signature
2026-10-09 13:24:30 +07:00
ericandhideoneric 829aca9483 Snap the Marquee drag outline to whole pixels (#1587)
- Rectangular and Elliptical Marquee previews use the same pixel rect the release commits, so the ants and size readout match the final selection.
- Extract marquee_preview_px in canvas.rs and use it in draw_drag_preview.
- Add a regression test: a zoomed-in drag between pixel edges previews whole pixels and commits that same rect.

Co-authored-by: hideoneric <hideoneric@users.noreply.github.com>
2026-10-09 13:24:27 +07:00
Dan HiornsandClaude Opus 5.5 5aaefe378e Layers panel: pop-up sliders for Opacity and Fill (#1592)
Photoshop's Layers panel Opacity and Fill fields have a ▾ that opens a
slider. Add widgets::popup_value_field, a value field with that ▾:

- a click opens the slider below the field; a click outside closes it
- pressing the ▾ and dragging moves the value at once and closes the
  slider on release
- the number can still be typed, scrubbed and stepped with the arrows

A drag (on the slider, from the ▾, or scrubbing the number) sends
layer.setProps live with a per-drag coalesce key, so it is one history
step. The ▾ reuses the translated Opacity/Fill labels (no new strings)
and is disabled on the Background layer, like the fields.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:24:24 +07:00
Books 0bb46df638 fix(raw): a DNG with only DefaultCropSize is cropped from the active area's origin (#950) (#1595)
The DNG default crop was applied only when both DefaultCropOrigin and
DefaultCropSize were present, so a file carrying just the size was
developed at the whole active area. Each tag now takes its own default
from DNG 1.7.1.0: the origin (0, 0), the size the whole image. The
existing bounds check is unchanged, so an origin-only crop that no
longer fits still falls back to the active area with a warning, and a
tag with the wrong number of values is still reported as incomplete.

Fixes #950.
2026-10-09 13:24:21 +07:00
McClabe 7c15011410 fix: open Image command dialogs before applying (#1596) 2026-10-09 13:24:17 +07:00
kubuntugamer 967dcfd324 fix(io): a layered TIFF export warns that retained EXIF will be dropped (#1545) (#1597)
A TIFF export with --tiff-layers reported "EXIF not supported; it will be
dropped" even though the file kept the tags. export_layered puts EXIF into
the Photoshop image resources as 1058, which the TIFF writer emits as tag
34377 and the importer reads back, so reopening the file restored the
original tags. ExportResult.warnings is documented as notes about what an
export will lose, so the message was an incorrect loss report for an export
that loses nothing.

The TIFF codec has no EXIF directory, so the second copy handed to it in
meta.exif was never written; it only existed to make the codec warn. That
copy is gone, and with it the false warning. The flat-TIFF path still hands
the codec the tags, still drops them, and still says so.

The test is the issue's own claim checked in both directions: a layered
export reports no EXIF loss AND the Artist tag survives the round trip, so
the missing warning reflects a real absence of loss rather than a silenced
one; a flat export is the control and keeps warning. It fails on the exact
reported message before this change.
2026-10-09 13:24:14 +07:00
Claude f55bc64d50 fmt: rustfmt the #1547 save path and test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:24:07 +07:00
kubuntugamer 950eb1dd28 fix(automation): a pathless PSD or PSB save leaves the document dirty (#1547)
Headless::save recorded the saved revision only for .pcraft, so saving a
document back to its own PSD or PSB wrote the file correctly and still
left session.list reporting dirty: true. Automation clients that use the
flag could not tell a saved document from unsaved work.

The test is now "was this a layered document save" (PSD, PSB or .pcraft)
rather than "was this .pcraft". A flat export (PNG, JPEG, ...) stays a
copy: it leaves dirty and the document's path alone. The desktop app.save
path already recorded the revision for every format, so this only affects
the headless JSON-lines server and the MCP stdio server, which both route
through Headless::save.

Tests cover the reported sequence for both .psd and .psb -- create, reopen
the saved file, edit, then doc.save with no path -- plus a control that a
flat export is a copy. docs/control-protocol.md now states the outcome of
doc.save for the dirty flag and the document path.

Co-authored-by: Space Bunny Free <chasbigham@gmail.com>
2026-10-09 13:24:07 +07:00