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>
* 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>
* 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>
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>
* 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>
* 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>
* 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>
* 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>
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>
#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>
* 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>
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>
* 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>
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>
* 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>
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.
* 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>
* 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>
#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>
* 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>
* 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>
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>
* 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>
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>
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>
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>
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>
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.
* 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>
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>
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.
* 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.
* 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>
* 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>
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>
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).
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>