Follow-up to #102. Drop the 4 Hz background mirroring of the OS clipboard
in logic(): reading the whole system clipboard (possibly a large image)
without user action is a privacy problem (macOS warns about it) and wastes
CPU. Edit > Paste / Paste in Place are now enabled when the session
clipboard has content or an OS clipboard service exists (no read); the menu,
shortcut, palette and control paths import the OS image once, on the paste
itself, and keep #102's centring of external images. An empty or non-image
clipboard makes Paste a no-op with a status message instead of an error.
Tests: external_clipboard_enables_and_centres_paste adapted (enabled without
a read, exactly one read per paste, centred), plus an empty-clipboard no-op
test and idle_frames_never_read_the_os_clipboard (20 full app frames, zero
reads).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #97/#98/#99/#101 code was not rustfmt-clean at max_width 160, and the
liquify/menu regression tests from #97/#98 used Context::run, which egui 0.36
removed: port them to run_ui, clear texture deltas, and let the popup area
lay out for a few frames before hit-testing it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japanese could not be typed or displayed in the UI.
- Type tool: handle egui IME events (Preedit/Commit). The preedit text is
written into the layer and replaced on every update, underlined while
uncommitted, and the caret rect is reported through output.ime so the OS
enables the IME and positions its candidate window. Without output.ime,
egui-winit never calls set_ime_allowed, which is why only paste worked.
- UI fonts: append the first installed OS Japanese font (Hiragino, Yu Gothic,
Meiryo, Noto Sans CJK JP, IPA...) to the end of every egui font family, so
Latin text keeps Inter / JetBrains Mono and only missing glyphs fall
through. No font is bundled; a missing font is not an error. Native only.
- Text engine: try Japanese fallback families before the SC ones.
Not covered: the web build has no CJK font, UI strings are not translated,
and text layers do not set a "ja" locale yet.
Tested: IME preedit/commit/cancel/stale-range unit test, font fallback unit
test, ui-egui and text crate tests, clippy -D warnings, cargo xtask layers.
Verified by hand on macOS (UI glyphs, IME composition, candidate window).
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
The drag drew a 1 px white box (invisible on white canvases). It now
previews the exact path shape.create commits, filled and stroked, with
the accent path outline on top. shape_geometry is shared by the preview
and finish_shape; engine::vector_cmds::shape_path builds the path.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): ignore menu-title hover through overlapping popups
* test(ui): keep overlap regression warning-free
* fix(ui): keep tall menu levels inside the viewport
* test(ui): inspect popup areas through public memory API
edit.paste's `enabled` gate checks only the session clipboard, but the
OS clipboard import ran inside run()/invoke -- after the gate. Ctrl+V
was consumed then dropped, and Edit > Paste stayed grey, so images
copied in other apps could never be pasted.
The app now mirrors the OS clipboard at 4 Hz (a read every frame is
too costly), the shortcut handler imports before checking enablement
on paste presses, and a clip_external flag keeps external images
centring instead of pasting at (0,0).
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Right-click opens the Brush Preset picker (or erases), eraser-on-Background tests, README comma
- #75: the right mouse button never paints with the tool. With a painting tool it opens the
Brush Preset picker at the pointer (Photoshop). New preference Preferences › Tools ›
Right-click with painting tools (tools.rightClickWithPaintingTools: brushPicker | erase,
serde default brushPicker): with erase, a right drag with the Brush erases with the current
brush (one paint.stroke, one undo step, pen pressure/tilt as usual). ui.pointer accepts
"button": "secondary". New module ui-egui/src/paint_mouse.rs; picker body shared with the
options-bar chip.
- #76: the engine already paints the background colour when erasing a transparency-locked
layer (the Background); added tests at 8/16/32-bit in RGB/CMYK/Gray, with a selection, on a
normal layer (erases to transparency), on a transparency-locked layer, and through the UI.
- paint.stroke/pencil refuse coordinates beyond ±1e6 (an f64::MAX point ran out of memory).
- #73: test that the canvas hands the live stroke over to the committed document with no
frame lacking the stroke, refreshing only the stroke's rect.
- #69: README comma.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Brush smoothing follows the options bar; the live stroke draws the catch-up tail (#73)
- The options-bar Smoothing % (pro and classic bars, Brush and Eraser) edits the session
brush's smoothing, which the live stroke and the committed paint.stroke both use; the
canvas no longer forces 0.3. The session brush starts at Photoshop's 10 %.
- With smoothing the brush lags the pointer and catches up when the stroke ends. LiveStroke
now draws that catch-up tail on every step (StrokeRenderer::tail_preview renders only the
tiles the tail touches; the previous step's tail is restored first), so the preview at the
last move equals the commit and nothing pops in on release. At 0 % there is no lag.
- Path › Stroke Path ignores smoothing (it strokes the exact geometry).
- Tests: paint tail_preview = finished stroke; canvas: 0 % preview = commit, 50 % preview =
commit with the end reaching the pointer, smoothing changes the stroke, commit equals
paint.stroke with that smoothing, no stale tails after sharp turns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Smoothing is a per-tool option that brush presets keep
- Picking a brush preset (tools.setBrush / paint.* "preset", .abr import with select) keeps the
current smoothing amount and mode instead of the preset's (BrushSettings::picked_over),
as Photoshop's options bar does. An explicit smoothing alongside the preset still applies.
- The Brush and the Eraser each remember their smoothing (UiState smoothing_tool /
tool_smoothing, synced on tool switch before the options bar, the canvas and each stroke);
a tool seen for the first time starts at 10 %.
- Tests: engine presets keep 10 % / a user value with pulled-string mode; UI per-tool
smoothing across Brush/Eraser/other tools and on the first stroke after a switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Adjustment dialogs: preview through a clipped adjustment layer on the GPU (#77)
While an Image › Adjustments dialog is open on a pixel layer, the canvas shows the
document with a temporary adjustment layer (the dialog's settings, the selection as
its mask) clipped to the target instead of re-running the command on a CPU proxy.
The canvas compositor draws it, so on the GPU canvas a change recomposites only the
target's area within the selection, and of that only what the view shows (the rest
catches up as the view moves); zoomed out on a large document it composites a copy
reduced to about screen resolution, built once per session. The base document and
region are built once per session; a change swaps the layer's adjustment in a
shallow copy. OK still runs the real command (one history step); Cancel drops it.
Kept on the CPU proxy where the layer composite can't match the command: non-pixel
or clipped targets, Blend If, visible untouched area, channel / Quick Mask targets,
CMYK/Lab/other modes, colour-making kinds in Grayscale, and documents the GPU
compositor doesn't cover. The navigator waits for settings to settle.
Tests: preview vs command within 1/255 (worst 0.37/255) for all 13 kinds at
8/16/32-bit, RGB and Gray, with/without selection; nothing changes outside the
region; dialog flow (cache, keys, damage, Cancel untouched, OK one step); GPU
parity of the preview and of the zoomed-out proxy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* interactive_bench: per-change Image › Adjustments dialog preview rows (#77)
Times the old CPU proxy preview against the clipped-layer preview at full
resolution, at 100 % view and zoomed out, with and without a selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* engine: display LUT without the 32-bit preview for the GPU canvas shader; keep values above 1.0 in texture_buffer
ColorState::gpu_canvas_lut leaves View > 32-bit Preview Options out of the display LUT
(the canvas shader applies exposure/gamma before it, on unclipped float texels), and
ColorState::hdr_preview returns the active preview. CanvasDisplay::texture_buffer no
longer clamps linear values to 1.0 before sRGB-encoding them (the 8-bit path clamps
afterwards with the same result).
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU canvas: Rgba16Float display texture for 16/32-bit documents (#46)
16- and 32-bit documents get an Rgba16Float canvas texture when the adapter can
sample, filter, render to and copy it (Rgba8Unorm stays for 8-bit documents, as the
fallback, and for high-bit documents over 100 MP, about 1.07 GB with mips), so smooth
gradients no longer band through 8 bits. Both the wgpu compositor path (encode pass
with 16F pipelines; no upper clamp) and the CPU band/rect upload path (f32 -> half
conversion, reused band buffer) write it. The 32-bit preview (exposure/gamma) moves
into the canvas shader, before the colour-management/proof LUT, so values above 1.0
are exposed into range instead of clipped. GpuCanvas::read_texels / texture_info for
tests and benchmarks; PHOTOCRAFT_CANVAS_F16=0/1 disables the float canvas / ignores
the budget. large_image_bench gains --depth 16|32.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
User and imported .abr brush presets now survive a restart. The new
photocraft_engine::preset_store keeps one JSON file per preset group plus
content-addressed, deflated tip bitmaps (8-bit when the tip came from 8-bit
data) in <config dir>/Presets, and an index with group order and deleted
built-ins. Built-ins are never written; writes are atomic and bounded;
corrupt or oversized files are skipped with a warning and left on disk.
The desktop app loads the store on a background thread and attaches it when
it arrives; sessions without a store (CLI, MCP, web, tests) stay hermetic.
Brush preset mutations bump ToolState::presets_rev and the store syncs after
the command, rewriting only groups whose content changed.
Fixes#78
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Select › Color Range: Select presets, samples, localized clusters, invert
select.colorRange only did one sampled colour. Add the dialog's Select
presets (Reds … Magentas, Highlights / Midtones / Shadows, Out of
Gamut), several samples (colors / eyedropper points), the Subtract from
Sample eyedropper (subtractPoints), Localized Color Clusters with Range,
tonal Range, and Invert. Bad params are rejected without touching the
selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Select › Color Range… opens a real dialog
The menu item ran select.colorRange immediately with the foreground
colour. It now opens a Color Range dialog: Select (Sampled Colors, the
hue families, Highlights / Midtones / Shadows, Out Of Gamut), Fuzziness,
Localized Color Clusters with Range, the tonal range sliders, the three
eyedroppers (on the preview; Shift adds, Alt subtracts), a grayscale
selection / image preview, Invert, OK and Cancel. Controls a mode
doesn't use are disabled. The preview and OK run the same engine
command, so the GUI, CLI and MCP select identically; OK is one history
step and Cancel leaves the selection untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Dialogs shrink back when their body gets shorter
The OK / Cancel row was laid out right-to-left with Align::Center, so it
filled the height left over from the previous frame's size and the
dialog could only ever grow. In the Color Range dialog, switching from
Midtones (two extra sliders) to Out Of Gamut or Sampled Colors left an
empty band around the buttons. Aligning the row to the top keeps it
exactly as tall as the buttons, so the dialog fits its content again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): brush and eraser show the real stroke while drawing
While dragging, the Brush and Eraser drew a hard, flat egui polyline at
the brush width; the soft brush only appeared on release.
The engine's new `brush_cmds::LiveStroke` shares `paint.stroke`'s setup
and renders incrementally with `StrokeRenderer` onto a copy of the
document. The canvas shows that copy and recomposites only each step's
damage (preview key = stroke key + step). The commit passes the live
stroke's seed, so its pixels match the preview, and reuses the canvas so
only the stroke rect refreshes.
Downsampled CPU textures (documents over 4096 px, the Navigator) now
update partially too, aligned to the downsample factor, instead of
re-flattening the whole document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Live stroke on the large-image stack: reduced CPU textures, pen tilt, display key
Adapts #64's live Brush/Eraser stroke to #71 (banded/reduced canvas), #66
(colour-managed display) and #74 (stylus tilt):
- compose: `render_reduced_damage` renders just the output pixels of
`render_reduced` whose source area meets a damage rect, with bit-identical
values (same spans, same summation order). The CPU canvas texture of a
document over 4096 px now updates partially from it (strokes, live steps,
any damaged edit) instead of #64's factor-aligned `downsample`, which
didn't match #71's area-reduced texture; uploads still go through
`display_image` (document -> monitor profile).
- ui-egui: the live stroke feeds each point's pen tilt/rotation (the same
values `Stylus::stroke_points` gives the commit), so tilt-driven brushes
preview what they commit; the first points come from the drag after
`Stylus::begin_stroke`.
- Preview keys keep #66's display key folded in; `damage_since` un-folds
the cached keys to compare against the live stroke's steps, and the commit
resets the caches to the document's key (raw 0 ^ display key).
- Stroke counter is masked so live keys stay in their 2^44 band.
6000x4000, 120 px soft brush, release: LiveStroke::push 0.72 ms mean
(p95 1.0), GPU damage refresh 1.6 ms mean (p95 4.7); CPU reduced-texture
rect 0.75 ms vs 135 ms for the full 3000x2000 reduction it replaces.
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>
* Compose: render in place and in bands; area-reduced and proxy thumbnails
render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.
New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).
thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail
egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.
The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* IO: banded export and import; no full-size copies in encoders; larger decode limits
- Flat export renders the composite in bands and quantises each band; alpha
is dropped in place when every pixel is opaque. The single-layer native
path copies surface rows a band at a time (no f32 read_region and no
per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
flatten, no second plane copy); near-opaque documents are re-encoded
without the white matte so the output is unchanged. Layer interleaved
bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
was refused at 2^28).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Filters: write tile results in memory-bounded groups
apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* History: bound pixel memory by Preferences > Performance > Memory Usage
History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves
One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto main (#58/#61/#63/#66/#70/#74) by the maintainers: automation
open/save return and surface import/export warnings (app.open/app.save
reply with `warnings`), automation opens route .abr/.grd to the preset
libraries with script events suppressed, interactive opens keep open_file's
path/recent/notice handling.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* psd: .abr brush (v1/v2/v6+) and .grd gradient (v5) parsers and writers
Clean-room from public format descriptions: legacy computed/sampled brushes,
8BIM-sectioned samp/patt/desc files, 8/16-bit raw and PackBits tips. Bounded
(MAX_EDGE, MAX_TOTAL_BYTES), damaged brushes are skipped with warnings, hostile
input returns Err. Adds an abr fuzz target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* paint: 36 grouped built-in brush presets and new procedural tips
General / Dry Media / Wet Media / Special Effects groups (pressure size/opacity
variants, airbrush, pencils, charcoal, pastel, crayon, rake, watercolour, ink,
oil, foliage, grass, stipple, sparkle, halftone…). Leaf, grass, charcoal,
sponge, star and rake tips are generated in code. BrushPreset gains a group.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io: map .abr brushes onto BrushSettings
Tip shape, shape dynamics, scattering, texture (embedded patt patterns),
dual brush, colour dynamics, transfer, pose, noise, wet edges, build-up and
captured tool options; unmapped settings are reported as warnings. Includes
bench_abr (100-brush import, 5000-dab stroke).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* engine: brush.presets.importAbr and gradient.presets.importGrd
Path or base64 data, group per file (re-import replaces), unique names next to
the built-ins; graceful-failure tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ui: real stylus pressure/tilt, grouped Brushes panel, Import Brushes
Pen force from egui Touch events (Windows WM_POINTER via winit), Pointer Events
pressure/tilt/twist on the web, tilt via ui.pointer; mouse stays 1.0. .abr/.grd
open from File > Open, drag-and-drop, Brushes panel and Preset Manager. Preset
previews keyed on a cheap signature instead of the full brush JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Magic Eraser and Background Eraser engine commands
paint.magicEraser erases the wand region (tolerance, anti-alias,
contiguous, sample all layers, opacity) to transparency, tile by tile in
parallel; paint.backgroundEraser erases colours similar to the one under
the brush hotspot (continuous / once / background swatch sampling,
discontiguous / contiguous / find-edges limits, tolerance, protect
foreground colour). Both compare and erase in the layer's own channels at
any depth, respect the selection, turn the Background into Layer 0 like
Photoshop, and are one undo step. The Magic Eraser paints the background
colour on layers with locked transparency.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Magic Eraser and Background Eraser tools in the Eraser group
Shift+E / E cycles Eraser, Background Eraser and Magic Eraser; the
toolbar flyout lists all three. Options bars: Magic Eraser tolerance,
anti-alias, contiguous, sample all layers and opacity; Background Eraser
sampling toggles, limits, tolerance and protect foreground colour. The
Background Eraser cursor always shows its sampling hotspot. Icons are
derived from Lucide's eraser (ISC), recorded in ATTRIBUTION.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Image Rotation, Canvas Size, Crop, Trim and Image Size move vector geometry
Canvas-wide operations remapped only pixels: type, shape and smart-object
transforms, vector masks, saved/work paths, guides, slices, notes and
Count/Ruler marks stayed put, so they jumped back on the next re-render.
New engine module canvas_geom applies the canvas map to all non-pixel
geometry. 90°/180°/flip turns remap every pixel surface exactly (incl.
type/shape/smart/fill caches and smart filter masks) and move geometry
through the matching affine; guides swap orientation under 90° turns;
rect/ellipse/line live shapes stay live; gradient fill angles follow.
Canvas Size/Crop/Trim translate geometry (shapes re-render for the new
canvas), Image Size scales it and re-renders vectors, Arbitrary rotation
also moves unlinked vector masks and document marks. Layer effect angles
stay (Global Light), as in Photoshop.
Tests compare the CPU composite of a rotated document (type, shapes,
vector mask, smart object) against the rotated flattened original, also
after re-rendering everything from geometry, at 8/16/32-bit RGB, CMYK
and Gray.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Magic Eraser: edit encoded tile bytes in parallel (24 MP: 1260 -> 375 ms)
Tiles are copied out as encoded bytes and only the touched samples are
decoded, so the parallel workers do all the work and the serial write-back
is a memcpy. The ignored bench prints a phase breakdown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Adjustments: full parameter sets for every kind, Hue/Sat ranges, CMYK/Lab tone channels
- doc: Hue/Saturation gains six colour ranges (HueRange), Gradient Map a dither flag,
Levels/Curves a ToneSpace (RGB, CMYK inks + black, Lab) for mode-native channels.
- engine: new adjust_params module (from_params over a base / to_params) used by
image.adjustments.*, layer.newAdjustmentLayer.* and layer.setAdjustment. Curves,
Color Balance, Channel Mixer, Black & White, Gradient Map and Photo Filter now honour
their params; bad params are BadParams errors; setAdjustment merges (empty = reset).
- compose/gpu: hue-range tables, CMYK/Lab tone channels (CPU; GPU falls back), dithered
Gradient Map, Black & White from grey/secondary/primary parts; GPU parity extended.
- io: hue2 ranges, grdm dither, CMYK/Lab Levels/Curves records round-trip.
- destructive adjustments use the document's tone transfer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Adjustments UI: one editor per kind for Properties and Image › Adjustments dialogs
- adjust_editors: Brightness/Contrast (legacy), Levels and Curves (per channel in RGB, Gray,
CMYK and Lab, histogram behind), Exposure, Vibrance, Hue/Saturation (master + six ranges with
draggable range sliders, colorize, before/after spectrum), Color Balance (tones, preserve
luminosity), Black & White (six weights, tint colour), Photo Filter (presets or colour,
density), Channel Mixer (outputs, constant, monochrome, total), Posterize, Threshold
(histogram), Gradient Map (stops, presets, reverse, dither). Replaces the M7 placeholder.
- Properties previews live (GPU) while a control moves and commits one setAdjustment per gesture.
- adjust_dialog: Image › Adjustments opens the same editor modally; the canvas previews the
real command on the proxy, Preview checkbox, OK = one history step, Cancel leaves the doc.
- Curves (#43): hit-test at the press origin, Delete/Backspace removes the selected point when
the graph has focus, Cmd/Ctrl-click removes, dragging off removes (and back on restores),
arrows nudge; endpoints and the last two points stay; pressing the gradient bars does nothing.
- tone: histograms per channel space, of the image below a layer or of the target layer alone.
- kittests: every adjustment layer kind edited through the Properties panel with real input at
8/16/32-bit RGB, Gray, CMYK and Lab (#12); Curves point interactions; dialog preview/OK/Cancel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io tests: adjustment round trip covers the new Levels/Curves/Hue-Sat/Gradient Map fields
#60's round-trip tests predate #68's doc-model additions (tone space and black channel,
Hue/Saturation ranges, Gradient Map dither); fill them with their defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* photocraft-plugins: sandboxed WebAssembly filter plug-ins (wasmi host, ABI v1)
A new L4 crate runs plug-ins in wasmi (pure Rust, MIT/Apache) with no host
imports, a fuel budget metered in slices with a wall-clock deadline, a memory
cap, a recursion cap and wasmi's strict module limits. Every band gets a fresh
instance; traps, budgets, bad manifests and bad pointers are all Errors.
Pixels go over as interleaved normalised f32 in tile-aligned bands, in
parallel on native, blended by the selection. Includes the example Invert
plug-in (no_std Rust, examples/plugins/invert-rs) built into a test fixture,
hostile-module tests, and docs/plugins.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Engine: plugin.* commands (install, list, run, remove, reload)
plugin.run filters the active pixel layer, a targeted channel or a smart
object (recorded as a smart filter) as one undo step; Preferences >
Plug-ins gains Use Additional Plug-ins Folder, loaded when prefs apply.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* UI: Filter > Plug-ins lists installed plug-ins with generated dialogs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Plug-ins: bands never outgrow the memory cap; regenerate parity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Bench: example plug-in Invert vs built-in Invert on 24 MP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* CMYK documents display and export through their embedded CMYK profile
A thread-local CmykSpace (photocraft_color::convert::with_cmyk_space) replaces the built-in
coated CMYK while the CPU compositor, the GPU compositor's uploads and the PSD merged
composite convert a document's CMYK pixels; GPU residents track the profile they were
converted with.
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* EXR/HDR are linear: tag imports linear sRGB, linearise exports
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Colour-managed canvas: document profile -> monitor profile on GPU and CPU paths
* engine display_color: CanvasDisplay per (document profile, mode, monitor), identity fast
path when they match (sRGB on sRGB costs nothing); linear composites are stored
sRGB-encoded in the 8-bit texture. canvas_lut now always carries the CM transform, with
Proof Colors / Gamut Warning / 32-bit preview on top.
* GPU canvas applies the display LUT whenever the transform isn't the identity (also for
filter/proxy previews); the CPU canvas and Navigator transform the 8-bit composite.
* Edit > Color Settings > Monitor Profile (auto|built-in|.icc). macOS 'auto' reads the main
display's ICC profile at launch via osascript/AppKit (no unsafe); elsewhere sRGB.
* Tests: P3 (hand-computed), Adobe RGB, ProPhoto, Gray Gamma 2.2, synthetic CMYK, linear,
monitor setting; GPU+CPU canvas test via kittest (skips without Rgba32Float).
Fixes#46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io: tag EXR/HDR linear in import_flat (adapt to the shared image_to_document)
#65 moved flat decoding into image_to_document(name, img) so camera raws can reuse it; the
linear-EXR tagging needs the file bytes for format detection, so it now runs in import_flat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* File open/save: set the path everywhere, Open Recent, show errors and import/export warnings
File › Open, drag-and-drop onto the window and launch paths now share
PhotocraftApp::open_file: the document is named after the file (not the full
path), keeps its path so File › Save writes back, and goes to Open Recent.
Failures are shown as errors (status bar + a non-blocking notice).
Import/export warnings from photocraft-io are no longer thrown away: they show
in the status bar and in a dismissable notice (native and web), and the
control protocol returns them from app.open/app.save/file.open/file.save/
file.saveAs/file.saveACopy. .pcraft is in the Open and Save filters, and
File › Save writes .pcraft documents in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* macOS: open files from Finder, Open With and the Dock (Apple events)
Finder double-click, Open With, drops on the Dock icon and open -a deliver
files as kAEOpenDocuments Apple events, not argv, and winit 0.30 ignores them.
Register an NSAppleEventManager handler through the small fmv-macos-events
crate (MIT OR Apache-2.0, 168 lines, audited, pinned =0.1.0; it keeps the
Objective-C unsafe out of our forbid(unsafe_code) workspace) before the event
loop starts, so both the launching event and later ones reach the UI as
OsEvent::Open; the window is focused. The quit Apple event goes through the
normal close path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Shortcuts match like Photoshop: Ctrl/Cmd + '+' zooms, exact modifiers, clipboard keys
- Cmd/Ctrl+'=' also matches '+' (Shift+= on US layouts, numpad +, the '+' key on
Nordic/German layouts); Delete also matches Backspace (the Mac delete key).
- Modifiers match exactly (Cmd+Alt+I no longer runs Cmd+I); Shift shortcuts on
punctuation match the shifted character (Cmd+Shift+; arrives as Cmd+':').
- Zoom In/Out, Fit on Screen and 100% stay live while a dialog or a non-text
widget has focus.
- clipboard_keys: egui-winit turns Cmd+C/X/V into Copy/Cut/Paste events (and
drops Cmd+V when the clipboard holds an image); outside text fields they
become key presses again so Edit > Copy/Cut/Paste/Copy Merged run.
- Snapping: Ctrl (Windows) / Control (Mac) disables snapping only while held
during the drag, not at the press (Ctrl-click belongs to the tool).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Dialogs: no cancel on outside click; canvas pans and zooms under them
- Disable egui's keyboard UI zoom: Cmd/Ctrl +/-/0 never scale the interface.
- A click outside a dialog no longer silently cancels it (Esc still does).
- With a dialog open the canvas still scrolls, pinch/Cmd-scroll zooms and
Space/middle/Hand-drag pans, like Photoshop; everything else stays modal.
- Clipboard key presses restored in raw_input_hook.
- Studio Properties card: Order::Middle (menus, popups, dialogs stay above it)
and draggable by its title off the area being worked on.
- Magic Wand options (tolerance, anti-alias, contiguous, sample all layers) in
the Studio themes too.
- egui_kittest tests: Ctrl+Plus zooms the canvas and leaves zoom_factor at 1,
zoom works with a dialog open, outside clicks keep the dialog, scroll and
space-drag pan under it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Menu bar: hovering another title switches the open menu
Like Windows and macOS menu bars: with a menu open, moving to another top-level
title opens that menu. Tests for the switch and dropdown row spacing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- canvas: partial redraws read last_damage with unwrap_or(EMPTY), and the
GPU cache is looked up with let-else after the upload.
- icons: a missing icon (and missing fallback) yields empty bytes, which
egui shows as a broken image, instead of expect.
- type tool: the caret update is an if-let instead of expect("editing").
- Variables dialog: pages return early if their JSON state is malformed
instead of unwrap.
- parity: the same hunk as #40.
- ui-egui denies unwrap/expect/panic/unimplemented/todo/unreachable
(a one-line attribute in lib.rs).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Three remaining expect() sites in files no open PR touches:
algo::inpaint::complete and algo::pyramid::gaussian loop with while-let
instead of levels.last().expect(), and ui-egui parity::compute indexes
the menu it just pushed instead of last_mut().expect().
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Close, Close All, Close Others, Revert, Exit and the window close button
now ask Save / Don't Save / Cancel when a document has unsaved changes.
The action is parked until each dirty document has been answered, then
re-run. Window close requests are cancelled while the prompt is open.
Closes#9
On D3D12 without DXC, wgpu compiles HLSL with FXC, which aborted on
fs_adjust ("internal error: compilation aborted unexpectedly"). wgpu turns
that into a panic, so every GPU test failed on the Windows runner, and the
app would crash where the same path is taken. A bisect on the Windows
runner pinned it to two cases of adjust()'s switch, Exposure and Selective
Color: both looped over dynamically indexed vector components. They are
now written per channel / per range with the same math (GPU-vs-CPU parity
is unchanged and passes on Metal and on the Windows runner).
Compositor::try_new_with_format catches validation and internal errors
while building the pipelines and returns Err; the canvas then uses the CPU
compositor (once, remembered) instead of crashing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust 1.99's clippy adds chunks_exact_to_as_chunks and some_filter, which
broke the Clippy step on every CI runner (it uses the latest stable). All
sites are mechanical: as_chunks drops the remainder exactly like
chunks_exact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The canvas only showed effects after OK. While the dialog is open, the
canvas now renders the document with the dialog's style applied: the same
commands OK runs, replayed on a scratch session (no history), cached by
document revision + style fields so switching pages doesn't re-render.
Cancel drops the preview. Works on both GPU and CPU canvas paths
(display_doc). Tests cover the preview, re-render on edit, and cancel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
All dialogs (Layer Style, New Document, filters, ...) were pinned to the
centre. Dragging the title row now moves them, clamped so the dialog stays
on screen. The title text is no longer selectable, so dragging it moves
the dialog instead of selecting text.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Tracks opened file paths (de-duplicated, newest first, capped at 10) in UI
state, and shows them as a dynamic File > Open Recent submenu after Open As…,
plus Clear Recent Files. Clicking an entry reopens that path; a stale index
errors gracefully. Recorded on both dialog opens and path opens (file
associations / control channel). Tests cover tracking/dedupe/cap/listing/clear
and a real open → recent → reopen round-trip.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Some drivers (e.g. Intel Vulkan on Linux/Windows) can't render to Rgba32Float,
so creating the 'fs_content' pipeline panicked with 'Format Rgba32Float is not
renderable' as soon as a document/image was opened. The accumulation format is
now chosen per adapter: Compositor::preferred_acc_format() keeps Rgba32Float
where it's a valid render target and falls back to Rgba16Float otherwise. The
compositor carries the format (pipelines, accumulation texture, byte size and
the readback half-decode all follow it); the app picks it from the eframe
adapter. Capable GPUs are unchanged (parity still exact, 18/18). A new test
forces the Rgba16Float path on any GPU and confirms it renders within display
tolerance (blend + adjustment). wasm green.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ctx.open_url is unreliable on native (notably Windows), so the Help menu,
the Discord buttons, and the start-page links did nothing. Added an open_url
Service hook wired to the `open` crate on native (ShellExecute on Windows);
links::open prefers it and falls back to ctx.open_url (web). link_row now
routes clicks through it instead of ui.hyperlink_to. Test drives every Help
link id + the open() helper through a mock service.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Preserve menu label spacing after egui installs its default menu-bar style.
- Apply six pixels of horizontal padding per side.
- Cover label padding, overlap, and File-menu interaction across themes.
Like Photoshop, the Add a mask button in the Layers panel and the
layer context menu's Add Layer Mask now turn an active selection into
the mask (Reveal Selection) and fall back to Reveal All without one.
Option-click on the button inverts it (Hide Selection / Hide All).
The choice lives in layer_menu_ui::add_mask_command so the button and
the menu agree; it only picks between the existing layer.layerMask.*
commands. Tests cover the mapping, the menu entries with and without a
selection, and that the new mask matches a rectangular selection.
Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
paint_gradient gains a dither flag: one quantisation step of monochromatic
position-hash noise, breaking banding without colour speckle. paint.gradient
takes "dither":bool (default true, like Photoshop); the options-bar Dither
checkbox now persists (gradient_dither) and is sent. New gradient_dither_
breaks_banding test; clippy + wasm green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
paint.stroke/paint.pencil now honour a "mode" param (normal|multiply|screen|…),
and the options-bar Mode dropdown sets the session brush's blend mode (it was
decorative). The Eraser is forced to Normal (no blend mode in Photoshop). New
brush_blend_mode_multiply test; clippy + wasm green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The options-bar Fill dropdown was decorative. It now persists in tool_options
(bucket_fill_pattern) and the click sends contents=foreground|pattern, so
Pattern actually flood-fills with the current pattern. ui tests + wasm green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Play loops the playhead via timeline.setFrame at the timeline fps, using
egui time (wasm-safe, no Instant) and requesting repaints; pausing/closing
stops it. New pause icon. ui-egui tests + wasm green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Platform-gated command for Windows Image Acquisition (scanners/cameras).
Reports device availability; real WIA COM device I/O is Windows + hardware
only (Photoshop gates this item to Windows too), so off-Windows it returns
available:false with zero devices. 1 test. Parity 624 -> 625 (100.0%);
FLOOR 620. Every Photoshop menu item now has a live command.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
layer.videoLayers.{newVideoLayerFromFile,replaceFootage,interpretFootage,
reloadFrame} load frames from a single image or an image-sequence folder;
file.import.videoFramesToLayers adds each frame as its own layer;
file.export.renderVideo composites the whole document at every timeline
frame and writes an image sequence (clean-room, no proprietary codec).
6 tests. Only Windows-only WIA Support remains. Parity 618 -> 624 (99.8%);
FLOOR 619.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Layer.video (VideoData: frame surfaces + fps + source); the layer's
displayed content is the frame at the timeline playhead, re-synced after
any edit/navigation. Commands: New Blank Video Layer, Insert Blank /
Duplicate / Delete / Restore Frame, Restore All Frames, Show Altered
Video, Rasterize (Layer > Video Layers > Rasterize and Rasterize > Video).
Timeline duration grows to fit. 4 tests; engine green, clippy clean, wasm
OK. Parity 609 -> 618 (98.9%); FLOOR 613. (File-based footage + Render
Video + Video Frames to Layers + .pcraft frame persistence are follow-ups.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
timeline_ui::handles() wired into menus::is_live so the Timeline panel
counts toward parity. 608 -> 609 (97.4%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clean-room trap: per-ink grayscale dilation (separable max filter,
par_rows/wasm-safe) applied only under already-inked pixels, so internal
colour edges get a trap band while object-vs-paper edges stay clean.
Engine image.trap (CMYK-only, active pixel layer, one history step).
algo 3 tests + engine 2 tests. Parity 607 -> 608; FLOOR 603.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merge a presets/preferences file (another install, or exported library)
into the current preset library, appending gradient/style/shape/pattern/
tool-preset groups the library lacks (by name); idempotent. Headless,
2 tests. Parity 606 -> 607; FLOOR 602.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Groups the selected layers and clips them to a frame rect (their bounds)
via a rectangular layer mask — the visual result of Photoshop's Frame for
this command, built from existing group + mask primitives (no doc-model
change). Headless/scriptable; 2 tests. Parity 605 -> 606; FLOOR 601.
(A distinct Frame layer type + the interactive Frame tool K are a later arc.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Photoshop's data-driven graphics dialog in the ui-egui crate, driving the
headless engine commands: Define page (per-variable name + layer + type,
add/remove), Data Sets page (navigator, per-variable value editors, Apply).
State lives in the dialog's __variables field (command-shaped JSON), so the
control channel drives it via ui.dialog.set / confirm like a human. 2 tests;
offscreen snapshot verified. UI stays in ui-egui; all behaviour is engine
commands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>