Skip to content

feat: JPEG XL support — JXL format read support via jxl-oxide - #1

Closed
switch87 wants to merge 1127 commits into
mainfrom
manifest-name-follows-save
Closed

switch87 wants to merge 1127 commits into
mainfrom
manifest-name-follows-save

Conversation

@switch87

@switch87 switch87 commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

What

Adds read support for JPEG XL (.jxl) images to PhotoCraft, using the pure-Rust decoder jxl-oxide.

  • A new Format::Jxl variant.
  • Format detection recognizes both JXL flavours: the bare codestream (FF 0A) and the ISOBMFF container (JXL box signature).
  • A decoder module, crates/codecs/src/codecs/jxl.rs, behind an optional jxl Cargo feature (optional dependency on jxl-oxide).
  • FormatCaps for JXL: read is enabled when the jxl feature is on, at 8 and 16 bits per channel. Write is intentionally unsupported until a pure-Rust encoder exists.
  • .jxl is added to the Open dialog's extensions.
  • The decoder checks the image size against the usual Limits before allocating.

How it works

The jxl feature is optional. Official builds would enable it; without it, Format::Jxl still exists so files are recognized, but opening one reports that JPEG XL support isn't compiled in.

Status

This PR lives in my fork and is not ready for review. The branch it points at (manifest-name-follows-save) does not contain the JXL commits, so the diff shows unrelated upstream history instead. JPEG XL read support is being worked on upstream in storytold#2789 (open PRs storytold#2868 and storytold#3841), so this is kept only for reference.

Refs storytold#2789

vangel76 and others added 30 commits October 11, 2026 06:26
…ld#2830)

