Rebased onto the paged GPU compositor (#187): the first-frame empty submit moves into encode_inner, and the test GPU lock covers the paged compositor too.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Layers rows: vector-mask thumbnail, link chains, red X on disabled masks (#153)
Each mask gets a chain (click toggles layer.layerMask.linked /
layer.vectorMask.linked) and a thumbnail; disabled masks are crossed out
and Shift-click toggles them. Vector masks render white/grey, cached and
pruned like the other row thumbnails. Thumbnails shrink on narrow rows so
the name keeps its minimum width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Channels/Paths: temporary layer mask and layer path rows, aspect thumbnails, bottom footers (#154)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91)
Move tool: Shift locks the drag to the dominant axis (re-evaluated with
hysteresis, so moving far enough the other way switches it); Alt at drag
start duplicates the selected layers once the pointer moves and moves the
copies, folded into one "Duplicate + Move" history step; arrow keys nudge
1/10 px (Alt duplicates first) and nudge the Free Transform box. All in a new
move_mods module with two one-line hooks in canvas::tool_event.
Free Transform: Shift on the body locks to 8 directions, Alt-click moves the
reference point, Cmd on an edge skews (Shift: along the edge, Alt: about the
centre), Cmd+Alt+Shift on a corner = perspective.
Preview (#91): the moving pixels were sampled down to 2048 px (nearest
decimation) and drawn bilinear, so a 24 MP layer previewed at 1/3
resolution. Now uploaded at full resolution (up to the device limit, box
filtered beyond), with a lazily built box-filtered mip chain for zoomed-out
views and nearest filtering at >= 200% (or Nearest Neighbor interpolation).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: clear texture deltas in transform gpu_ctx helper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- #147: default-sized dock groups give way (down to compact heights) so the
filler (Layers) keeps a preferred 500 pt: 10+ rows at 1440x900, ~5 at
1280x720 (was ~2.5 and 0). Heights the user dragged to are untouched; the
first splitter drag pins the other groups at the heights they show.
- #150: Window > Character / Paragraph (and Type > Panels) open a new
Character | Paragraph dock group below Properties instead of switching
Properties; choosing the visible one again closes it; workspace presets and
Reset close it. Saved layouts missing a group slot it below its default
predecessor, so it never lands under Layers.
- #151: dock tab strips (Pro and Studio) fit left of the panel menu button:
padding shrinks, then labels elide with '...', then overflowing tabs move
into a >> chevron menu; the selected tab always stays on the strip.
- #152: when the active layer changes by any means (canvas auto-select,
Select commands, undo, automation) the Layers panel opens its closed parent
groups (layer.setExpanded) and scrolls the row into view if it is hidden.
- Tests: rows visible at 900/720 pt; Character keeps Properties and toggles;
tab rects never intersect the menu button in every theme at 1x/2x and
180-420 pt; chevron menu switches tabs; reveal expands and scrolls (and
doesn't scroll for visible rows); undo reveals. Adjustment-editor and
shortcut tests no longer depend on default group heights.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush
Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields
- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
tool options map instead of warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush
- Brush Settings: Tilt Scale (enabled with Size on Pen Tilt) and Brush Projection in Shape
Dynamics, the Spacing checkbox, Wetness/Mix Jitter plus the Mixer Brush options in Transfer,
and a lock beside every section.
- Brushes tab (new brushes_tab.rs): list/grid toggle, drag presets within and between groups,
drag group headers to reorder, context menus to rename or delete presets and groups; all via
brush.presets.* commands.
- Options bar, the canvas brush picker and the [ ] keys edit the brush through tools.setBrush;
edits of one gesture share a coalesce key, so a drag is one journal entry.
- ui.set accepts brushesView (list|grid).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
roadmap.md: the honest parity assessment now breaks parity down by dimension with dated
measurements (PSD oracle 115/170, round trips 169/169, shortcut audit 0 failures, perf numbers,
RAW coverage, AI 0%, ~20 missing tools...), an overall estimate (feature surface ~60-70%,
pro-switchable ~25-35%), and a priority order for what's next.
README: the status note says plainly that PhotoCraft isn't yet a daily-driver Photoshop
replacement, what the biggest gaps are, and links the assessment.
AGENTS.md: agents read the assessment first when picking work and update it when numbers move.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)
Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.
Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Color Range: test the dialog in the full app without a GPU; show preview failures (#145)
The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).
A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app is native egui/eframe on wgpu and the web build is the same Rust compiled to
WebAssembly; state explicitly that Tauri (or any webview/JS UI framework) must never be added.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
parity.md's 625/625 measures menu wiring, not behaviour. Record a candid assessment after
0.2.0 and the first user feedback (feature surface ~55-70%, pro-switchable ~20-35%, area by area),
how to make parity measurable (workflow acceptance tests, real-file corpus, visual QA, user
reports), mark parity-estimate.md's ~82% as an upper bound, and update the current focus.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Flatpak single-file bundle as a release artifact (#140)
packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml repackages the
release's Linux tarball (no Rust build, no network) on the freedesktop 26.08
runtime; packaging/linux/flatpak-bundle.sh builds it with flatpak-builder and
flatpak build-bundle and smoke-tests it. release.yml gains a Flatpak job per
arch that feeds the draft release and SHA256SUMS. The Flathub manifest moves to
26.08 with the same documented finish-args (portals + Pictures/Documents), and
packaging-lint checks the two manifests agree. Font scanning also looks in
/run/host/fonts and /run/host/user-fonts so host fonts show up in the sandbox.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: installing the Flatpak bundle (#140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Flatpak job: install the SVG pixbuf loader for appstream compose
Without librsvg2-common (absent on the x86_64 runner image) the host's
appstreamcli compose fails with file-read-error on the scalable icon.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* release.yml: the Flatpak job runs without the release environment
It only repackages the Linux job's tarball and signs nothing, so it doesn't need (and
shouldn't hold) the signing secrets while running third-party tooling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Hand-built TySh/EngineData type layers as Photoshop writes them (point,
paragraph BoxBounds, scaled and rotated transforms, multi-line with explicit
leading, mixed sizes, 72/144/300 dpi) go through PSD export -> import and must
keep their full pixel extent and layer bounds, parse to the right pt size,
transform and shape, re-render with our engine onto the same pixels (bounds
within 1 px, IoU > 0.97) and composite to the file's merged image.
Investigation of #148: imported text bounds were already right. In the
designer QA PSD, 'Headline 105' carries a 300x200 vector mask added by the
generator (every i % 10 == 5 leaf), which clips the text at x = 685; with the
mask deleted the full line renders. H 29 px is the ink height of a 36 px line
without descenders, as Photoshop reports it.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(geom): saturate Rect::translate and inflate on i32 overflow
from_xywh already uses saturating_add. translate and inflate used
plain +/- and panicked in debug (or wrapped in release) when a layer
near the i32 edges was nudged off-canvas.
* fmt: Rect::translate/inflate at the workspace width (#159)
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>
- Layers panel rows lay out their right-hand indicators first (lock, fx badge
with an effects disclosure triangle, link, optional blend-mode label) and
give the name the remaining width, cut with an ellipsis. The Studio kind
sub-label and the Effects sub-rows truncate the same way; a narrow panel
squeezes group indentation before the name. Rows leave room for the
overlay scrollbar. Drawn rects are recorded for tests (layer_row_ui).
- New view-state command layer.setEffectsExpanded (DocState::fx_collapsed,
no history step), driven by the fx triangle (Alt-click: all layers).
- Layers panel menu: Collapse All Groups (layer.setExpanded all=false).
Group open/closed state is document data (PSD lsct, .pcraft).
- ui.set {dockWidth} sets the right dock width (250..520) for agents and
snapshots.
- engine example designer_psd: ~140-layer designer PSD (4-deep groups, long,
CJK/Greek/accented names, fx, clipping, masks, vector masks, locks, links,
type/shape/fill/adjustment layers, hidden layers) for visual QA.
- Tests: name never intersects an indicator at 1x/1.5x/2x, 250/290/520 pt and
in every theme; fx triangle hides/shows effect rows without selecting;
Collapse All Groups from the panel menu, state survives PSD and .pcraft.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Layers panel: group disclosure triangles expand/collapse groups (#126)
Group rows get a disclosure triangle; click toggles the group, Alt-click sets
every group. Rows now list a group directly above its contents (the panel
used to reverse a pre-order walk, putting headers below their children) and
closed groups hide their contents (a kind filter still shows them).
The state is Group::expanded (PSD section divider open/closed folder,
.pcraft), toggled by the new layer.setExpanded command as a view change:
no history step, a clean document stays clean. document.inspect reports it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Shortcuts: every displayed shortcut fires, from wherever focus is (#127)
- The menus show the catalogue's Photoshop shortcuts, but only command and
shell shortcuts were bound: Image Size (Cmd+Alt+I), Canvas Size
(Cmd+Alt+C), Feather (Shift+F6), Layer > Quick Export / Export As,
Window > Info (F8) and Actions (Alt+F9) showed a key that did nothing.
The binding table (new shortcut_dispatch module) now merges the
catalogue's shortcuts for live items, one owner per key.
- Any focused widget swallowed every shortcut (egui_wants_keyboard_input):
after clicking into a value field (Layers Opacity/Fill, options bar
Opacity/Flow/Smoothing), the Curves graph, or Tab, nothing fired until
the canvas was clicked. Now a focused text field keeps typing and its
editing keys (select all, clipboard, undo, navigation) while Cmd
shortcuts and F-keys still fire, as in Photoshop; a focused non-text
widget keeps only its navigation keys.
- A shortcut whose command is disabled says why on the status bar.
- Layers panel rows carry their layer name for accessibility (and tests).
Tests press every displayed menu / shell shortcut and every tool letter in
a whole-app kittest harness with a layered document (group, type layer,
selection) from four focus places, asserting each dispatches its item or
the item is greyed with a reason; plus real runs of the common ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* UI tests: scroll Layers rows into view under the fixed-height dock
The dock (#139) gives the Layers panel a fixed height that scrolls, so a
group's triangle or a row can sit below the fold. Tests now scroll the
node into view (accesskit ScrollIntoView) and click its real rect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Canvas: tool gestures start at the press origin, not where egui recognised the drag (#123)
A drag is only recognised once the pointer has moved past egui's click
distance; the Down event used the pointer position at that moment, so a
Type tool drag-selection's anchor landed a glyph or more away from where
the button went down. Adds a text-engine test that the caret geometry
(clusters, lines) sits on the rendered glyphs for tracking, horizontal
scale, mixed sizes and wrapped, centred paragraph text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Type tool: PSD type edits on the layout it shows; size edits are live, one step per drag, sized as shown (#123, #124)
- Clicking into a PSD type layer whose pixels are still Photoshop's
re-renders it with our engine first (inside the edit session's history
step; Cancel restores Photoshop's pixels), so caret and selection sit on
what is shown. Hit-testing a type layer also accepts its shown pixels.
- type.edit / type.setStyle damage only the layer's old and new pixels, so
the canvas recomposites that area instead of the whole document.
- Scrubbing a size (or any) type field coalesces per drag: live preview
every frame, one history step per drag.
- Options bar and Properties show and edit font size and leading as the
layer appears (size x transform scale, like Photoshop), and the options
bar shows the size at the selection.
- examples/type_bench: 6000x4000, 50 type layers, size step went from
297 ms to 4.6 ms (CPU canvas), 489 ms to 10 ms with drop shadows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Crop test: the frame starts at the press origin now (#123)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* tools.setBrush: skip untouched tip/pattern bitmaps; enum variant changes replace
merge_brush no longer base64-round-trips sampled tips and pattern tiles the
patch doesn't name: a Brush Settings edit on a 2500 px imported tip goes from
121 ms to 0.68 ms. A patch switching an externally tagged enum's variant
(texture pattern tile -> procedural) now replaces it instead of merging into
an invalid two-variant object.
Refs #95
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Brush Settings panel: Photoshop structure and depth, edits through tools.setBrush
- Sections split into brush_sections.rs: Brush Tip Shape with a tip grid of
preset tips (relative-size thumbnails), Restore original size, Flip X/Y, a
draggable angle/roundness ellipse with fields, Hardness (greyed for sampled
tips), Spacing; Shape Dynamics, Scattering, Texture (pattern swatch, style,
invert, scale, brightness, contrast, each tip, mode, depth, minimum depth,
depth jitter), Dual Brush (mode, flip, tip grid, size, spacing, scatter,
both axes, count), Color Dynamics, Transfer, Brush Pose (tilt now in ±100 %
of the model's ±90°, was off by 90x), Build-up, Smoothing.
- Control pop-ups offer the direction controls only for Angle; Fade shows its
step count; disabled sections show their options greyed; clicking a section
name shows and turns it on.
- Every edit becomes one tools.setBrush with only the changed fields (bitmaps
skipped unless changed), so the panel is journaled and drivable.
- brush_preview.rs: slot cache keyed on a cheap settings signature; the strip
and thumbnails re-render only on change and no longer leak a texture per
slider value.
- Brushes tab: size slider, search, collapsible groups (state in
UiState.brushes_panel), tip thumbnail + stroke preview + name rows, new /
delete / import buttons.
Refs #95
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Panel group heights no longer follow their content (#88): switching between a
pixel and an adjustment layer used to grow Properties and squeeze Layers.
- New `dock` module: `DockLayout` (order, heights, collapsed) in `UiState::dock`
(serde default, settable with `ui.set {dock}`). The last expanded group fills
the column; content taller than its group scrolls inside it.
- Drag the gap between groups to resize (min heights, clamped to the window);
double-click a tab or use the panel menu (hamburger, now live) to collapse,
move a group up/down, or close it; drag a tab strip to reorder (blocked by
Lock Workspace).
- Layout saved with New Workspace, reset by Reset Workspace (default heights,
order and tabs), and remembered across launches in `prefs.panelLayout` while
Remember Workspace Changes is on.
- #129: the icon rail toggled panel visibility, so a stray click made a panel
vanish. It now shows / expands / collapses a docked group and never hides it.
Window > <panel> and Window > Color/Gradients/... expand a collapsed group
instead of hiding it, so the menu always brings a panel back.
Tests: kittests for stable rects across layer kinds (Pro and Studio), splitter
drag + UI-state round trip + prefs persistence, reset/new workspace, double-click
collapse and strip-drag reorder (and lock), tiny windows, rail/Window menu
recovery, and a click-around test over layers, canvas, toolbar and options bar.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
blend_text_gamma_setting switched the compositor's process-wide text gamma
(psblend::set_text_gamma) to 1.0 and back while other engine unit tests ran
in parallel threads. turns_at_every_depth_and_mode flattens a document with
a type layer before a turn and again after edit.undo; when the toggle landed
between the two flattens, anti-aliased glyph edges blended under different
gammas and the bit-exact comparison failed (seen once on Windows CI).
Move the gamma test to crates/engine/tests/text_gamma.rs (its own process),
document the hazard on set_text_gamma, and make the undo assertion report
the turn, depth, mode, differing pixel count, max difference and first pixel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Dragging inside an existing crop frame now moves it (Photoshop Classic Mode) instead of
starting a new one; edges and corners resize it (Shift keeps the aspect ratio, Alt resizes
about the centre, an options-bar ratio preset always holds); Space while drawing a new frame
moves it like the marquee tools in Photoshop. Cursors follow the hit (resize arrows, move,
crosshair), the overlay gains edge handles, and a moved frame snaps its edges.
New module crop_ui.rs; small hooks in canvas.rs, snap_ui.rs, shortcuts.rs, control.rs
(ui.pointer `space` flag), panels.rs (hint) and lib.rs.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>