Commit Graph

1089 Commits

Author SHA1 Message Date
Brandon Thomas 0c72d95425 Web: store translation catalogs and bundled fonts deflated, unpacked on first use (wasm back under the 24 MiB gate)
The 0.6.0 web build was 26.9 MB, over packaging/web/package.sh's 24 MiB gate (Cloudflare's per-file
cap is 25 MiB, #198). The i18n .tsv catalogs (1.85 MB -> 0.64 MB) and the Inter/JetBrains Mono fonts
(1.52 MB -> 0.73 MB) are now deflated by build.rs and inflated on first use, so wasm is 25,141,080
bytes. No visible change. A catalog that fails to inflate falls back to English, a font that fails
falls back to egui's default font; neither panics. Headroom is only ~25 KB: the next size cut is
needed soon (fetching catalogs on demand would save another ~0.64 MB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:11:13 -04:00
Shayan Horbank e398cc80b8
Use shared Color Picker for Layer Style and text styles (#2015) (#2049)
* Use shared Color Picker for Layer Style colors (#2015)

* Use shared Color Picker for text style colors (#2015)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:03:51 -04:00
texjer a81eaa4187
Keyboard Shortcuts: import Photoshop .kys sets; first launch takes Photoshop's live set (#1163) (#1987)
* Edit › Keyboard Shortcuts: Import Photoshop Shortcuts… loads a .kys set (#1163)

Photoshop saves a shortcut set as .kys XML: <command name="Gaussian Blur...">
rows with one or more <shortcut>Control+Opt+Cmd+G</shortcut>, then the tool
keys. The dialog's new button (and File › Open or a drop of a .kys) parses it
with roxmltree, matches each row to our commands by menu label, and fills the
dialog's overrides, so OK applies the set the way hand-made changes are
(a key moved to a command is taken from its old owner) and Cancel discards it.

Rules, from Photoshop's own ambiguities: a label in several menus (Levels…
under Image › Adjustments and Layer › New Adjustment Layer; All under
Select, Edit › Purge and View › Show) goes to the command whose default is one
of the row's keys, else the one with a default, else the first in menu order.
A row's first key is kept unless one of its keys is already the command's
default (Zoom In is Cmd++ then Cmd+=). Scripts, Bridge, the frames commands
and the macOS application menu have no command here and are counted in the
message. Tool keys are not imported: the tool letters are fixed.

* First launch beside Photoshop imports its live shortcut set; the Open dialog lists .kys

Photoshop keeps the set in use as Keyboard Shortcuts.psp in its per-version
settings folder (macOS ~/Library/Preferences/Adobe Photoshop <v> Settings,
Windows %APPDATA%\Adobe\Adobe Photoshop <v>\Adobe Photoshop <v> Settings),
the same XML a saved .kys holds. The desktop app hands the newest version's
file to the UI (Services::photoshop_shortcuts); at the first launch that has
no shortcut overrides yet, kys_import::auto_import applies it through the
Keyboard Shortcuts dialog's own OK path and posts a notice naming the set and
the count. The preference dialogs.photoshopShortcuts records the source, so
the import happens once; someone who already changed a shortcut keeps theirs.
Reset All to Defaults undoes it.

The Open dialog's filter now lists .kys (it greyed them out), and dropping
Keyboard Shortcuts.psp imports it like a .kys.

* kys import: Ukrainian and Dutch strings; tests follow Cmd+K and Cmd+F as the defaults (#1900)

* kys import: user-facing strings name no other product (#1985)

The button is Import Shortcuts…, its tip names the .kys format, the notice
says "Keyboard shortcuts imported" and names the set, and a file that is not
a set is "not a keyboard shortcut set (.kys)". A set without a name is called
by its file name. Comments, identifiers and the XML root tag keep the format's
own name.

* rustfmt: format panels.rs and prefs_ui.rs

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:55:43 -04:00
ROSSA AI 5ed094a940
ui: Panels::rail and Panels::menu_bar let an embedding app hide the icon rail and the menu bar (#1960)
The right dock's icon rail and the title bar with the in-window menus were
always drawn. An app that embeds PhotoCraft's UI and offers a simpler view, or
draws its own bar, could hide every other panel through `ui.panels` but not
these two.

Two Panels flags, both on by default and with `#[serde(default = "yes")]`, so
saved settings and the current UI are unchanged. `ui.set` accepts them like
the other panel flags. The title bar's caption buttons, where PhotoCraft draws
its own, go with it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:47:12 -04:00
mroxso 16ad6e1079
Add font previews and keyboard cycling to font menus (#1746)
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:43:59 -04:00
Max Murphy 995c895c33
Notices: auto-hide after a delay, with Interface preferences (#2022) (#2100)
* Notices: auto-hide after a delay, with Interface preferences (#2022)

Non-blocking notices no longer stay on screen until closed: each card
hides itself after Interface › Notification › Auto Hide Notifications'
delay (new, default 6 s), and the timer pauses while the pointer rests
on the stack until it moves away. Two new Interface preferences,
notificationAutoHide and notificationDurationSeconds, are read by
notices.rs; errors still land in the status bar.

The decision lives in the pure notices::expire helper, unit-tested
without an egui clock; Notice.shown_at round-trips through the control
channel with serde defaults. Labels translated across the complete
catalogues. Regenerated docs/scorecard.md.

* Notices: repaint on expiry, and a fresh delay when a long hover ends (#2022)

Addresses review of the auto-hide change:

- When the last notice expires, `show` had already painted it and, with
  nothing left, requested no further frame, so the card lingered until
  unrelated input. It now requests a repaint in the frame that drops
  anything, clearing the stack at once.
- A stationary hover over a settled UI refreshed `shown_at` only on
  frames, so hovering longer than the delay then leaving expired the
  notice instantly. `show` now tracks the pointer leaving the stack and
  restarts the timers on that frame, so hovering never counts as elapsed
  time.

Regression tests: expiring a notice requests a frame to clear it;
leaving a long hover gives a fresh delay.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:38:03 -04:00
Luniiya 8c3b6f8e5f
Appearance modes: sync with system, separate dark and light themes (#1981)
* Appearance modes: sync with system, separate dark and light themes

* Appearance button cycles Auto, Light, Dark

* Appearance: default stays Dark, follow the portal signal instead of polling, split out the Preferences resize

* Preferences: size both appearance cards for the five dark themes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:34:49 -04:00
Luniiya 782bcb67fa
Toolbar: Studio themes draw round colour chips with Switch Colors below (#2002)
* Toolbar: Studio themes draw large round colour chips with Switch Colors right below

* Toolbar: round chips keep Default Colors, follow Tokens::round_chips, lie side by side in two columns

* Theme: Solarized Dark and Adwaita use the Studio round colour chips

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:14:37 -04:00
Sivarajan cc584d9aff
Add Adwaita and Adwaita Dark themes (+ brush chip fix on light themes) (#1823)
* Brush preset chip: draw the tip preview in the icon colour on light themes

The options-bar chip always painted the round tip white, which nearly vanished on Studio
Light's chrome (246 gray) and disappears entirely on a white header bar. Dark themes keep
Photoshop's white tip; light themes use their icon token.

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

* Add Adwaita and Adwaita Dark themes

Two opt-in themes that put the Studio layout in GNOME's libadwaita palette, so PhotoCraft
sits with the rest of a GNOME desktop: header bar, sidebar, card and view colours, the
GNOME blue accent (#3584e4), destructive and warning colours, 6 / 9 / 12 px radii, and a
neutral (not red) close button as in GNOME's title bars. The default theme is unchanged.

Wired through ThemeKind, the interface.theme preference (adwaita, adwaitaDark), Window ›
Theme and the control channel, with menu and preference labels in every complete-menus
language ("Adwaita" kept as the proper name). A test holds both themes to WCAG contrast:
7:1 for text and 4.5:1 for dim text on every surface that carries labels.

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

* i18n: translate the Adwaita theme names in Dutch and Ukrainian

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 21:08:12 -04:00
SilentYeti db0fb07862
Add native layered Paint.NET (.pdn) import (#1998)
Squashed onto main, with the maintainer's asks applied:
- Photoshop's blend menus (layers, brush, pencil, gradient, layer style) stay as they are;
  the Layers panel and Blending Options list a Paint.NET mode only for a layer that uses one.
- The six Paint.NET mode labels are translated in every catalog, and the blend-mode i18n test
  iterates layer_modes().
- pdn::pixels rejects truncated uncompressed (version 1) data before allocating its buffer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:05:00 -04:00
Brandon Thomas d3fa5e4c6f Bump version to 0.6.0 (from 0.5.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:23 -04:00
Max Murphy 9c427a7a2c
Themes: add Solarized Dark (#1136) (#2098)
* Themes: add Solarized Dark (#1136)

* Solarized Dark: ship the palette's MIT license notice (#1136)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 20:34:31 -04:00
Brandon Thomas 69b1379781
prefs_ui: declare recovery_tests after the production code (fixes prefs_usage on main) (#2194)
#2115 put `#[cfg(test)] mod recovery_tests;` at the top of prefs_ui.rs, and the
prefs_usage test reads each file only up to its first test module, so every preference
read in prefs_ui.rs looked unread.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:16:52 -04:00
abc2cb 23fbfb9b3c
Layers: delete targeted masks and edit Solid Color by thumbnail (#1997)
* Fix mask trash targeting and Solid Color Fill editing

* Fix Solid Color picker mode conversion and validate RGB ranges

* Apply rustfmt to Solid Color regression tests

* Fix Solid Color mode regression test using distinct RGB values

---------

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 20:15:29 -04:00
Zam 6884809d02
Gradient Editor window: the Gradient tool's swatch opens a customizable gradient editor (#1983)
Rebased onto main as one commit (the PR's net diff); the preset-drop mapping now uses
ViewXform::active, which accounts for the display's pixels per point.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:05:26 -04:00
Nigel Ihza Firdausy d502c5de27
ci(web): validate browser builds and wasm size on pull requests (#1689)
* ci(web): build browser release and enforce wasm gate on PRs

* docs(web): describe PR browser build and size checks

* CI web gate: run only when web-relevant files change (main always)

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-09 20:05:15 -04:00
Ahmed Moussa 1cfc56db56
fix(Text): served fonts become script fallbacks on the web (#2109)
A host's font manifest can now list the scripts each font covers (`scripts` field). The first served family for a script the bundled fonts lack (Arabic, Hebrew, Devanagari, Thai, Japanese, Chinese, Korean) becomes that script's fallback. When a layout contains text in that script, it requests the family. The font database adds the family to the fallback stack once it arrives, and type layers drawn before then are re-rendered.

The web shell also fetches the fallbacks for the browser's languages (`navigator.languages`) at startup, so an Arabic reader gets the Arabic font before typing anything.

The manifest's SHA-256 is now turned into a Subresource Integrity value (`sha256-<base64>`) and passed with the font fetch, so the browser rejects any file whose bytes don't match.

Also adds a contributor entry for hamada147 to `contributors/people.toml`.

Signed-off-by: Ahmed Moussa <moussa.ahmed95@gmail.com>
2026-10-09 20:04:04 -04:00
zenjabba 1330899aca
fix(web): persist imported brush libraries in IndexedDB (#479) (#2054)
* Persist browser brush presets through IndexedDB

* fix(web): clear resolved preset staging rejections

* web: translate the brush preset storage messages

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

---------

Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:58:25 -04:00
siraxe 35f832f03a
Brush panel: update-current-brush button, identity-based preset selection, tip-grid fixes (#2007)
* update brush button

* ui brush duplicated highlight fix

* formatting fix + locale

* i18n: translate "Update the current brush with these settings" in remaining complete catalogs

* Tip Shape picks the tip only; set_brush records the preset once accepted

- A click in the Brush Tip Shape grid copies the tip fields only (main's
  Photoshop behaviour: the dynamics sections stay); the picked preset is
  marked current with brush.presets.setCurrent instead of a whole-preset
  tools.setBrush, so identity-based selection still tracks it.
- tools.setBrush sets current_preset only after the brush merged and
  validated, so a failed call leaves no state behind; a reset still wins
  over a preset in the same call. Tested.
- brush.presets.save / brush.defineFromSelection append at the end again
  (insert-after-the-copied-brush had no Photoshop reference).

* Brush presets: forget the picked preset when the tool's brush swaps; no indexing in brush.presets.update

Each painting tool keeps its own brush (#218), so Update Brush could overwrite the
preset picked with one tool with another tool's brush.

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-09 19:56:41 -04:00
Tanish Manku 917fbcb006
Pen pressure follows every sample: moves in one frame no longer share the frame's last pressure (#2112)
Brush phase 3b. The stylus feed was one latest-value slot, so every pointer move in a frame
painted with the frame's last pressure, tilt and rotation: fast pen strokes got stair-stepped line
weights (#2046, #2036, #1776, #610).

- `StylusFeed` keeps the latest sample (for proximity, the eraser end and tilt) and also queues
  every sample in order (bounded to 1024). `set()` is unchanged, so the macOS, X11 and web
  sources, and a WinTab source later, need no changes.
- Each frame takes its pen samples in order: the feed's queue, then the touch forces (Windows).
  Pointer move k of n takes the pressure, tilt and rotation interpolated at its share of the
  frame, from the previous frame's last sample. Rotation takes the short way round. A mouse
  still paints at 1.
- A new stroke starts from this frame's samples, never from a sample an earlier stroke left behind.
- The stylus updates once per egui frame: the canvas runs once per view, and egui can run a
  frame in several passes, so a second update drained an empty queue.
2026-10-09 16:55:18 -07:00
Harrison O'Connor-Hoover 269d8fedc7
Keep startup responsive while recovering autosaved documents (#2115) 2026-10-09 19:50:53 -04:00
David Mann 03bb64a681
Brushes: keep the folder hierarchy of imported .abr files (#1851) (#2001)
* Brushes: keep the folder hierarchy of imported .abr files (#1851)

Photoshop CC brush files keep their Brushes panel folders in a `phry`
(preset hierarchy) section; the importer skipped it, so every preset of an
.abr landed in one flat group named after the file.

- photocraft-psd: `abr::parse` reads `phry` (a token stream of `Grup`
  {Nm, zuid}, `preset` and `groupEnd` objects) into `AbrFile::folders`, one
  folder path per preset. A damaged hierarchy never fails the parse: a stray
  groupEnd, more or fewer preset tokens than presets, folders nested deeper
  than MAX_FOLDER_DEPTH (32) or an unreadable section become warnings and the
  affected presets land at the top level. `write_v6_folders` writes `phry`
  back (`write_v6` is unchanged).
- photocraft-paint: `BrushPreset::folder`, the nested folder path inside its
  group (serde default, so older JSON loads unchanged).
- photocraft-io: the file's folders nest inside the group named after the
  file, as Photoshop's Import Brushes does.
- Preset store: each stored preset records its `folder`; group files written
  before this change load with every preset directly in its group (no
  rewrite). Paths from disk and from imported .pcpresets files are bounded.
- Commands: brush.presets.move, moveGroup, renameGroup and deleteGroup take
  an optional `folder` path to move a preset into a folder, or to move a
  folder among its siblings, rename or delete it. importAbr reports the
  number of folders kept.
- Brushes panel and Brush Preset picker: nested folders are drawn indented
  under their group (list and grid), open and close like groups, take
  dropped presets, reorder among their siblings by drag and drop, and have
  Rename/Delete in their context menu.

Checked against the 4-folder ABR Photoshop 25.1 exports of its default
brushes (8/6/6/8 presets): all 28 land in their folders, and a write/parse
round trip keeps them.

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

* Brushes: bound phry folder allocation; reword the move folder param

Review on #2001:
- read_phry now runs after every section is read, so it builds folder
  paths only for the presets in desc (a phry may precede desc), and the
  paths it holds are capped at 16 MB; presets past the cap import at the
  top level with a warning.
- brush.presets.move documents `folder` as folder names, so the
  automation filesystem-path classifier no longer flags it.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 19:46:07 -04:00
Brennan Stehling 85572c61c3
Save in the background so large saves don't freeze the window (#2017) (#2072)
* Save in the background so large saves don't freeze the window (#2017)

File › Save and Save As encoded and wrote the whole document on the UI thread,
so a multi-GB layered TIFF or PSB save left the window unresponsive for the
whole write, with no progress.

Saves now run as a background job: a new `Services::save_file` encodes and
writes atomically on a worker (the desktop app provides it), the document is
locked meanwhile, and the existing progress dialog shows the stage with Cancel.
Cancelling while encoding leaves the file untouched. The save is recorded when
the job ends, the unsaved-changes prompt waits for it, and a window close
during a save is held until it ends. Without the service (web, tests) saves
stay synchronous.

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

* Background save: a save can't be cancelled once writing starts (#2072 review)

- `Services::save_file` now gets a `SaveCtl` and calls `commit()` right before
  replacing the file. Cancel and Esc are refused after that, so a "Cancelled"
  save can no longer replace the file while the document shows unsaved.
- A save's worker stays counted (`saving()`) until it returns, even when its job
  was cancelled: quitting waits for it, and another save to the same file is
  refused until it is gone.
- A failed or cancelled save no longer closes a window whose close was held.
- The pending reply carries the job id, and the job becomes `last_started`, so
  control and MCP clients can wait for the save.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 19:39:55 -04:00
Sir Starch 819bd47bf1
perf: bevel_bench, the cost of changing a chisel bevel's size (#2006 follow-up) (#2118)
#2006 made a bevel size change dearer on purpose (+10-75% on the GPU canvas,
2x for CPU text bevels) and asked for a bench of it under perf/.

`bevel_bench` renders a 12 MP document with one Chisel Hard inner bevel on
an anti-aliased ellipse (a smooth outline) or on a text paragraph (many thin
edges). Each case warms the caches one pixel below the size, then times
refreshes with the size growing by one pixel each, so the GPU canvas
rebuilds its distance fields every time (it keeps them while the size
shrinks) and the CPU compositor misses its map cache. Sizes 8, 50 and 250,
GPU and CPU, with `--scene`, `--size`, `--reps`, `--no-cpu` and `--json`.

Registered in perf/budgets.toml as P52-P59 with no budgets (track only,
like P45 and P46): GPU for ellipse and text at 8, 50 and 250, CPU at 8.
docs/scorecard.md is regenerated; the rows read "not run" until the next
baseline.

RTX 4080 SUPER, release, 9 reps, p50 ms, before #2006 (3bc49f5e) -> after:
ellipse GPU 88 -> 105 (8), 169 -> 199 (50), 305 -> 460 (250); text GPU
119 -> 140, 141 -> 252, 238 -> 557; CPU text at 8 402 -> 797. Runs vary by
about 20% on this machine.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:39:45 -04:00
Ayan e93b30e60d
Proximity Match: 1.5-3.8x faster stroke searches, smaller mask tables (#2101)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:34:26 -04:00
Ayan 3953d78378
Selection distance transforms: 7.6-10.6x faster and correct past 4K (#2093)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:28:33 -04:00
Alex 590c1496b4
Affinity import: special shapes and sample corpus (#1606) (#2035)
* Import Affinity issue 1606 special shapes

* Affinity corpus: give the #1606 sample ceilings a margin for font differences between machines

The samples' labels render with whatever fonts are installed, so the measured
differences move by up to 0.8 between machines; each ceiling is now the higher
measurement plus about 1. Also use as_chunks in on_white (clippy with --features corpus).

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-09 19:27:35 -04:00
Gert Pellin - GPE 0e011a69dc
feat(canvas): Caps Lock forces the precise crosshair for painting tools (#1758) (#2111)
* feat(canvas): Caps Lock forces the precise crosshair for painting tools (#1758)

* Caps Lock poll: back off for 5 s after an X error instead of reconnecting and warning every frame

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-09 19:25:36 -04:00
kayrockscreenprinting 5630e112b4
Layer style colours keep the document's mode (Gray, CMYK) (#2103)
In a Grayscale or CMYK document, effect colours (overlays, shadows, glows,
strokes, satin, bevel, gradient stops) were stored as RGB and drawn
unconverted: a red Color Overlay in a Gray document was red. Effect::in_mode
converts them where effects are written: the effect commands, the Layer
Style dialog (replace), Paste Layer Style and style presets.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:20:10 -04:00
ravelund 510636f461
DNG: JPEG XL compression 52546 (DNG 1.7, Samsung Expert RAW) (#2047)
* DNG: JPEG XL compression 52546 (DNG 1.7, Samsung Expert RAW)

Decode JPEG XL-compressed DNG strips and tiles with jxl-oxide (pinned
=0.12.6), with geometry and channel checks, Limits/AllocTracker budgets
and catch_unwind around the decoder. Warn when a DNG carries a
ProfileGainTableMap (tag 52525), which is not applied yet.

Tests encode fixtures in-process with zune-jpegxl (dev-dependency only).

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

* raw README: list Deflate DNG beside JPEG XL after rebasing onto #1971

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>
2026-10-09 19:17:46 -04:00
David Mann c161a7a5c9
Shape tools: a click opens Create Rectangle / Create Ellipse (#1175) (#2031)
In Photoshop a single click (no drag) with a shape tool opens a creation
dialog to type the exact size. PhotoCraft ignored the click: the drag
geometry was below 1 px, so finish_shape returned without doing anything.

- New shape_dialog module: Create Rectangle (width, height, four corner
  radii with a link toggle, From Center), Create Ellipse, Create Triangle
  and Create Polygon (sides, star ratio). Width and height show in the
  ruler unit and are stored in document pixels; values are remembered per
  tool. OK runs the existing shape.create command at the clicked point
  (or centred on it) with the options bar's fill and stroke; invalid
  sizes/positions are rejected with an error, never a panic.
- canvas::finish_gesture opens the dialog when the gesture never left
  the press point by more than 3 screen px; drags draw exactly as before.
  Line and Custom Shape keep ignoring clicks.
- vector_ui::create_shape shares the fill/stroke params with the drag path.
- Catalog entries for the new labels and titles in every complete language.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 19:14:31 -04:00
kayrockscreenprinting 72d78f3067
Patterns paint in the document's colour mode (Gray, CMYK) (#1950)
An RGB pattern (every built-in one) showed in colour as a pattern fill or
Pattern Overlay in a Grayscale or CMYK document, unlike Edit > Fill, the
export, and Photoshop, which fills in grey but keeps the pattern's colours
(a Grayscale round trip brings them back).

pattern::pattern_rgba projects a pattern's pixels through the document's
mode when painted; the CPU prepared tiles and the GPU pattern textures use
it. Patterns themselves are unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:12:45 -04:00
David Mann c7287703a3
Crop tool: Show Cropped Area (H) and Crop Shield colour/opacity options (#1919) (#2128)
The Crop options bar gets Photoshop's gear menu (Set additional Crop options): Show Cropped Area
(H toggles it while the Crop tool shows a box; off hides everything outside the box behind the
canvas colour) and Enable Crop Shield with Color (Match Canvas, the pasteboard colour, or Custom…,
which opens the Color Picker), Opacity (default 75 %) and Auto Adjust Opacity (half the opacity
while the box is dragged). The shield used to be a fixed black at about 51 %. The options persist in
ToolOptions.crop_shield (older settings get Photoshop's defaults; a stray opacity is clamped) and
ui.set takes cropShield. Classic Mode and Auto Center Preview are left out: PhotoCraft always crops
in Classic Mode.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 19:10:06 -04:00
David Mann 35010f5138
Crop tool: Straighten button and ⌘/Ctrl-drag level the frame (#1919) (#2127)
The Crop options bar gets Photoshop's Straighten button (one-shot), and holding ⌘/Ctrl with the
Crop tool is a temporary Straighten. The drawn line (with an angle readout) turns the frame so the
line ends up level, or plumb when it is nearer vertical (the Ruler's straighten_angle convention),
and fits the frame to the largest of its proportions inside the image (Classic Mode). It is a
pending frame edit: Enter commits, Esc cancels. New logic lives in crop_straighten.rs.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 18:56:11 -04:00
Ian Swope 2caa58884c
Free Transform: the preview follows edits made while the box is open (#1384) (#2099)
Changing the moving layer's opacity, fill or visibility in the Layers panel
during Free Transform was applied and kept on commit, but the preview showed
the session's starting state until then: the moving pixels kept their first
opacity and the document under them was the copy taken when the box opened.

follow_edits rebuilds that copy from the current document whenever its
revision changes (keeping the lifted pixels out) and takes the tint from the
layer's opacity x fill, or 0 when hidden. canvas::display_doc calls it before
reading the preview, so the canvas never caches a new revision with the old
preview. Lone mask/channel transforms and Transform Selection are unchanged.

Blend modes are still not shown on the moving pixels (an egui mesh blends
normally); that needs the compositor and is left for discussion in #1384.
2026-10-09 18:51:55 -04:00
Elvis33LE f9ea13a857
EXR: multi-part files open the colour part; exr_info lists them all (#2116)
* EXR: multi-part files open the colour part; exr_info lists them all

A Maya/Arnold render writes one part per AOV. The decoder took part 0
silently, so a depth-only part failed the whole file with "no R/G/B or
Y channels". Now the part that looks most like a colour image opens
(RGBA > RGB > YA > a single channel, earliest wins), with a
DecodeWarning::MoreParts naming how many parts exist.

exr_info lists every part (name, view, size, channels, deep/tiled) from
the headers alone, and decode_exr_part decodes a chosen part — F16 parts
stay F16. Deep parts stay with the deep decoder: a file that has any is
refused here with a pointer to decode_deep_exr, by design.

* EXR: cap the parts' sizes together, move the chosen part out instead of cloning it

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-09 18:51:33 -04:00
zenjabba 9d1713cfb6
fix(ui): show brush strokes live in mask view (#2078) (#2108)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 18:44:34 -04:00
David Mann 596842666f
Masks: mask button adds a vector mask instead of replacing the layer mask (#2075) (#2114)
The Layers panel "Add a mask" button always dispatched
layer.layerMask.revealAll/hideAll/…Selection, which replace the layer's
mask unconditionally. A second click (Alt or not) silently discarded a
painted mask; Alt-click only looked like "invert" because hide-all
replaced reveal-all.

Like Photoshop, the button now adds a layer mask when the layer has
none, a vector mask (Alt: Hide All) when it already has a layer mask,
and is greyed when the layer has both (or is a shape layer with a layer
mask). Its tooltip becomes "Add vector mask" once a layer mask exists.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 18:33:14 -04:00
zenjabba 48fc0279e7
fix(ui): keep 32-bit Preview Options within the window (#2081) (#2105) 2026-10-09 18:29:57 -04:00
zenjabba 9200786605
fix(linux): disable unsupported startup notification (#2089) (#2097)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 18:18:54 -04:00
Tanish Manku a680ed22c9
Brush strokes carry their timing: the first moves count, smoothing feels the same on any device, a held airbrush builds up (#2110)
Brush phase 3a (owner-approved brush overhaul):

- Every stroke point now carries a time (`timeMs`). egui's pointer events have none, so a frame's
  samples are spread evenly between the previous point and the frame time. Speed spacing, airbrush
  Build-up and Stroke Catch-Up worked from scripts but never from the canvas, because every point
  had time 0. The live stroke and the commit get the same times.
- A stroke started on the press takes its moves from the press on. The moves before egui
  recognised a drag (its 6 pt click distance) were dropped, which gave every stroke a straight
  start and lost the pen's pressure ramp-in.
- Smoothing pulls by stroke time (16 ms steps, fractional below that), not once per input event,
  so a 1000 Hz mouse and a 60 Hz pen feel the same. Without Stroke Catch-Up a paused pointer stops
  the brush, as in Photoshop.
- A pointer held still keeps the stroke's clock running only while the stroke changes with time
  (airbrush Build-up, or smoothing still catching up): it repeats the held point every 16 ms and
  asks for repaints, then goes idle.
2026-10-09 15:08:25 -07:00
Sir Starch f4c1048ead
Chisel bevel: facets are flat at any edge angle (#1926) (#2006)
* Layer styles: chiselled bevel facets are flat at any edge angle (#1926)

Chisel Hard and Soft shaded the facets of an edge at 30, 60, 120 or 150
degrees in regular cross-stripes (a comb along the edge), while 0, 45, 90
and 135 degree edges were clean. The height of a chisel is a linear ramp of
the distance to the edge, and that distance was measured to the nearest
pixel centre of a thresholded mask: an oblique edge is a pixel staircase,
so the distance ripples with its period (0.19 px at 60 degrees), and the
shading is a gradient of it, scaled by depth.

Chisel bevels now measure to the edge itself. A sharp anti-aliased edge
pixel (partly covered, touching an empty and a full pixel) places the edge
by its coverage along its own normal (edge_offset inverts the area a
straight edge cuts off a pixel), and a pixel measures to the line of the
edge pixels around the foot of its perpendicular, not to the centre of the
nearest one (edge_distance). The ripple drops to 0.01 px. Hard-edged masks
and soft ramps have no sharp edge pixel and give exactly the plain fields.

The GPU compositor takes the bevel distance fields from the CPU through
new FieldKind::BevelInside / BevelOutside (the glow fields are unchanged);
distance_field takes the reach so values past it are not refined, and the
band halo of the bevel fields grows to reach + 9.

* Chisel bevel: edge normals from a line fit, exact at hard stretches (#1926)

A follow-up to the previous commit, from measuring how far it was from the
best it can do. The 3x3 coverage gradient gives an edge's direction to 1-3
degrees, and the error is systematic, so every distance tilted the same way
and left a 0.010-0.017 px ripple. With the true normals the ripple is 0.002
to 0.004 px, so the normal was the limit.

refine_edge_normals fits a line through the edge points (pixel centre moved
to its edge) of the sharp pixels within 3 px that agree to 25 degrees, and
takes the line's normal where the points really are on a line (spread along
it at least 0.7 px, off it at most 0.1 px). Corners and tight curves keep the
gradient's normal. On straight edges it reaches the ideal-normal floor.

The inside field now seeds from the empty pixels, as the plain field does,
and measures to the sharp pixel next to the nearest empty one, capped by the
plain distance (which never falls short of the true one). A circle's pole,
or a rounded rectangle on whole pixels, has a stretch of edge exactly on a
pixel boundary with no sharp pixel; the distance to the sharp pixels alone
was up to 0.29 px too long there. A mask without sharp pixels still gives
exactly the plain field.

Measured on anti-aliased shapes: ripple on straight edges 0.0105 -> 0.0038
(0.0172 -> 0.0021 at 75 degrees); a disc of radius 70, inside, worst error
0.29 -> 0.02. The band halo of the bevel fields is reach + 9 and the banded
test now sees a halo that is too small (the old one passed at reach + 3).

* Chisel bevel: sparse sharp-edge storage, no full-map copies (#1926)

Measured before sending the fix: the new
bevel fields made a bevel size change in the GPU canvas about twice as slow
(12 MP: 84 -> 161 ms per step), and nearly all of a step is the CPU effect
maps. The cost of the normal refinement was memory, not arithmetic: a
Vec<Option<SharpEdge>> is 16 bytes for every pixel, refine_edge_normals
copied the whole map twice for a few thousand edge pixels, and
local_coverage cloned the whole map and read nine pixels for each.

SharpEdges keeps a 4 byte slot per pixel and a short list of the edge
pixels. The candidates are found by a row by row pass over which pixels are
covered (an edge pixel is covered and has an empty neighbour), and the
coverage is worked out where it is needed by the same formula as
local_coverage. The refinement works on the list. Nothing else changes:
the fields are bit-identical (FNV fingerprints of 27 maps at 5 reaches and
of whole bevel_maps at 3 sizes, 216 of 216 equal), and the rendered PNGs of
the issue scene and a realistic scene are byte-identical.

Release, 12 MP ellipse, size 8, one thread: new fields 408 -> 351 ms (old
fields 290); the sharp pass 48 -> 19 ms and the PCA step 32 -> 1 ms.
GPU canvas, ms per size change, main / before / after: 12 MP ellipse
84 / 161 / 96, 300 stars 122 / 246 / 166, text 91 / 184 / 139, 36 MP
ellipse 295 / 522 / 336. What is left on edge-heavy content is the
neighbour search and edge_distance, which this commit does not touch.
2026-10-09 14:22:21 -07:00
zenjabba 3bc49f5e1a
Fix Smart Object conversion of selected layers (#1079) (#2052)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 14:03:16 -07:00
kayrockscreenprinting 0493d06aaa
Edit › Stroke: whole opaque pixels, Center like Photoshop, and strokes on the canvas edge (#2025)
* Edit > Stroke: whole opaque pixels; Center puts the odd pixel inside; canvas edge strokes

Center expanded and contracted the selection by w/2, so odd widths left
half-transparent pixels on both sides (a 1 px stroke was a 2 px band at
half opacity), and a selection edge on the canvas border got no stroke
(Select All, Stroke Inside drew nothing). As in Photoshop, widths round up,
Center puts ceil(w/2) inside and floor(w/2) outside, and the mask is padded
so the canvas border is a selection edge.

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

* Edit > Stroke: a hard-edged selection strokes hard, matching Photoshop's curves

Photoshop's 6 px Outside stroke of a 14 px circle selection made without
anti-aliasing is reproduced exactly when a pixel is stroked whenever its
centre is less than width + 1 from a selected pixel's (our expand gave the
6..7 px fringe partial alpha). For a hard selection the band is hardened;
soft selections keep their soft edges.

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

* Edit > Stroke: the inside of a hard stroke matches Photoshop on curves

Photoshop's 3 px and 5 px Inside strokes of a 14 px hard circle, and its 6 px
one filling the circle, fit one rule: inside, a pixel is stroked when its
centre is less than width + 1/2 from an unselected pixel's (the outside rule
is width + 1). Straight edges are unchanged.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 14:01:07 -07:00
kayrockscreenprinting 1b8a68d45b
Edit › Fill and Edit › Stroke: the colour opens the Color Picker (#2019)
* Edit > Fill: Color... and its swatch open the Color Picker

Choosing Contents: Color... opens PhotoCraft's Color Picker (as Photoshop's
Fill does), and the colour swatch opens it too instead of egui's compact
popup. OK sets the Fill colour, not the foreground; Cancel leaves it.

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

* Edit > Stroke: the colour swatch opens the Color Picker

A dialog body asks for the Color Picker on its colour field with
color_picker_ui::request; Fill and Stroke share it. OK sets the dialog's
colour, not the foreground.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 14:00:31 -07:00
David Mann 51f9abaad7
Brushes: decode imported tips on demand, not at start (#1843) (#1949)
Opening the preset store decoded every imported tip at full resolution, and
each tip then lived twice: in the session's library and in the store's
snapshot of the last sync. A generated 600-brush library (16-bit tips,
800-2000 px, 2.27 GB decoded) cost about 4.5 GB after import and again after
every restart.

Library presets now hold their bitmaps as StoredTile references with a small
saved preview (at most 96 px): photocraft-paint gains TipShape::Stored and
Pattern::Stored (rendered with the full bitmap when loaded, else the preview),
and the store writes each tip's size and preview with its group. Opening the
store reads previews only; once a group is written its presets drop their full
bitmaps. A tip is decoded when its brush is used (picking a preset, a stroke
naming one, exporting) into a 256 MB least-recently-used cache. A loaded stored
tip serialises as `sampled`, so tool presets, actions and brush.get keep the
same JSON. Stores written before this change have no previews: their tips are
decoded once at the next start, eight at a time, and the groups rewritten. A
missing or corrupt tip file is now reported when the brush is used.

Measured with crates/engine/examples/abr_memory.rs on the 600-brush library
from crates/io/examples/gen_abr_library.rs (Windows, release):
after import 4565 -> 38 MB working set, after restart 4551 -> 20 MB,
after using 5 brushes 4560 -> 37 MB; store open 1.47 s -> 0.05 s.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 14:00:12 -07:00
zenjabba 20a5f193f8
docs: describe actual raster format depths and decode limits (#520) (#2050)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 13:59:13 -07:00
Roaldo Sulejmani 424a5d492f
fix: remove Photoshop from user-facing strings (#1985) (#2008)
The app still showed the Adobe Photoshop name in several user-visible
strings. Per the product rule (user-facing text says PhotoCraft only),
none of these mention Photoshop now.

UI labels, tooltips and status messages:
- New Document preset "Default Photoshop Size" -> "Default PhotoCraft Size"
- "All shortcuts reset to Photoshop defaults." -> "All shortcuts reset to
  PhotoCraft defaults."
- "Import Photoshop brushes (.abr)" -> "Import brushes (.abr)"
- smart-filter fallback name "Photoshop filter" -> "Smart filter"
- File > Save As .psd/.psb filter "Photoshop" -> "PSD Document"

Errors and warnings:
- "this Photoshop filter isn't implemented in PhotoCraft" -> "this smart
  filter isn't implemented in PhotoCraft"
- smart_map: "has no PSD equivalent", "not a recognized filter", "its
  filter data is missing/unreadable", "this filter has no PSD equivalent"
- tiff_layers: "the PSD layer data...", "the PSD image resources..."
- multichannel: "32-bit Multichannel isn't supported"
- abr: "not a readable brush file (.abr)"
- gradient import: "not a readable gradient file (.grd)"

Translations: updated the changed tl! sources in all 16 catalogs and
dropped the stale "Load Photoshop brushes (.abr)" entries.

Installer / OS metadata: de-branded the nfpm description, the PSB MIME
comment, the AppStream summary/description, the macOS UTType
descriptions, the Windows ProgId Description and the desktop launcher's
Keywords. The Photocraft.PhotoshopDocument ProgId and the
image/vnd.adobe.photoshop MIME/UTType stay: machine identifiers,
changing them hurts file fidelity and Open-with registration.

Command params docs (agent-facing): dropped Photoshop from the
type_cmds kernPair text, the layer_menu_cmds Advanced Blending defaults
and the mask_view_cmds backslash-key text. The "photoshopManages" wire
value stays. Comments, format identifiers and the "photoshop" theme
alias are out of scope and unchanged.

The macOS native-menu shortcut resolution messages no longer say "as in
Photoshop" (Hide/Minimize).
2026-10-09 13:59:07 -07:00
David Mann 27906c05c1
Compose: bound effect memory on large documents (#1909) (#2076)
A layer with Transparency Shapes Layer off takes the whole canvas as its
effect region, so on a 1920 x 103675 page each such stroked layer built
~2.4 GB of effect maps, and a render held them for every layer at once.
Every edit's damage rect rebuilt them: 13 s and 11+ GB for three layers,
past 24 GB for ten.

When such a layer's effects are local (no gradient strokes, Precise
glows, chiselled bevels or noise) and it is composited within the
rectangle being rendered (each band of render_bands, or a damage rect),
build its maps over that rectangle padded by twice the effect reach, and
let them go when the next band starts. Composited over anything wider (a
group's effect shape renders its children over the group's whole region)
it keeps the whole region. The pixels are unchanged: a test renders every
local effect kind, masked and unmasked, inside a group with far-reaching
effects and clipped to a base with a wide glow, at 8/16/32 bits, in bands,
parts and tiles from a cold effect cache, and compares bit for bit with
the whole render.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:58:01 -07:00
zenjabba 102e7ea891
fix(ui): apply distinct workspace preset layouts (#1120) (#2051)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 13:57:56 -07:00