* Options bar: Reset Tool and Reset All Tools (storytold#2825)

Right-click the tool icon at the far left of the options bar for Reset
Tool (the current tool's options back to their defaults) and Reset All
Tools (asks "Reset all tools to their default settings?", then resets
every tool's options), as in Photoshop. Both are shell commands,
tool.reset and tool.resetAll, so the control channel can run them;
tool.resetAll opens the confirmation as a dialog, or skips it with
{"confirmed": true}.

ToolOptions is one struct, so tool_reset::reset_options maps each tool
to its fields; fields shared between tools (Tolerance, Anti-alias,
Feather, Strength, ...) reset with any tool that shows them. Selection
tools also go back to New Selection, and painting tools get the default
brush (colours kept).

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

* Reset Tool: cover Color Replacement, the anchor point tools and the crop custom ratio

Tools and options that landed on main since the branch was cut.

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>
… flip (storytold#2832) (storytold#2840)

The Free Transform context menu listed only the modes. It now follows
Photoshop's order: the modes, then Content-Aware Scale and Puppet Warp
(greyed while a box is open), Rotate 180°, Rotate 90° CW and CCW, and Flip
Horizontal and Vertical.

The Rotate and Flip items, whether chosen from this menu, Edit › Transform,
a shortcut or the control channel, now turn or mirror the live box about its
reference point without committing it (transform_tool::intercept). Before,
they ran the engine preset on the layer under the open box. They are greyed
while warping. Distort and Perspective are greyed over a box on editable
type, matching storytold#2701. A control-channel right-click during a transform opens
the same menu as the canvas.

Scorecard TOOL-214-5 is now done.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
…ld#2835) (storytold#2845)

edit.transform without a "target" (or "layer") param now follows the
Channels panel: a targeted alpha channel or the Quick Mask transforms
instead of the layer. Edit › Transform › Rotate 180°/90° and Flip
Horizontal/Vertical are routed to the panel target like the paint and
filter commands, so they turn a targeted alpha channel, the Quick Mask or
an unlinked layer mask within its frame (one undo step). The Background
no longer greys the transforms when a channel is targeted.

Flips scorecard row BUG-207-4.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…orytold#1767) (storytold#2809)

The rotate zone drew its own fixed arc with hard-coded black and white,
and the angle was bare white text that vanished over light images. W/H
and the move offset were not shown at all.

The rotate zone now uses the Crop tool's turn cursor (theme tokens),
bent round the nearest corner through the view, so it follows a turned
or flipped view. While dragging, the shared rounded readout beside the
pointer shows the angle (document-relative, one decimal), W/H in
percent while scaling, or the offset while moving, like Photoshop's
Show Transformation Values. live_readout() exposes the value for tests.

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…touches (storytold#2844) (storytold#2852)

With Auto-Select on (or Cmd held with it off), a Move-tool drag that starts where
no layer has pixels (an empty spot, or the pasteboard) now draws a marquee instead
of moving the previously selected layer. Releasing selects every layer whose
content touches it (Auto-Select: Group selects their outermost groups); Shift adds.

New engine command layer.selectInRect; the UI side lives in move_box.rs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
…#1067) (storytold#2803)

View > Pattern Preview only toggled a flag that nothing read. With it on, the
canvas now draws the document repeated around itself as a seamless tile, like
Photoshop, on the GPU canvas and the CPU fallback:

- pattern_preview.rs works out which copies cover the visible area (at most
  31 per axis, centred on the view).
- GPU: each copy is another paint callback over the same document texture,
  offset by whole document sizes in the shader (ViewParams::offset, the spare
  view-uniform slots), so nothing is composited again and seams are exact.
- CPU: the same texture drawn again with a shifted view mapping.
- The drop shadow is off while tiling; selection, guides and other overlays
  stay on the document itself.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The Pen flyout (P / Shift+P) now holds Pen, Freeform Pen and Curvature Pen.

Freeform Pen: a drag draws freehand; on release the trace is fitted with
cubic Beziers within the options bar's Curve Fit (default 2 px) by
photocraft_vector::trace::fit_trace (Schneider, "An Algorithm for
Automatically Fitting Digitized Curves", Graphics Gems 1990, the fitting
Make Work Path already uses), keeping turns sharper than 60 degrees as
corners. Releasing on the start point closes the path.

Curvature Pen: each click adds a smooth point and the path runs through
every point (Catmull-Rom style handles from
photocraft_vector::edit::curvature_knots); dragging a point moves it,
double-clicking toggles corner/smooth, clicking the first point closes,
Enter/Esc finish and Backspace removes the last point, as with the Pen.

Both commit through the Pen's path: a shape layer in Shape mode, else the
targeted vector mask, else the work path, as one History step.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
…2861)

The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal
Type Mask, in Photoshop's order. They type like the Type tools, with the
canvas under Quick Mask's colour and the glyphs clear while editing. On
commit the glyph coverage replaces the selection; no layer is left behind
and the whole session is one "Type Mask" history step. Escape always
cancels and leaves the selection unchanged.

The commit runs a new engine command, type.createSelection: a type layer's
glyphs (optionally removing that layer, the tools' hidden working text) or
type.create's keys directly, with new/add/subtract/intersect and an
anti-aliasing override. type.create's layer building is shared through
type_cmds::new_text_layer.

Original dashed-T icons (ATTRIBUTION.md), tool label translations in every
catalog, README tool list and the gaps/parity docs updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Speed up large feathered mask refreshes

* Format preference label for workspace check

* gpu test: allow float noise on a one-code vector-mask parity step

The combined mask is cached as U16 now; one pixel lands exactly one 8-bit
code apart (1.0000075/255), within the <=1/255 parity rule.

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>
Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
New Perspective Crop tool in the Crop flyout (C): drag a box or click four
corners, drag corners independently onto a skewed object, perspective grid
(Show Grid), Enter commits, Esc cancels. W/H/Resolution come from the Crop
tool's fields (Front Image, Clear), or the quad's mean side lengths.

Engine: image.perspectiveCrop {corners, width?, height?, resolution?,
interpolation?} maps the quad onto an upright canvas through the
quad-to-rectangle homography (Heckbert's square-to-quad map, inverted):
every raster layer, mask, alpha channel and the Quick Mask, one undo step.
The Background stays opaque over the background colour; type, shape, fill
and smart object layers are rasterized first; vector geometry follows the
nearest affine map. Non-convex, self-crossing, counter-clockwise or
degenerate quads are refused with an error.

algo: warp_surface_clipped bounds the per-tile warp by the new canvas even
when a layer reaches past the map's horizon; convex_quad, quad_size,
quad_to_rect.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…torytold#2866)

Photoshop's Actions panel has a check column: an unchecked step is skipped
by playback, Batch and droplets. PhotoCraft could reorder and delete single
steps but always ran every step.

- Action gains `disabled` (0-based step indices, omitted from actions.json
  when empty), kept in step with actions.move and actions.delete.
- New command actions.toggle {action, step?, enabled?}: one step, or every
  step like the action's own check; bad indices and types are errors.
- actions.play, the desktop playback path and the steps handed to Batch and
  Create Droplet skip unchecked steps; actions.get reports `disabled`.
- Panel: a check box per step, dimmed label when off, "2/3 steps" count.

Part of storytold#219 (AUTO-219-2).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
…r's x (storytold#1946) (storytold#2821)

egui's TextEdit picks the character under the pointer with
Galley::cursor_from_pos, which returns the start of the text when the
pointer is more than 5 points above the first row and the end when it is
below the last one, for single-line fields too. So dragging up selected
everything left of the anchor and dragging down everything right of it.

A small egui plugin, installed by PhotocraftApp::setup_context, keeps the
pointer's y on the text row of the focused single-line field while the
primary button pressed in it is held, before egui sees the input. The x is
untouched, so the selection follows it, as in Photoshop and native fields.
It covers every single-line TextEdit, number fields being typed into
included; a multi-line field (taller than two widget heights) keeps its
vertical drag.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
) (storytold#2870)

Select Subject recomputed superpixels, saliency, GrabCut and the
full-resolution re-cut on every call (P29: 260-370 ms on 24 MP here,
693 ms p95 in the baseline). The session now keeps the last result,
keyed by the pixels it came from: `same_pixels` (tile pointers) against
a weakly held document, plus the sampled target (composite or layer).
Selection-only edits such as Deselect keep it valid; the mode
(replace/add/subtract/intersect) is still applied each time. One entry
per session, dropped on pixel edits and when its document closes.

Repeat: 7-10 ms p95 (was 270-370 ms). First run unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…told#2735) (storytold#2854)

A dropdown needed two clicks: egui's ComboBox opens its list on a whole
click and its rows only take clicks, so press-drag-release (as the menus
do since storytold#775) chose nothing. press_menu::PressCombo gives combo boxes the
gesture without replacing egui's widget:

- after the box, a primary press on it opens the closed list (egui counts
  a popup opened in a pass as open, so it is drawn from the next frame);
- a row released on by that press-drag is chosen and closes the list;
- on the click that ends the press (pointer still on the box), egui would
  toggle the list shut, so it is closed just before the box and egui's
  toggle reopens it, drawn that frame: a plain click still leaves the list
  open for a second click.

widgets::dropdown (and its hovered/wheel variants), dropdown_with_tooltips,
the font family picker, the kerning presets, the Gradient Map presets, the
shape stroke choices and the credits sort all use it.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…old#2873) (storytold#2882)

The blur itself was already parallel (9-15 ms on 24 MP); most of the
command was serial glue around it. Modify copied the mask into a padded
buffer and back out sample by sample even with no padding, and combine
built a full-canvas byte buffer, scanned it, then copied it into tiles
on one thread.

- modify: use the mask as is when the selection is clear of the canvas
  edge (pad 0); pad and crop row by row otherwise.
- combine / mask_to_surface: fill selection tiles in parallel straight
  from the mask, a row at a time; fully selected tiles share one
  copy-on-write tile.

Bit-identical: the per-sample oracles (combine, now with whole tiles,
and the old pad/crop) are compared in tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ytold#2859)

* feat(ui): refine gradient fill properties

* feat(ui): use combined stops in Gradient Editor

* test(ui): restore cross-platform save path helper

* Refine Properties with a shared gradient ramp

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
…ld#2883) (storytold#2884)

Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#2823) (storytold#2876)

The Fit button was a full-size 30 pt button in the 30 pt Studio bar and
the zoom field a 24 pt box in the 24 pt Pro bar, so both touched or
overflowed the bar's edges. Inside every value field the theme's 24 pt
interact height and 4 pt button padding made the number taller than its
slot, so it sat low and, in a narrow box, spilled out of the left edge.

The status bar's controls now take a compact height (22 pt in Studio,
18 pt in Pro) from status_bar_heights, leaving even margins, through
new compact_value_field and compact_button widgets. A value field's
number slot is at least one text row tall and the number widget is
sized to it, so the digits sit 1 pt above the box's centre as intended.

With the bar at its true height the canvas shifted by 1 pt, which showed
that the live lasso ants were not on pixel centres; they are now snapped
like the marquee's.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ld#2900) (storytold#2902)

A dab that painted past a layer's stored tiles changed its resident page
region, which recreated the texture and converted and uploaded every tile
of the layer in that page cell again. Copy the overlap from the old
texture on the GPU instead (in its own submission, ahead of the queued
tile writes) and keep its tile map, so only new or changed tiles upload.

32-bit float RGB/gray tiles also convert to RGBA16F straight from their
samples (same bytes as the general path) instead of decoding into a float
buffer and converting pixel by pixel.

P16 (32-bit, 20 layers) brush dab + refresh, median of 6 alternating runs:
p50 8.9 -> 3.1 ms, p95 27.2 -> 7.5 ms. P13 is unchanged (~1.6 / ~4.5 ms).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…told#2661) (storytold#2892)

Photoshop has four interface brightness levels; PhotoCraft had the two
darkest (Pro, Pro Medium Gray). Add the other two with the Pro layout:

- Pro (Light Gray), id proLightGray: #B8B8B8 panels.
- Pro (Light), id proLight: #F0F0F0 panels.

They are light themes: Preferences > Interface lists them under Light
Theme, Window > Theme has an entry for each, and the legacy
interface.theme preference selects and migrates them. ThemeKind::is_pro
replaces the hard-coded Pro | ProMedium checks so the dock keeps the Pro
tab layout on every Pro level. Labels are translated in every catalog.

Tests check the panel greys, ids and names, the darkest-to-lightest
order, and WCAG contrast of text, icons and accent text on every surface.


Claude-Session: https://claude.ai/code/session_01CVtZHu998NDhf4Z8QvS5pd

Co-authored-by: ngrafton2006 <ngrafton2006@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
…rytold#2894)

* scorecard: eight Open bugs rows describe work that is already in the tree

AGENTS.md sends contributors to the scorecard's missing and partial rows when
picking work, so a stale row costs someone a session: they sit down to write a
test that is already there. These eight ask for work that exists and is covered
by passing tests.

storytold#205 / storytold#207, mask and channel transforms:
- BUG-205-4 ("no raster-mask test"): linked_raster_mask_moves_with_the_pixels.
- BUG-205-5 ("the other two not implemented"): all three cases are covered, by
  linked_mask_with_a_selection_moves_only_the_selected_region,
  unlinked_mask_stays_put and targeted_unlinked_mask_transforms_alone.
- BUG-207-4 ("transform() has no target handling"): edit.transform takes target
  (pixels, mask, quickMask or {channel: i}) and documents it in its params doc;
  targeted_alpha_channel_transforms_alone covers the alpha channel.

storytold#206, kerning — the whole section:
- BUG-206-1: CharStyle has both `kerning: Kerning` and `kern: f32`.
- BUG-206-2 / BUG-206-3: io/tests/psd_kerning.rs round-trips manual, Off and
  Optical kerning, and reads the AutoKerning flag and Kerning value of every
  character back out of the written TySh.
- BUG-206-4: Alt+Left/Right at a collapsed caret calls kern_pair, and the
  Character panel field parses Metrics / Optical / a manual value; both tested.
- BUG-206-5 stays partial, not done: the round trips are covered, but no oracle
  corpus file is dedicated to kerning values, which that row also asks for.

FILE-216-5 keeps its missing status (no save-path tests for layered TIFF, PDF,
SVG, ACO/ASE, .shc or Smart Filters), but its note claimed there is no
atomic-save failure-injection test; format/src/atomic/tests.rs has two, and
BUG-203-6 already records them as done.

docs/scorecard.md regenerated with cargo xtask scorecard; Open bugs moves from
22/2/8 to 29/2/1.

* scorecard: the Type section's kerning and Thai rows are out of date too

TYPE-218-6 ("Optical renders as Metrics; Alt+arrow moves by word") is done:
Alt+Left/Right at a collapsed caret calls kern_pair, optical kerning is computed
from the glyph outlines in text/src/optical.rs and applied by layout.rs when the
run's mode is Optical, and both modes round-trip through PSD. Covered by
type_tool_alt_arrows_kern_the_pair, optical_matches_photoshop,
optical_kerning_tightens_open_pairs, txt2_carries_optical_kerning and
io/tests/psd_kerning.rs. The row points at storytold#206, whose own rows are in the same
state.

TYPE-218-3 goes to partial, not done: Thai is covered (THAI_FAMILIES in fonts.rs
and thai_in_latin_font_falls_back_to_installed_thai_font, storytold#1909), but Khmer and
Indic have no fallback list and there are still no per-script golden tests.

Unchanged because they read correctly: TYPE-218-1 (TextShape is still Point and
Box), TYPE-218-2 (no direction UI), TYPE-218-4 (the axes in the shell are the
brush's), TYPE-218-8 (no triple or quadruple click) and TYPE-218-9.

* scorecard: Tools and Distribution rows are out of date too

TOOL-213-7 ("Live stroke only for Brush and Eraser"): canvas.rs strokes_live
covers Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn and Sponge,
and live_retouch_command adds the Clone Stamp, Healing Brush and History Brush.

TOOL-214-8 ("ruler only feeds straighten"): image.rotation.arbitrary opens
seeded with the measured ruler angle and the direction that straightens it;
arbitrary_rotation_starts_at_the_ruler_angle checks 5.7106 deg within 1e-3 and
the direction for three lines.

TOOL-214-5 goes to partial, not done ("No context menu in canvas.rs or
transform_tool.rs"): TRANSFORM_MENU does open on a right click during a
transform, with Free Transform, Scale, Rotate, Skew, Distort, Perspective and
Warp, and three tests cover it — but the row also asks for Flip and
Rotate 90/180, which are not there.

DIST-220-2 ("release.yml only x86_64 and i686 msvc"): the Windows matrix has
{ arch: arm64, target: aarch64-pc-windows-msvc }, and windows-arm64.yml
installs that MSI on a Windows 11 ARM64 runner, checks the binaries are ARM64
and runs the CLI natively. Signing takes the same path as x64 (DIST-2).

Unchanged because they read correctly: the whole Automation section (ABI_VERSION
is still 1 with Kind::Filter only, there is no ONNX dependency, no such xtask
check, and agent_tasks.rs really does run ten scripted tasks), TOOL-213-12,
TOOL-214-4, TOOL-214-6, DIST-220-1 and DIST-220-3.

* scorecard: Workspace and UI and File compatibility rows are out of date too

UI-217-9 ("prefs.rs UiScale unused"): prefs::UiScale offers Auto plus 75 to 300
percent, display_scale resolves it and sync_display_scale applies it with
ctx.set_zoom_factor; being a preference, it is remembered. The zoom_factor
assert the note cites is in a canvas-zoom test, checking that zooming the
document does not scale the interface.

FILE-216-1 ("No corpus step in ci.yml; in progress in storytold#190"): ci.yml has a
corpus job on every PR and on main, pinned, sha256-verified and cached by the
pins. storytold#190 is merged.

UI-217-22 and UI-217-23 go to partial, not done:
- layer.stampVisible merges the visible layers into a new layer and leaves the
  originals, and layer.stampDown exists, but neither has a menu entry and
  Alt+Merge is still missing.
- layer.copyToDocument copies layers into another document keeping masks,
  styles and blending (storytold#589), but there is still no copy/paste layer clipboard.

UI-217-11 keeps missing; only its note changes, from "five themes" to the eight
ThemeKind has. No Soft dark/Soft light, previews or outlined knobs.

Unchanged because they read correctly: UI-217-20 (guides are still Vec<f32> and
there is no rulers preference), FILE-215-6 (SmartObject.transform is still an
Affine), FILE-215-19 (no JPEG Options dialog), and the rest of both sections.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rytold#2888) (storytold#2907)

The Elliptical Marquee tested and wrote every pixel one at a time
(Surface::write_pixel), single-threaded, with no shared full tiles. The
add/subtract/intersect modes read both selections over the whole canvas
into f32 buffers. On 6000x4000 an ellipse took ~0.6-1.1 s and a mode
combination ~0.3-0.5 s.

ellipse_surface builds the shape per row: a run of fully selected pixels
(bounded analytically, checked with the exact f32 tests at both ends)
and edge pixels that get the original per-pixel coverage test. Tiles are
built in parallel, and fully selected tiles share one tile.
combine_surfaces maps two GRAY8 selections tile by tile through a
256x256 byte table, sharing tiles that come through unchanged. Both
produce exactly the old bytes (equivalence test against the old
per-pixel code).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The Frame tool (K, its own toolbar slot after the Crop group) draws a
rectangular or elliptical frame; the options bar picks the shape, Shift
constrains to a square or circle and Alt draws from the centre. A frame
drawn over the selected pixel layer's content takes that layer in;
otherwise it is an empty frame.

Frames are built as Frame from Layers builds them: a group clipped by a
layer mask (rectangular or anti-aliased elliptical). The new engine
command layer.new.frame does it in one Session::edit, so one undo step.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

* feat(ui): add guide layout dialog with live preview

* test(ui): restore path helper used by save dialog assertions

* chore(i18n): avoid conflicts with appended catalog entries

* chore: avoid append conflict for contributor credit

* Merge main: one people.toml entry for Skyfuller1303, drop the duplicate joined helper

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>
… (storytold#2917)

* perf: Duplicate Layer recomposites only where the copy draws (storytold#2883)

Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

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

* perf: pasting a layer recomposites only where it draws (storytold#2901)

Paste (and Paste in Place, Paste Into, Paste Outside) inserted the
pasted layer through Session::edit, which reports no damage, so every
paste recomposited the whole document (P10/P11). Capture the document
before the paste and report the new layer's change bounds with
note_insert, as Duplicate Layer does (storytold#2884). Anything that isn't
self-contained (a new clipping base, video, excluded channels) still
recomposites everything.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Image Size: Photoshop's resampling, measured

Photoshop 25.4's Image Size was measured as a black box (synthetic 16-bit
images in, exact RAW out); every method but Preserve Details' enlargement is
linear, so each was taken apart from its impulse responses.

- Bicubic is the Keys cubic at a = -0.75, not Catmull-Rom.
- Bicubic Smoother (stretched x1.15, a = -0.625) and Bicubic Sharper
  (x1.05, a = -1), with a growing with the reduction, and Bicubic Automatic.
- Reducing widens the filter by 1/s; below 1/4 the image is first averaged
  down to four times the target. Bilinear is never widened and averages
  whole pixels below 1/2. Positions are rounded to 1/128 pixel and stop at
  the edge pixel's centre.
- Two passes, the smaller scale first, clipped and stored between them; the
  bicubic filter is designed for the height's scale and serves both axes.
  Bicubic Automatic splits a mixed resize into Sharper, then Smoother.
- Image Size and Fit Image take automatic, bicubicSmoother, bicubicSharper
  and bicubicAutomatic; the dialog lists Photoshop's methods and opens on
  the interpolation preference.

On 225 probes the mean error falls from 240-550 to under 0.5 levels of
32768; 8-bit results are identical to Photoshop's for 99 % of values.

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

* Format: one rustfmt change main needs

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rytold#2665) (storytold#2932)

The Pro toolbar drew each tool glyph at 16 px in a 30 px button (the
generic icon-button proportion), which reads small and thin next to
Photoshop's toolbar. Pro themes now draw tool glyphs at 20 px; the other
themes keep the icon-button size.


Claude-Session: https://claude.ai/code/session_01CVtZHu998NDhf4Z8QvS5pd

Co-authored-by: ngrafton2006 <ngrafton2006@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
…e timeouts (storytold#2487) (storytold#2507)

* MCP bridge: reconnect before idle sockets die, retry reads, actionable timeouts (storytold#2487)

The app closes idle control connections after 30s (IO_TIMEOUT) while the
bridge kept one persistent connection, so the first call after a pause
failed with "operation may have completed". The bridge now drops and
reconnects before sending once the connection has been idle 25s, so a
request is never written to a socket the app may have dropped.

The default call timeout drops from 60s to 55s, strictly below the app's
60s reply deadline, so the bridge's own error — naming the method and the
likely causes — is the one callers see instead of a race between the
app's deadline reply and the caller's own cutoff.

Idempotent read methods (ui.inspect and friends) get one automatic retry
on a transport failure; edits keep storytold#1007 semantics and are never resent.
A timed-out call is never retried (it would only double the wait).

* MCP bridge: caller-vouched read retries, per-failure wording, docs contract (storytold#2487 review)

The retry decision moves from a hand-maintained method list — most of whose
entries were engine command ids that never travel as control-protocol
methods, so they could never match — to the call sites that know a call is
a read: call never resends (storytold#1007), call_read retries once on a transport
failure, and the MCP server opts its pure-read tools in. Failures are
worded by what they prove: a connection that could never be established is
"the request was not sent", not "may have completed"; a read timeout says
a read may be retried safely instead of demanding a state inspection. The
55s client cap, the read-retry exception, and the idle reconnect are now
documented in the control protocol and the security book.
…ld#1481) (storytold#2948)

As in Photoshop's pop-up menus, typing a letter in an open dropdown list
jumps to the next option starting with it (the same letter again cycles
through them), letters typed within a second match a longer prefix, and
Enter closes the list on the option shown. Matching is case-insensitive
on the translated labels (Unicode lowercase, no byte slicing). While a
dropdown list is open it owns the keyboard like an open menu, so its
letters no longer reach the tool shortcuts.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
atoslins and others added 22 commits October 12, 2026 03:28
* Linux: StartupWMClass is the window's app id

The desktop entry said StartupWMClass=photocraft, but the window carries
the app id ai.storyteller.photocraft (APP_ID) on Wayland and under X11
alike, so nothing that matches a window to its launcher by that key ever
matched it. VectorCraft, GridCraft and WordCraft already use the app id.

A test reads the entry and compares it with APP_ID.

* Contributors: add atoslins (Atos Lins)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
…3562)

* test(automation): ten more MCP acceptance tasks (11-20)

* docs(scorecard): FILE-216-6 note counts 20 tasks
…old#3601) (storytold#3607)

Studio, Studio Light and Classic showed only the hint 'Drag to draw a
gradient'. The Gradient arm was gated by t.pro; it now runs in every theme.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…n't overrun (storytold#3580) (storytold#3616)

A translated label beside a fixed-width control could run over its neighbours: the Layers
Lock/Fill row guarded only the redundant "Lock:" caption, so "Fill:" ("Заливка:" in Russian,
longer still as pt-br "Preenchimento:" / fr "Remplissage :") overlapped the lock icons.

Rather than a per-row fit check, add the one shared primitive for fixed chrome:
`widgets::elided_label(ui, text, max_w)` lays the text out no wider than its room, cuts it with
'…' and puts the full string on hover (built on the existing `tab_strip::elided`). The
Opacity/Fill scrub labels now elide to the room beside their field, so any translation shrinks
instead of overlapping, in every locale.

Documented the rule in docs/ui-design.md: wrap by default; beside a fixed control, elide; a
redundant caption may drop on a narrow dock (the lock icons carry the meaning).

Test: widgets::tests::elided_label_never_exceeds_its_room. Full photocraft-ui-egui suite: 2077
pass; fmt and clippy clean.
…torytold#208) (storytold#3624)

The marquee already picks the selection mode from the modifier held at the press
(shift adds, alt subtracts, shift+alt intersects) rather than constraining the shape.
marquee_tests.rs covered add and subtract but not intersect; this adds that case,
the one gap BUG-208-4 still names.

The test fixes an existing selection, holds shift+alt at the press (then releases so
the drag stays a plain rectangle, isolating the mode from the square/centre shape
constraints) and drags a second rectangle. It samples three points -- one in the
overlap, one only in the first rectangle, one only in the dragged rectangle -- so it
passes for intersect and fails for replace, add or subtract.

Test only; no production change.

Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
…ld#3638) (storytold#3640)

With Auto Center Preview off, or in Classic Mode, dragging a crop edge or
corner snapped the pointer, which grabs the handle up to the 8 px grab
radius away from it. The snap line showed on the target while the frame
edge stopped as far from it as the grab offset, so recropping toward the
pixels a Delete Cropped Pixels-off crop kept left a transparent strip
along the top and sides (or cut a few pixels off).

The crop handle snap (snap_ui::crop_handle) now covers every resize: it
snaps the handle of the frame crop_ui would make and moves the pointer by
the difference, 1:1 without Auto Center Preview and holding the ratio on
a corner as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ding between two (storytold#2946) (storytold#3572)

anchor_factors resolved the x and y axes independently and with different matching
rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase
"top"/"bottom" prefix). A misspelling matched one rule and missed the other, so
"topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not
offer -- with no error.

Match the anchor exactly against the nine documented values via one ANCHORS table;
anything else is a BadParams error naming `anchor` and listing them, as storytold#507 settled
for other command params. The nine valid values keep the exact factors they had, and an
omitted anchor still means center.

Test covers the nine offsets, the omitted default, and the misspellings from the issue
("topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE").

Fixes storytold#2946.

Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
…Photoshop (storytold#3437) (storytold#3590)

* Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (storytold#3437)

In Skew mode a corner drag scaled the box and the side handles moved
freely. Checked in Photoshop: a corner moves alone, horizontally or
vertically (whichever way the drag mostly goes); the top and bottom
handles slide only horizontally and the left and right ones only
vertically; Option moves the opposite corner or edge the other way. On
editable type a corner slides its whole edge instead, so the text stays a
parallelogram and remains live type. Free Transform is unchanged.

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

* Skew mode: a drag outside the box doesn't rotate it; ⇧ changes nothing (storytold#3437)

Checked in Photoshop. A click outside still commits.

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>
…isplayInfo (storytold#3577)

Photoshop 27.11 refused two of PhotoCraft's re-exported corpus files
(ag-psd layer-mask/src.psd, psd-tools effects/stroke-without-vector-mask)
with "Could not import the clipboard because an unexpected end-of-file
was encountered". Both carry transparency in the merged image plus an
alpha channel: PhotoCraft wrote two extra channels but named and
described only one in resources 1006, 1045 and 1077. In every corpus
file that has them (177, Photoshop's included), those resources hold one
entry per extra channel, the merged transparency first.

- Export: a "Transparency" entry leads the names and DisplayInfo when
  the merged image carries transparency and there are alpha channels.
- DisplayInfo (1077) records are 13 bytes, as in all 166 corpus files
  that have one; PhotoCraft wrote and read 14 (the older 1007 layout),
  which shifts every channel after the first.
- Import skips the transparency's entry, so an alpha channel keeps its
  own name and options ("Alpha 1", not "Transparency").

Corpus oracles and round trips are unchanged (io 148/170, psd-tools
237/309, Photoshop 137/258; every round trip renders the same).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…er the contour (storytold#3578)

Measured on Photoshop 27.11 (scripted: a 128 px square with one glow,
sampled pixel by pixel across its edges; outer and inner, both
techniques, sizes 10 and 20, spread 0/40 %, choke 0/30 %, Range 50 %):

- A Precise glow is 1 - D / (size + 1) beyond the solid spread/choke
  part, D being the centre-to-centre distance to the shape's edge.
  PhotoCraft's ramp ended at `size` and the default Range then doubled
  it, so it stayed solid for half the size and fell off steeply: up to
  122 levels off. The raw coverage is now 0.5 at the edge falling to 0
  at size + 1, so the shared Range step (v / range) gives Photoshop's
  ramp at the default 50 %; other Range values are unmeasured.
- A centre-source inner glow is exactly 1 - the edge glow, taken after
  the contour and Range. PhotoCraft inverted before them: the softer
  centre glow was 115 levels off, the precise one 64.

All 16 glow cases are now within 2 levels (10 were 58 to 122 off);
softer edge and outer glows are unchanged. On the GPU: the same ramp in
fs_mglow, and an "invert after the contour" mode (p0.y = 2) in
fs_mfinish for glows only (Satin keeps its own invert). The 44 GPU
parity tests pass. Corpus: the two Photoshop precise-glow oracles go
from 0.31 / 0.37 to 0.05 worst error (curved anti-aliased shapes,
still not under 2/255); nothing else changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ew (storytold#3720) (storytold#3731)

With the pointer on the gray pasteboard, wheel/Alt-wheel/pinch zoom anchored on the pointer,
so each notch slid the image away from it and out of the window. The anchor is now the
nearest point of the image (clamped in document space, so rotated and flipped views work);
over the image nothing changes.
…#3738)

The menu items ran the engine commands with no params: Save Selection
silently saved into a new alpha channel and Load Selection failed with
"missing `channel`". The form dialogs already existed in view_cmds but
the two ids were missing from wraps(), so they were never shown. Load
Selection now only offers the layer mask when the active layer has one.

Fixes storytold#3713

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…3745)

Since the shared docking migration (storytold#3097) the tool column's scroll area
filled the toolbar's whole height, so a window taller than the tools
pushed the colour chips, Quick Mask and Screen Mode to the bottom.
Photoshop keeps them right under the last tool. The scroll area now
shrinks to its tools and only a column taller than the window scrolls,
with the chips kept at the bottom as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…on (storytold#3737) (storytold#3739)

* test(ui): scaffold reported menu viewport coverage (storytold#3737)

* chore: opt FredApps into public profile credit

* Menus keep clear of the taskbar when the window position is unknown (Wayland) (storytold#3737)

On Wayland winit can't report a window's position, so egui has no
inner_rect/outer_rect, only the monitor size. work_area::visible_part
then returned the whole window, and fit_window never maximized an
oversized window, so long menus (Layer, View, Window...) were sized to
the full window and ran under a bottom panel. Without a position, cut
the window to the monitor size less the taskbar (the most that can be
visible wherever the window is), and use the content size as the
outer size for fit_window.

Replaces the scaffold test with one that fails on main.

---------

Signed-off-by: FredApps <153173854+FredApps@users.noreply.github.com>
storytold#3742)

Adds the 1,287 rows the other catalogs have and Italian lacked (filter and adjustment parameters and choices, Camera Raw, Lighting Effects, History and Layers labels, metadata, dialogs), fixes mistranslations in existing rows (Facet, Gradient stops, Save As Preset, Canvas, Spot Color) and uses one term per concept (Predefinito, Traccia, clic, scorciatoia, filmato).

Factors the Korean generated-label check into missing_generated_filter_labels and runs it for Italian too. Adds docs/localization-it.md and updates localization-parity.
Measured on Photoshop 27.11 (scripted: a 128 px square with one Bevel &
Emboss, light from the top at 30°, sampled pixel by pixel across its
edges; Chisel Hard and Soft, inner and outer, size 10 and 20, depth 100
and 300):

- A chisel facet's slope is depth × size / (2 (size + 1)): it rises to
  the same depth × size / 2 as a Smooth bevel, over size + 1 px.
  PhotoCraft gave it the full depth, about twice as steep (facet 217
  where Photoshop shows 188).
- Inner Hard's ramp runs from half a pixel beyond the edge to size + ½,
  so the facet is flat right up to the edge pixel; Inner Soft starts a
  pixel further out (a steeper edge pixel) and is not blurred; Outer
  Hard ends a pixel further out; Outer Soft is a pixel shorter with a
  steeper edge pixel. Outer chisel bevels also shade the pixel where
  their ramp reaches zero (it still slopes), as emboss styles already
  did.

All six inner and outer chisel cases are now within 1 level (they were
27 to 154 off); Smooth bevels are unchanged. Emboss and Pillow chisel
are not modelled yet (they keep the blur).

On the GPU: the same ramps in fs_mbevelh (Chisel Soft flag in p0.z), the
emboss-only blur, and the outer reach-past flag in p3.x; the 44 GPU
parity tests pass. Corpus: every Photoshop chisel oracle improves
(bevel-inner-chisel-hard 20.1 -> 6.5 % differing pixels, outer-chisel-
hard 13.6 -> 5.2 %, inner-chisel-soft 22.3 -> 9.3 %, emboss and pillow
chisel a few points); pass counts unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
storytold#3757)

The Character panel (and the Properties Character section) gains
Photoshop's row of eight OpenType buttons: Standard Ligatures, Contextual
Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling
Alternates, Ordinals and Fractions. Each shows pressed while its feature is
on at the selection (else the layer) and toggles it through the existing
type.openType.* command, on the same scope as the other Character controls
(the selected characters while typing, else every selected type layer).

Scorecard TYPE-218-5 stays partial: the Numerals and Stylistic Sets
submenus are still missing.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
…storytold#3312)

photocraft_io::named_for_file(doc, name) serialises the document under the
file it is being written to, so a .pcraft manifest carries the identity the
save is about to adopt rather than the previous one — the same rule every
other format follows on import, where document.name comes from the file it
was read from. A bare extension names no file and changes nothing.

Applied once, in the .pcraft branch of export_stages, which export and
export_with both funnel through: the desktop Save (services.rs:242),
Export (services.rs:384), the web build (web.rs:355) and the engine file
command (file_cmds.rs:295) are covered by one site, and the previews
rendered before the bytes describe the same document as the manifest.

Scope per review: the automation funnels (files::save / save_bytes) already
landed in storytold#3319 with their own named_for_output, so this carries no
crates/automation change; its scenarios are covered by
crates/automation/tests/native_save_name.rs on main.
@switch87
switch87 force-pushed the manifest-name-follows-save branch from 0fd2795 to cf130a6 Compare October 12, 2026 09:01
@Jxck-S

Jxck-S commented Oct 15, 2026 •

Copy link
Copy Markdown

The PR description looks broken overall: it contains stray terminal escape characters (colored diff output), pasted command output, and missing inline code/names, which makes it hard to read. Could you clean it up and rewrite it as plain Markdown?

@switch87

switch87 commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner Author

Thanks for flagging this, and sorry for the mess. I've rewritten the description as plain Markdown. Note that the branch this PR points at doesn't contain the JXL commits, so the diff shows unrelated upstream history; the actual work on JPEG XL read support is happening upstream in storytold#2789.

@switch87 switch87 closed this Oct 15, 2026
@switch87 switch87 reopened this Oct 15, 2026
@switch87 switch87 closed this Oct 15, 2026
@switch87
switch87 deleted the manifest-name-follows-save branch October 15, 2026 03:48
switch87 pushed a commit that referenced this pull request Oct 24, 2026
…torytold#7822)

* Color pickers: pick on press and follow small drags (storytold#7793)

The Color Picker dialog's field and slider, and the Color panel's
field and hue strip, only applied the colour on dragged()/clicked().
egui holds dragged() back until the pointer moves past its click
threshold (~6 pt) or is held a while, so a press picked nothing at
first and small adjustments jumped.

Add widgets::pick_pos, which reads the pointer from the frame the
button goes down (is_pointer_button_down_on), and use it on all four
surfaces. The Color panel now restyles selected type only when the
picked colour changes.

Tests: color_picker_ui::field_and_slider_pick_on_press_and_follow_small_moves,
panels::color_tests::color_panel_field_and_hue_strip_pick_on_press.

* #1 Reverted accidental scoreboard change when regenerating it
storytold#2 Fixed color picker not checking for pointer press, but any mouse click

* Update crates/ui-egui/src/widgets.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Color pickers: rustfmt pick_pos

* test(ui): use main's rgb_to_hsb in the colour picker tests

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.