Files
photocraft/docs/integration-testing/integration-checkpoint.md
T

4511 lines
231 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Integration checkpoint and decisions
**Partial integration: 91 of 108 selected heads are ancestors; 17 require resolution.**
No integrated build, corpus, full sweep or manual interaction qualification is claimed.
See [test plan](approved-bugfix-parity-2026-10-29.md), [selection evidence](approved-selection.json)
and [machine-readable status](integration-status.json).
## Latest progress (updated 2026-10-29T05:08:07.390596+00:00)
- Verified local integrated heads: **91 / 108**, checkpoint `3c79ed44304b27a0d811e379284acc29855798f6`.
- Layer Style sizing/layout/defaults and profile capture ports are committed; action JSON, definition history, named targets and starter actions are committed.
- CPU compositor checkpoint at `31ce2208…`: 402 PASSED, 0 FAILED, 2 IGNORED. Later code is not yet fully validated.
- Parent approved #5176 compatibility migration: atomically commit the new authoritative index last; preserve legacy/corrupt files and never resurrect stale entries. Implementation committed; 35 preset-store tests PASSED at d98e4195, including all four migration regressions; one benchmark ignored. Final-tree rerun remains pending.
- Final Windows portable artifact: NOT RUN until complete integration and applicable validation. Protocol correction is IN PROGRESS; generic historical per-PR cases are not accepted as complete.
## Scope holds
- #7098 excluded: successful current-head rollup contains only CodeRabbit; its official expanded corpus distribution remains pending.
- #3226 held: latest approval explicitly calls the provenance wording confirmation non-blocking, but asks maintainers to confirm it before landing. Parent decision required before counting it.
- #7848 prerequisite #7281 is merged upstream (2026-10-24); its corrective behavior still needs porting and new integrated validation.
- Both #9339 and #9342 retained: session-aware Export As conversion and IO/Save-for-Web embedding are complementary.
- #9509 is now integrated in `compose/src/composite.rs`; #9512 still requires combining its Fill behavior with that HDR precision change.
## Concrete resolution proposals
1. Canvas #2300: keep current split helpers and animated display-camera flow. Run stale-polygon cancellation and edge-scroll against the target camera before deriving the transforms; request repaint on center changes. Port its edge/corner, flip, rotation, aspect, pause and ownership regressions into current canvas tests. Do not restore the old monolithic body.
2. Adjustment #9512: extend current `blend_back` with Fill, retain `blend_rgb_fill` for special modes and retain #9509's `let j = 1.0-k; b*j+blended*k`. Port GPU planner changes into current planner modules; clipped adjustments keep Fill as coverage. Run both PR regression suites plus combined HDR/Fill/Opacity/mask cases.
3. Action stack #1991/#3455/#3461/#5090/#5176: preserve current split engine/UI action architecture. Port registry entries, bounded JSON validation, separate definition-history state, playback target resolution and tests; preserve every existing ambient-path authorization case when adding `actions.export`. Deleted old files must stay deleted.
4. Profile #9339/#9342: preserve captured effective working profile through preview, exact-size worker, replay and layer batches. Add IO embedding defaults without relabeling unconverted P3/Adobe RGB pixels as sRGB. Preserve embedding-off and source-immutability regressions.
5. Layer Style #4938 → #5010: keep current dialog dispatch/modules, port shared fixed sizing and three-column layout there, then #9517 scrolling and #9481 defaults. Preserve current dialogs and test-module split. Test short screens and every theme/scale.
These proposals are pending parent semantic review. Mechanical moved-test/catalog/workflow resolutions are already committed; they preserve both sides' independent behavior.
## Selected PRs by subsystem
### Actions and automation (11)
- [#1991](https://github.com/storytold/photocraft/pull/1991) feat(actions): import and export reusable Actions JSON files — INTEGRATED
- [#3455](https://github.com/storytold/photocraft/pull/3455) Undo and redo action definition edits from the Actions panel — INTEGRATED
- [#3461](https://github.com/storytold/photocraft/pull/3461) Replay recorded layer visibility on the intended named layer — INTEGRATED
- [#3748](https://github.com/storytold/photocraft/pull/3748) control: suggest near matches for unknown command ids and methods (#3674) — PENDING CONFLICT
- [#4993](https://github.com/storytold/photocraft/pull/4993) Prevent control-channel engine commands from editing behind modal dialogs — INTEGRATED
- [#5090](https://github.com/storytold/photocraft/pull/5090) Actions: add editable PhotoCraft Essentials starter presets — INTEGRATED
- [#5176](https://github.com/storytold/photocraft/pull/5176) Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale — INTEGRATED
- [#6492](https://github.com/storytold/photocraft/pull/6492) Fail control startup promptly when its port is occupied — INTEGRATED
- [#6510](https://github.com/storytold/photocraft/pull/6510) Automation: resolve configured root aliases for absolute paths (#6426) — INTEGRATED
- [#7742](https://github.com/storytold/photocraft/pull/7742) Actions: a fractional setting an integer field cannot hold is refused, not rounded — INTEGRATED
- [#8905](https://github.com/storytold/photocraft/pull/8905) fix(plugins): bound a run's total plug-in memory, not just each instance — INTEGRATED
### Compositing and filters (20)
- [#3874](https://github.com/storytold/photocraft/pull/3874) fix(layer-style): support Blend If Alt-split handle feathering and throttle live preview — INTEGRATED
- [#3967](https://github.com/storytold/photocraft/pull/3967) Add move and delete actions to Smart Filter rows — PENDING CONFLICT
- [#4305](https://github.com/storytold/photocraft/pull/4305) Filter > Other > Frequency Separation (#1714) — INTEGRATED
- [#4711](https://github.com/storytold/photocraft/pull/4711) Treat Dissolve as a per-pixel choice — PENDING CONFLICT
- [#4992](https://github.com/storytold/photocraft/pull/4992) Preserve Dissolve coverage when compositing clipped layers — PENDING CONFLICT
- [#7153](https://github.com/storytold/photocraft/pull/7153) Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace — INTEGRATED
- [#7903](https://github.com/storytold/photocraft/pull/7903) Filter Gallery › Cutout 4/4: trace the image into flat paper shapes — INTEGRATED
- [#8039](https://github.com/storytold/photocraft/pull/8039) Compose: Extend Softer outer glows past Size (#7899) — INTEGRATED
- [#8191](https://github.com/storytold/photocraft/pull/8191) Filter Gallery › Cutout 1/4: colour and palette stage — INTEGRATED
- [#8192](https://github.com/storytold/photocraft/pull/8192) Filter Gallery › Cutout 2/4: regions stage — INTEGRATED
- [#8193](https://github.com/storytold/photocraft/pull/8193) Filter Gallery › Cutout 3/4: edges and curves stage — INTEGRATED
- [#8903](https://github.com/storytold/photocraft/pull/8903) Box-filter minified patterns in the CPU and GPU samplers (#8831) — INTEGRATED
- [#8931](https://github.com/storytold/photocraft/pull/8931) feat(algo): describe the Gaussian blur as data for other devices — INTEGRATED
- [#9472](https://github.com/storytold/photocraft/pull/9472) fix(io): import a Displace smart filter that names its map file without embedding it (#9162) — INTEGRATED
- [#9509](https://github.com/storytold/photocraft/pull/9509) fix: an edit at full coverage keeps its result over huge 32-bit values — INTEGRATED
- [#9512](https://github.com/storytold/photocraft/pull/9512) fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes — INTEGRATED
- [#9584](https://github.com/storytold/photocraft/pull/9584) fix(compose, gpu): Blend If hard points match Photoshop at 16 bits and for Gray — INTEGRATED
- [#9589](https://github.com/storytold/photocraft/pull/9589) fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity — INTEGRATED
- [#9595](https://github.com/storytold/photocraft/pull/9595) fix(compose, gpu): a Pass Through group with Blend If renders isolated — INTEGRATED
- [#9651](https://github.com/storytold/photocraft/pull/9651) perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) — INTEGRATED
### File fidelity and colour (15)
- [#3142](https://github.com/storytold/photocraft/pull/3142) Read applied Sony RAW metadata and crop ILCE-6000 images — PENDING CONFLICT
- [#3387](https://github.com/storytold/photocraft/pull/3387) feat(codecs): add lossless JPEG XL export for 8/16-bit images — PENDING CONFLICT
- [#3519](https://github.com/storytold/photocraft/pull/3519) feat(export): optionally trim transparent margins from layer files — PENDING CONFLICT
- [#4035](https://github.com/storytold/photocraft/pull/4035) fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778) — PENDING CONFLICT
- [#5870](https://github.com/storytold/photocraft/pull/5870) fix(format): reject duplicate native layer identities — INTEGRATED
- [#6387](https://github.com/storytold/photocraft/pull/6387) Video Frames to Layers: every page of a multi-page TIFF (part of #6379) — INTEGRATED
- [#8897](https://github.com/storytold/photocraft/pull/8897) fix(io): read linked smart objects for PSD export as regular files only — INTEGRATED
- [#9339](https://github.com/storytold/photocraft/pull/9339) fix(export): embed requested sRGB profile for untagged sources (#9325) — INTEGRATED
- [#9342](https://github.com/storytold/photocraft/pull/9342) fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325) — INTEGRATED
- [#9401](https://github.com/storytold/photocraft/pull/9401) fix(format): a save on macOS keeps the file's creation time (#9388) — INTEGRATED
- [#9416](https://github.com/storytold/photocraft/pull/9416) Affinity reader: contiguous curve records and a cheaper object tag index — INTEGRATED
- [#9418](https://github.com/storytold/photocraft/pull/9418) Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073) — INTEGRATED
- [#9457](https://github.com/storytold/photocraft/pull/9457) raw: faster tone stage (Shadows, Highlights, Clarity, Texture, Dehaze), same bytes — INTEGRATED
- [#9590](https://github.com/storytold/photocraft/pull/9590) fix(io): PSD layer-effect contours keep at most 16 points — INTEGRATED
- [#9596](https://github.com/storytold/photocraft/pull/9596) fix(io): drop a kept Txt2 once no type layers are left — INTEGRATED
### Painting (9)
- [#4763](https://github.com/storytold/photocraft/pull/4763) refactor(liquify): reuse native brush resizing and anchored HUD feedback — PENDING CONFLICT
- [#4985](https://github.com/storytold/photocraft/pull/4985) feat(ui): add Shift+right-click brush mode picker — INTEGRATED
- [#5082](https://github.com/storytold/photocraft/pull/5082) fix(mixer-brush): Alt-click samples the foreground colour like the Brush (#4873) — INTEGRATED
- [#5165](https://github.com/storytold/photocraft/pull/5165) Fix: brush section selection — INTEGRATED
- [#5939](https://github.com/storytold/photocraft/pull/5939) fix(paint): scale blur sigma with brush size (#5892) — INTEGRATED
- [#6139](https://github.com/storytold/photocraft/pull/6139) Fix painting workflows, File Info and layer selection — INTEGRATED
- [#6236](https://github.com/storytold/photocraft/pull/6236) Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098) — INTEGRATED
- [#7848](https://github.com/storytold/photocraft/pull/7848) fix(retouch): preserve Smudge pickup with live-pixel stroke commits — INTEGRATED
- [#9623](https://github.com/storytold/photocraft/pull/9623) Pencil: keep a 1 px freehand line connected where it turns — INTEGRATED
### Platform and reliability (8)
- [#3554](https://github.com/storytold/photocraft/pull/3554) fix(packaging): launch bundled CLI directly from AppImage — INTEGRATED
- [#4742](https://github.com/storytold/photocraft/pull/4742) fix(macos): bundle photocraft-cli and resolve it from droplet scripts — INTEGRATED
- [#6383](https://github.com/storytold/photocraft/pull/6383) Windows releases ship a symbols zip: the release build's PDBs for profilers (#688) — INTEGRATED
- [#7743](https://github.com/storytold/photocraft/pull/7743) packaging: the portable zip holds the files, not a folder with the files in it — INTEGRATED
- [#9015](https://github.com/storytold/photocraft/pull/9015) fix(xtask): reject leading zeroes in numeric prerelease identifiers — INTEGRATED
- [#9019](https://github.com/storytold/photocraft/pull/9019) fix(xtask): reject unknown performance benchmark selections — INTEGRATED
- [#9110](https://github.com/storytold/photocraft/pull/9110) test: GPU tests honour WGPU_BACKEND, so they run on Windows with an Intel GPU (#4) — INTEGRATED
- [#9436](https://github.com/storytold/photocraft/pull/9436) Startup: a failed launch says why in a dialog instead of closing silently (#7643) — INTEGRATED
### Selection, paths and transforms (13)
- [#2300](https://github.com/storytold/photocraft/pull/2300) Scroll the viewport while outlining with Polygonal Lasso — PENDING CONFLICT
- [#3464](https://github.com/storytold/photocraft/pull/3464) fix(vector): target selected saved paths and own Pen gestures — PENDING CONFLICT
- [#5453](https://github.com/storytold/photocraft/pull/5453) Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1 — INTEGRATED
- [#5493](https://github.com/storytold/photocraft/pull/5493) Import Photoshop custom shape sets into the Shapes panel — INTEGRATED
- [#5515](https://github.com/storytold/photocraft/pull/5515) Selecting subject now automatically applies to selected layer, and us… — INTEGRATED
- [#5650](https://github.com/storytold/photocraft/pull/5650) Guides: ⌥ turns a dragged guide; dropping one on either ruler deletes it (#5034) — INTEGRATED
- [#6026](https://github.com/storytold/photocraft/pull/6026) Marquee: Fixed Size selects exactly the entered width and height (fixes #6015) — INTEGRATED
- [#6621](https://github.com/storytold/photocraft/pull/6621) Crop: right-click on the pending crop opens the crop menu (#6561) — INTEGRATED
- [#7648](https://github.com/storytold/photocraft/pull/7648) Pen: Path Operations menu, as in Photoshop (#1397) — INTEGRATED
- [#8074](https://github.com/storytold/photocraft/pull/8074) Transform: remember selected interpolation (#8067) — INTEGRATED
- [#8896](https://github.com/storytold/photocraft/pull/8896) Select and Mask: Photoshop's full-window workspace (#7069, #8148) — INTEGRATED
- [#8986](https://github.com/storytold/photocraft/pull/8986) Path tools: edit the saved path selected in the Paths panel (#8478) — INTEGRATED
- [#9493](https://github.com/storytold/photocraft/pull/9493) fix(vector): a mask feather continues the mask past the canvas edge — INTEGRATED
### Type and typography (6)
- [#3139](https://github.com/storytold/photocraft/pull/3139) Draw right-to-left layer names in visual word order — PENDING CONFLICT
- [#4783](https://github.com/storytold/photocraft/pull/4783) Fix/4758 type extreme finite geometry 20261013s1215 — PENDING CONFLICT
- [#8347](https://github.com/storytold/photocraft/pull/8347) Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment) — INTEGRATED
- [#9478](https://github.com/storytold/photocraft/pull/9478) fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470) — INTEGRATED
- [#9523](https://github.com/storytold/photocraft/pull/9523) Fix Type on a Path wrapping and caret positioning across closed-path seams — INTEGRATED
- [#9594](https://github.com/storytold/photocraft/pull/9594) fix(engine): copied type keeps its size across resolutions after an edit — INTEGRATED
### UI and workflow (26)
- [#3345](https://github.com/storytold/photocraft/pull/3345) Add grouped gradient presets and selectable style icons — PENDING CONFLICT
- [#3520](https://github.com/storytold/photocraft/pull/3520) fix(ui): restore square color chips in Studio Dark theme — INTEGRATED
- [#4023](https://github.com/storytold/photocraft/pull/4023) i18n(es): add 548 missing rows, fix five filter and video terms — PENDING CONFLICT
- [#4042](https://github.com/storytold/photocraft/pull/4042) feat(canvas): rotate view with Shift + middle-mouse drag — PENDING CONFLICT
- [#4547](https://github.com/storytold/photocraft/pull/4547) fix(icons): PhotoCraft icon corner radius matches other Craft apps (#4478) — INTEGRATED
- [#4883](https://github.com/storytold/photocraft/pull/4883) feat(ui): show selection and active-layer bounds in the status bar — INTEGRATED
- [#4890](https://github.com/storytold/photocraft/pull/4890) Add scrollbar visibility preferences (#3262) — PENDING CONFLICT
- [#4938](https://github.com/storytold/photocraft/pull/4938) Layer Style: one dialog size on every page, and it fits short screens — INTEGRATED
- [#5010](https://github.com/storytold/photocraft/pull/5010) Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938] — INTEGRATED
- [#5270](https://github.com/storytold/photocraft/pull/5270) Layer Style: Gradient Overlay takes any number of colour stops (#5177) — INTEGRATED
- [#5744](https://github.com/storytold/photocraft/pull/5744) fix(ui): keep detached documents in one interactive window — INTEGRATED
- [#6449](https://github.com/storytold/photocraft/pull/6449) Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop — INTEGRATED
- [#7255](https://github.com/storytold/photocraft/pull/7255) fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223) — INTEGRATED
- [#8892](https://github.com/storytold/photocraft/pull/8892) ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte — INTEGRATED
- [#8985](https://github.com/storytold/photocraft/pull/8985) Color panel and Color Picker: clip the round cursor to the colour field — INTEGRATED
- [#9004](https://github.com/storytold/photocraft/pull/9004) fix(ui): enlarge Move tool alignment icons for legibility — INTEGRATED
- [#9172](https://github.com/storytold/photocraft/pull/9172) Gradient Fill layer: default to Foreground to Transparent (#8708) — INTEGRATED
- [#9282](https://github.com/storytold/photocraft/pull/9282) feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer — INTEGRATED
- [#9354](https://github.com/storytold/photocraft/pull/9354) fix(ui): Full Screen Mode needs a document (#9349) — INTEGRATED
- [#9481](https://github.com/storytold/photocraft/pull/9481) fix(ui): new layer effects start from the Make Default values — INTEGRATED
- [#9504](https://github.com/storytold/photocraft/pull/9504) fix(ui): Keyboard Shortcuts records ⌘Z instead of undoing the capture — INTEGRATED
- [#9517](https://github.com/storytold/photocraft/pull/9517) fix(ui): Layer Style scrolls on a short window so OK stays on screen — INTEGRATED
- [#9597](https://github.com/storytold/photocraft/pull/9597) fix(ui): the layer list doesn't move with no layer selected — INTEGRATED
- [#9598](https://github.com/storytold/photocraft/pull/9598) fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon — INTEGRATED
- [#9606](https://github.com/storytold/photocraft/pull/9606) fix(ui): the Color panel remembers its options across restarts — INTEGRATED
- [#9703](https://github.com/storytold/photocraft/pull/9703) Fix adjustment layer name clicks targeting masks — INTEGRATED
## Pending conflict files and exact source deltas
The excerpts below are PR changes from GitHub-fetched heads, not approved resolutions. They supplement the decisions above.
### #9703: Fix adjustment layer name clicks targeting masks
`crates/ui-egui/src/panels.rs`
```diff
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b86d7b2af..5d9e8bd97 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -3490,8 +3490,7 @@ fn layer_row(
let mode = if m.command && on_thumb && doc.is_background(l.id) { "replace" } else { select_mode(m) };
actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode})));
crate::smart_ui::deselect(ctx, doc.id);
- // A thumbnail explicitly selects content or a mask. Selecting an adjustment/fill row
- // defaults to its mask, but clicking its content thumbnail must leave the mask target.
+ // Adjustment names target contents; fill rows keep their existing mask default.
let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p));
let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector);
// Clicking the layer thumbnail leaves mask view (#196).
@@ -3500,19 +3499,20 @@ fn layer_row(
let mask = if v.filter { "filter" } else { "layer" };
actions.push((photocraft_engine::mask_view_cmds::ID.into(), json!({"layer": l.id.0, "mode": "off", "mask": mask})));
}
- let content_less = matches!(l.content, LayerContent::Adjustment(_) | LayerContent::Fill(_));
+ let adjustment = matches!(l.content, LayerContent::Adjustment(_));
+ let fill_mask = matches!(l.content, LayerContent::Fill(_)) && l.mask.is_some();
if on_vector {
// The vector mask thumbnail targets the vector mask: the path tools edit it and the
// Paths panel selects the layer's path.
app.ui.selected_path = Some("layer".into());
actions.push(("ui.vectorMaskTarget".into(), json!(true)));
- } else if on_mask || (content_less && l.mask.is_some() && !on_thumb) {
+ } else if on_mask || (fill_mask && !on_thumb) {
if on_mask {
// The mask thumbnail also switches Properties to the mask (#3369).
app.ui.properties_layer = None;
}
actions.push(("ui.maskTarget".into(), json!(true)));
- } else if on_thumb || !row.primary {
+ } else if on_thumb || adjustment || !row.primary {
actions.push(("ui.maskTarget".into(), json!(false)));
}
// ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does.
```
### #9606: fix(ui): the Color panel remembers its options across restarts
`crates/ui-egui/src/panels.rs`, `crates/ui-egui/src/prefs_ui.rs`, `docs/scorecard.md`
```diff
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b86d7b2af..1f76a0f90 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -1868,6 +1868,7 @@ pub fn dock_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui));
crate::dock::persist(app, ui.ctx());
+ crate::color_panel_ui::persist(app);
}
/// The docking layout's protected canvas panel: the document area, with the icon rail on its end
diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
index 68ebf5bd9..2369b38fd 100644
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -250,6 +250,7 @@ pub fn load(app: &mut PhotocraftApp) {
}
}
crate::dock::restore(app);
+ crate::color_panel_ui::restore(app);
crate::brush_picker::restore(app);
crate::last_brush::restore(app);
sync_rulers(app);
```
### #9598: fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon
`ATTRIBUTION.md`
### #9597: fix(ui): the layer list doesn't move with no layer selected
`crates/ui-egui/src/panels.rs`
```diff
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b86d7b2af..c1be879f0 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -2626,56 +2626,59 @@ fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool)
/// greyed out at Normal, 100% and unlocked, instead of removing them (#8880). Same layout as the
/// active layer's rows in `layers`.
fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) {
- ui.add_enabled_ui(false, |ui| {
- ui.horizontal(|ui| {
- let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") };
- let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
- let mut m = BlendMode::Normal;
- let options = blend_options(false, m);
- widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width);
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- let mut o = 100.0;
- widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
- if show_opacity_label {
- scrub_pct_label(ui, opacity_label, &mut o, ui.available_width());
- }
- });
- });
- ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
- ui.horizontal(|ui| {
- let lock_label = if pro { tl!("Lock:") } else { tl!("Lock") };
- let lang = crate::i18n::current();
- let fill_label = if pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") };
- let gap = ui.spacing().item_spacing.x;
- let icons_w = if pro { 5.0 * 20.0 + gap } else { 22.0 + gap };
- let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width();
- if fits {
- label(ui, lock_label);
- }
- if pro {
- ui.scope(|ui| {
- ui.spacing_mut().item_spacing.x = 0.0;
- for (icon, tip) in [
- ("grid-3x3", tl!("Lock transparent pixels")),
- ("brush", tl!("Lock image pixels")),
- ("move", tl!("Lock position")),
- ("scan", tl!("Prevent auto-nesting in and out of Artboards and Frames")),
- ("lock", tl!("Lock all")),
- ] {
- icons::button(ui, icon, 20.0, false, tip);
- }
- });
- } else {
- icons::button(ui, "lock-open", 22.0, false, tl!("Lock layer"));
+ // Each row is disabled on its own and laid out as the active layer's rows are: a disabled
+ // wrapper around both added an item gap under the Fill row, which pushed the layer list down
+ // in the themes whose docks keep item spacing (#9546).
+ ui.horizontal(|ui| {
+ ui.disable();
+ let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") };
+ let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
+ let mut m = BlendMode::Normal;
+ let options = blend_options(false, m);
+
```
### #9590: fix(io): PSD layer-effect contours keep at most 16 points
`crates/io/src/effects_map.rs`
```diff
diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs
index 416aa56b5..f47248bca 100644
--- a/crates/io/src/effects_map.rs
+++ b/crates/io/src/effects_map.rs
@@ -122,14 +122,48 @@ fn contour(d: &Descriptor, key: &str) -> Contour {
let linear = points.len() == 2 && points[0].input == 0.0 && points[0].output == 0.0 && points[1].input == 1.0 && points[1].output == 1.0;
if points.is_empty() || (linear && (name.is_empty() || name == "Linear")) { Contour::Linear } else { Contour::Custom { name, points, corners } }
}
+/// The most points Photoshop reads in an effect's contour (`ShpC` › `Crv `): a layer whose effects
+/// carry a longer one fails to load ("A problem was encountered reading layer … program error"),
+/// and Photoshop then drops all of its effects; 16 loads (#9556).
+const PSD_CONTOUR_POINTS: usize = 16;
+
+/// Indices of the contour points a PSD keeps, in order: all of them up to [`PSD_CONTOUR_POINTS`],
+/// else both ends and then, one at a time, the point farthest (in output) from the line between
+/// the kept points around it, so peaks and valleys (Ring - Double's two rings) survive.
+fn psd_contour_points(points: &[CurvePoint]) -> Vec<usize> {
+ if points.len() <= PSD_CONTOUR_POINTS {
+ return (0..points.len()).collect();
+ }
+ let mut kept = vec![0, points.len() - 1];
+ while kept.len() < PSD_CONTOUR_POINTS {
+ let mut farthest: Option<(f32, usize)> = None;
+ for pair in kept.windows(2) {
+ let &[a, b] = pair else { continue };
+ let (Some(pa), Some(pb)) = (points.get(a), points.get(b)) else { continue };
+ let span = pb.input - pa.input;
+ for (i, p) in points.iter().enumerate().take(b).skip(a + 1) {
+ // Only equal inputs (a vertical step) have no span: inputs a few ulps apart still do.
+ let t = if span != 0.0 && span.is_finite() { (p.input - pa.input) / span } else { 0.0 };
+ let off = (p.output - (pa.output + t * (pb.output - pa.output))).abs();
+ if off.is_finite() && farthest.is_none_or(|(f, _)| off > f) {
+ farthest = Some((off, i));
+ }
+ }
+ }
+ let Some((_, i)) = farthest else { break };
+ kept.insert(kept.partition_point(|&k| k < i), i);
+ }
+ kept
+}
+
fn contour_value(c: &Contour) -> Value {
let (name, points) = match c {
Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]),
Contour::Custom { name, points, .. } => (name.clone(), points.clone()),
};
- let pts = points
- .iter()
- .enumerate()
+ let pts = psd_contour_points(&points)
+ .into_iter()
+ .filter_map(|i| points.get(i).map(|p| (i, p)))
.map(|(i, p)| {
let d = Descriptor::new("CrPt").with("Hrzn", Value::Double(f64::from(p.input * 255.0))).with("Vrtc", Value::Double(f64::from(p.output * 255.0)));
// Photoshop writes no `Cnty` (continuity) on smooth points; a corner carries `Cnty` false.
@@ -644,6 +678,47 @@ pub fn parse_lrfx(data: &[u8]) -> Option<(bool, Vec<Effect>)> {
mod tests {
use super::*;
+ fn crv(v: &Value) -> Vec<(f64, f64)> {
+ let Value::Descriptor(d) = v else { panic!("ShpC") };
+ let Some(Value::List(pts)) = d.get("Crv ") else { panic!("Crv ") };
+ pts.iter()
+ .map(|p| {
+ let V
```
### #9589: fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity
`crates/compose/src/lib.rs`, `crates/gpu/src/plan.rs`
```diff
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 0a3f5c480..547125999 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1808,13 +1808,18 @@ thread_local! {
/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
/// every blend mode's source-over result is linear in the source alpha.
fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
- // "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
- // judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend
- // Ifs: a group's own colour re-composites its children, which doubles the work at every
- // level of Blend If groups inside one another.
+ // "This Layer" is the layer's own colour. An adjustment layer (no content of its own) is
+ // judged by its result at full strength, before Opacity and its mask mix it in, as Photoshop
+ // does (#9542). Groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend Ifs are judged by
+ // the composite: a group's own colour re-composites its children, which doubles the work at
+ // every level of Blend If groups inside one another.
let is_group = matches!(layer.content, LayerContent::Group(_));
let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get);
- let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) {
+ let own = if let LayerContent::Adjustment(adj) = &layer.content {
+ let mut result = before.clone();
+ cx.apply_adjustment(adj, &mut result);
+ Some(result)
+ } else if is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH {
None
} else {
BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group)));
diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs
index 38cc879b0..33d4bf76f 100644
--- a/crates/gpu/src/plan.rs
+++ b/crates/gpu/src/plan.rs
@@ -410,7 +410,8 @@ pub const F_QUANT: u32 = 32768;
/// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups).
pub const F_ADD_DIFF: u32 = 16384;
/// `Lerp`: B mixed back towards A (the backdrop) by the layer's Blend If weight
-/// (`compose::apply_blend_if`), from C ("This Layer", or B itself for an adjustment layer, `p4.y`)
+/// (`compose::apply_blend_if`), from C ("This Layer": an adjustment layer's unmixed result; B when
+/// `p4.y` is set)
/// and A ("Underlying Layer"). The ranges are packed in `p0..p3` (`blend_if_params`); `p4.x` marks
/// a one-channel document.
pub const F_BLEND_IF: u32 = 524288;
@@ -563,13 +564,17 @@ impl<'a> Planner<'a> {
/// `compose::apply_blend_if`: `after` (the backdrop with `layer` drawn) mixed back towards
/// `before` (the backdrop without it) where the layer's Blend If ranges hide it. "This Layer"
- /// is the layer's own content with its masks; an adjustment layer is judged by its result.
- /// Consumes both slots.
+ /// is the layer's own content with its masks; an adjustment layer is judged by its result at
+ /// full strength, before Opacity and its mask (#9542). Consumes both slots.
fn blend_if(&mut self, layer: &'a Layer, before: Slot, after: Slot) -> Result<Slot, Unsupported> {
- let own = match layer.content {
- LayerContent::Adjustment(_) => None,
- _ => Some(self.content(layer)?),
+ let own = match &layer.content {
+ LayerCont
```
### #9523: Fix Type on a Path wrapping and caret positioning across closed-path seams
`crates/ui-egui/src/type_tool.rs`
```diff
diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs
index e8fdbe95d..7139e693a 100644
--- a/crates/ui-egui/src/type_tool.rs
+++ b/crates/ui-egui/src/type_tool.rs
@@ -192,12 +192,12 @@ pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) ->
let (tx, ty) = to_text(&aff, x, y);
// Type on a path: the point's place along the path is the position in the (single) line.
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
- let (tx, ty) = match shape {
- Some(photocraft_doc::text::TextShape::Path { path, start, flip }) => {
- photocraft_text::warp::arc_length_at(&path, flip, f64::from(tx), f64::from(ty)).map_or((tx, ty), |s| ((s - f64::from(start)) as f32, -1.0))
- }
- _ => (tx, ty),
- };
+ let text_start = l.bounds().map_or(0.0, |bounds| f64::from(bounds[0]));
+ let (tx, ty) = shape
+ .as_ref()
+ .and_then(|shape| photocraft_text::warp::Warp::on_path_with_text_start(shape, text_start))
+ .and_then(|warp| warp.path_text_x_near_text(f64::from(tx), f64::from(ty), l.bounds().map_or(0.0, |bounds| f64::from(bounds[2]))))
+ .map_or((tx, ty), |offset| (offset as f32, -1.0));
photocraft_text::hit_char(&l, &text, tx, ty).0
}
@@ -1297,7 +1297,8 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
};
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
// Type on a path: the caret, selection and underline follow the curve the glyphs sit on.
- let bend = shape.as_ref().and_then(photocraft_text::warp::Warp::on_path);
+ let text_start = l.bounds().map_or(0.0, |bounds| f64::from(bounds[0]));
+ let bend = shape.as_ref().and_then(|shape| photocraft_text::warp::Warp::on_path_with_text_start(shape, text_start));
// Line space (lines, clusters, carets) → screen: vertical type turns it 90° clockwise.
let scr = |x: f32, y: f32| -> Pos2 {
let (x, y) = l.to_text(x, y);
@@ -3255,6 +3256,109 @@ mod tests {
assert!(app.ui.text_edit.is_none());
}
+ // Independent perimeter oracle for the rectangle used by the endpoint hit tests.
+ fn closed_path_endpoint_test_point(s: f64) -> (f64, f64) {
+ if s < 200.0 {
+ (20.0 + s, 20.0)
+ } else if s < 300.0 {
+ (220.0, 20.0 + s - 200.0)
+ } else if s < 500.0 {
+ (220.0 - (s - 300.0), 120.0)
+ } else {
+ (20.0, 120.0 - (s - 500.0))
+ }
+ }
+
+ #[test]
+ fn closed_path_forward_hit_after_last_caret() {
+ for align in ["left", "center", "right"] {
+ let mut app = app();
+ app.ui.tool_options.type_align = align.into();
+ let path = photocraft_vector::shapes::rect(20.0, 20.0, 200.0, 100.0);
+ app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap();
+ pointer_up(&mut app, [20.0, 45.0], [20.0, 45.0]);
+ insert(&mut app, "ssssssssssss");
+ let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
+ let (l, _, _) = layout(&mut app, id).unwrap();
+ let (last, _, _) = l.caret(12);
+ let s = (575.0 + f64::from(last) + 2.0).rem_euclid(600.0);
+ let (x, y) = closed_path_endpoint_test_point(s);
+ assert_eq!(offset_at(&mut app, id, x, y), 12, "{align}");
+
```
### #9517: fix(ui): Layer Style scrolls on a short window so OK stays on screen
`crates/ui-egui/src/dialogs.rs`
```diff
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 2c683bce3..f91d5208c 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -503,7 +503,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
DialogKind::Command if crate::new_guide_ui::owns(&fields) => crate::new_guide_ui::body(ui, &mut fields),
DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields),
DialogKind::Command => {}
- DialogKind::LayerStyle => crate::layer_style::body(app, ui, &mut fields),
+ DialogKind::LayerStyle => {
+ // Bevel & Emboss is taller than a small window: the pages scroll, as a long
+ // filter dialog does, so OK and Cancel stay on screen (#9515).
+ let room = (ctx.content_rect().height() - DIALOG_CHROME).max(120.0);
+ egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::layer_style::body(app, ui, &mut fields));
+ }
DialogKind::Error => {
ui.label(fields.get("message").and_then(Value::as_str).unwrap_or("Error"));
}
@@ -1167,6 +1172,25 @@ mod tests {
}
}
+ /// #9515: Bevel & Emboss is taller than a small window (1435 × 858, the reporter's): its page
+ /// scrolls, so OK and Cancel stay on screen.
+ #[test]
+ fn layer_style_buttons_stay_on_a_short_window() {
+ use egui_kittest::{Harness, kittest::Queryable};
+
+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap();
+ app.run("layer.new.layer", serde_json::json!({})).unwrap();
+ let mut h = Harness::builder().with_size(egui::vec2(1435.0, 858.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app);
+ PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
+ crate::layer_style::open(h.state_mut(), Some("bevelEmboss")).unwrap();
+ h.run_steps(4);
+ for label in ["OK", "Cancel"] {
+ let r = h.get_by_label(label).rect();
+ assert!(r.bottom() <= 858.0, "{label} is off the window: {r:?}");
+ }
+ }
+
#[test]
fn layer_style_title_gutters_can_start_drags() {
use egui_kittest::{Harness, kittest::Queryable};
```
### #9512: fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes
`crates/compose/src/lib.rs`, `crates/gpu/src/plan.rs`
```diff
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 4786bc191..2c8d01a7a 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1065,8 +1065,10 @@ fn branchless_floor(x: f32) -> f32 {
}
/// Blend an adjustment layer's result (`adjusted`) back into the backdrop it was made from, each
-/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode`.
-fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode) {
+/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode` at layer Fill `fill`: below
+/// 100% the special eight ([`psblend::fill_is_special`]) blend a result pulled toward the mode's
+/// neutral colour, as a pixel layer does (#9501); for every other mode Fill is in `ks`.
+fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode, fill: f32) {
for ((b, a), k) in backdrop.iter_mut().zip(adjusted).zip(ks) {
if k <= 0.0 {
continue;
@@ -1074,7 +1076,7 @@ fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterato
let s = [a[0], a[1], a[2]];
// Normal is the source as it is (`blend_rgb` gives exactly that), and most adjustment
// layers are Normal: no per-pixel mode dispatch for them.
- let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb(mode, [b[0], b[1], b[2]], s) };
+ let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb_fill(mode, [b[0], b[1], b[2]], s, fill) };
*b = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
}
}
@@ -2014,9 +2016,13 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
}
}
} else {
+ // Fill below 100% in one of the special eight changes the blend itself and leaves
+ // coverage to Opacity, as for a pixel layer (psblend::composite_fill, #9501).
+ let (k, fill) =
+ if layer.fill_opacity < 1.0 && psblend::fill_is_special(layer.blend) { (layer.opacity, layer.fill_opacity) } else { (opacity, 1.0) };
match &mv {
- Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| opacity * v), layer.blend),
- None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(opacity), layer.blend),
+ Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| k * v), layer.blend, fill),
+ None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill),
}
}
recycle_buffer(adjusted);
diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs
index 38cc879b0..157667971 100644
--- a/crates/gpu/src/plan.rs
+++ b/crates/gpu/src/plan.rs
@@ -742,7 +742,7 @@ impl<'a> Planner<'a> {
p.a = Some(before);
p.b = Some(adjusted);
p.mode = layer.blend;
- p.opacity = opacity;
+ (p.opacity, p.fill) = adjustment_fill(layer, opacity);
p.mask = self.mask_use(layer);
return Ok(self.emit(p));
}
@@ -999,7 +999,9 @@ impl<'a> Planner<'a> {
p.a = Some(before);
p.b = Some(adjusted);
p.mode = layer.blend;
- p.opacity = opacity;
+ // Clipped, an adjustment takes Fill a
```
### #9481: fix(ui): new layer effects start from the Make Default values
`crates/ui-egui/src/dialogs/layer_style.rs`
```diff
diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs
index b65487c4f..7051c57be 100644
--- a/crates/ui-egui/src/layer_style.rs
+++ b/crates/ui-egui/src/layer_style.rs
@@ -64,6 +64,9 @@ pub const KINDS: &[(&str, &str)] = &[
/// Effect kinds Photoshop offers several instances of (stored as `…Multi` lists in the PSD).
const MULTI: &[&str] = &["stroke", "dropShadow", "innerShadow", "colorOverlay", "gradientOverlay"];
+/// Field holding the user defaults (Make Default) that new instances start from.
+const USER_DEFAULTS: &str = "userDefaults";
+
fn multi(kind: &str) -> bool {
MULTI.contains(&kind)
}
@@ -76,9 +79,15 @@ fn defaults(kind: &str) -> Value {
photocraft_engine::layer_style::effect_defaults(kind)
}
-/// Factory params for a new instance, with the shared light angle filled in.
-fn fresh_params(kind: &str, light: f32) -> Value {
+/// Params for a new instance: the user default saved by Make Default over the factory
+/// defaults (#9477), with the shared light angle filled in.
+fn fresh_params(kind: &str, light: f32, user: Option<&Value>) -> Value {
let mut p = defaults(kind);
+ if let (Some(o), Some(u)) = (p.as_object_mut(), user.and_then(Value::as_object)) {
+ for (k, v) in u {
+ o.insert(k.clone(), v.clone());
+ }
+ }
if p.get("useGlobalLight").and_then(Value::as_bool) == Some(true) {
p["angle"] = json!(light);
}
@@ -461,7 +470,8 @@ fn next_id(f: &Map<String, Value>) -> String {
fn add_instance(f: &mut Map<String, Value>, kind: &str) {
let light = f.get("pendingGlobalLight").or_else(|| f.get("globalLight")).and_then(Value::as_f64).unwrap_or(120.0) as f32;
let id = next_id(f);
- let new = json!({"id": id, "kind": kind, "on": true, "params": fresh_params(kind, light)});
+ let user = f.get(USER_DEFAULTS).and_then(|d| d.get(kind));
+ let new = json!({"id": id, "kind": kind, "on": true, "params": fresh_params(kind, light, user)});
if let Some(a) = f.get_mut("effects").and_then(Value::as_array_mut) {
let pos = a.iter().rposition(|e| e.get("kind").and_then(Value::as_str) == Some(kind)).map_or(a.len(), |i| i + 1);
a.insert(pos, new);
@@ -525,7 +535,13 @@ pub fn drag_shadow(f: &mut Map<String, Value>, start: bool, total: [f64; 2]) {
}
pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map<String, Value> {
+ initial_fields_with(layer, select, light, Map::new())
+}
+
+/// The dialog's fields, with the user defaults (`kind` → params) new instances start from.
+pub fn initial_fields_with(layer: &Layer, select: Option<&str>, light: f32, user_defaults: Map<String, Value>) -> Map<String, Value> {
let mut f = Map::new();
+ f.insert(USER_DEFAULTS.into(), Value::Object(user_defaults));
f.insert("layer".into(), json!(layer.id.0));
f.insert("globalLight".into(), json!(light));
f.insert("preview".into(), json!(true));
@@ -581,7 +597,8 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option<u64> {
let layer = st.doc.layer(st.active_layer?)?.clone();
let light = st.doc.global_light.angle;
let mode = st.doc.mode;
- let mut f = initial_fields(&layer, select, light);
+ let user = app.session.presets.layer_defaults.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
+ let mut f = initial_fields_with(&layer, select, light, user);
// Channels and Blend If list the document's own colour channels.
set_blending_field
```
### #9478: fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470)
`crates/text/src/layout.rs`, `crates/text/src/psd.rs`
```diff
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 4936b33c6..a4ec1d350 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -10,8 +10,10 @@
//! into text space: a line becomes a column running top to bottom, and successive columns advance
//! right to left. Within a column, CJK characters stay upright (centred on the column, placed with
//! the font's vertical metrics: `VORG`, `vhea`/`vmtx`, else the ideographic em box) and use the
-//! font's vertical alternates (`vert`) for punctuation, brackets and the long-vowel mark; other
-//! text (Latin, digits) runs rotated 90° clockwise, Photoshop's default. Lines, clusters, carets
+//! font's vertical alternates (`vert`) for punctuation, brackets and the long-vowel mark. Other
+//! text (Latin, digits) stands upright one character per em too, as Photoshop's Vertical Type tool
+//! sets it, or runs rotated 90° clockwise where its style says so (`CharStyle::rotate_roman`,
+//! PSD `BaselineDirection` 2; #9470). Lines, clusters, carets
//! and hit tests stay in line space; [`TextLayout::to_text`] / [`TextLayout::to_line`] convert.
use std::borrow::Cow;
@@ -608,8 +610,30 @@ impl Layouter {
let fallback: Vec<String> = fonts.fallback_stack().map(str::to_string).collect();
let si0 = style_at(prange.start);
let si_end = run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(out.styles.len());
- // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px).
- let build = |lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout<RunBrush> {
+ // Vertical type's upright roman characters (#9470), as byte ranges in `ptext` with their
+ // letter spacing (px) and em (px): each takes one em down the column, as Photoshop
+ // sets them, where shaping gives it its horizontal advance.
+ let mut roman: Vec<(Range<usize>, f32, f32, usize)> = Vec::new();
+ if vertical {
+ for (ri, st) in out.styles.iter().enumerate().take(si_end).skip(si0) {
+ let rs = run_starts[ri];
+ let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end));
+ if a >= z || st.rotate_roman {
+ continue;
+ }
+ let base = a - prange.start + prefix.len();
+ let piece = ptext.get(base..base + (z - a)).unwrap_or("");
+ let px = st.size_pt * k;
+ for (i, ch) in piece.char_indices() {
+ if upright_roman(ch) && !ch.is_whitespace() {
+ roman.push(((base + i)..(base + i + ch.len_utf8()), st.tracking / 1000.0 * px, px, ri));
+ }
+ }
+ }
+ }
+ // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px)
+ // and each upright roman character by its spacing in `roman_spacing` (px).
+ let build = |lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32], roman_spacing: &[f32]| -> Layout<RunBrush> {
let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false);
// Paragraph-start style as the default (covers the direction mark and empty
// paragraphs), then every run piece
```
### #9401: fix(format): a save on macOS keeps the file's creation time (#9388)
`crates/automation/src/workspace.rs`
```diff
diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs
index e8566b439..e3316a079 100644
--- a/crates/automation/src/workspace.rs
+++ b/crates/automation/src/workspace.rs
@@ -101,7 +101,10 @@ impl AuthorizedWorkspace {
// Best effort, and only when there is a file being replaced: a save still
// succeeds for a document whose Finder tag could not come along.
if let Ok(old) = root.dir.open(&relative) {
+ let old = old.into_std();
let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
+ // The creation time too (#9388), through the same handles.
+ let _ = photocraft_format::atomic::meta::copy_created_file(&old, &file);
}
}
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
@@ -608,6 +611,22 @@ mod tests {
assert!(temp_files(&inside).is_empty());
}
+ /// #9401 review: replacing a file through the automation path keeps its creation time, the
+ /// same as a save through `atomic_write` does on macOS (#9388).
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn write_keeps_the_replaced_files_creation_time() {
+ use std::os::macos::fs::FileTimesExt;
+ let (inside, _, workspace) = roots("created");
+ let path = inside.join("doc.psd");
+ workspace.write("doc.psd", b"original").unwrap();
+ let born = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000);
+ std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_created(born)).unwrap();
+ workspace.write("doc.psd", b"replacement").unwrap();
+ assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
+ assert_eq!(std::fs::metadata(&path).unwrap().created().unwrap(), born, "the creation time survives the automation write");
+ }
+
/// #7095: replacing a file through the automation path keeps the old file's extended
/// attributes, the same as a save through `atomic_write` does.
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
```
### #9354: fix(ui): Full Screen Mode needs a document (#9349)
`crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels.rs`, `crates/ui-egui/src/view_cmds.rs`
```diff
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 4db3dc2a3..dbb9dd165 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2297,6 +2297,7 @@ impl eframe::App for PhotocraftApp {
}
self.drain_control(ctx);
native_menu::run(self, ctx);
+ view_cmds::leave_full_screen_without_document(self, ctx);
// Switching tools commits, also between the Type and Type Mask tools.
if self.ui.text_edit.as_ref().is_some_and(|ed| !self.ui.tool.is_type() || ed.mask != self.ui.tool.is_type_mask()) {
type_tool::commit(self);
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b6bd34d9d..aa35d04a4 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -336,7 +336,14 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
{
let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({}));
}
- let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips);
+ // Greyed out while Cycle is unavailable (no document in Standard, #9349): its
+ // list would offer nothing else either.
+ let cycle_enabled = crate::view_cmds::is_enabled(app, "view.screenMode.cycle").unwrap_or(true);
+ let sm = ui
+ .add_enabled_ui(cycle_enabled, |ui| {
+ icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips)
+ })
+ .inner;
paint_toolbar_flyout_indicator(ui, &t, &sm);
// Right-click, or click and hold like the tool flyouts (#4444), lists the modes,
@@ -376,7 +383,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
("view.screenMode.fullScreen", tl!("Full Screen Mode")),
] {
let on = crate::view_cmds::checked(app, id).unwrap_or(false);
- if ui.add(egui::Button::selectable(on, label)).clicked() {
+ let enabled = crate::view_cmds::is_enabled(app, id).unwrap_or(true);
+ if ui.add_enabled(enabled, egui::Button::selectable(on, label)).clicked() {
let _ = crate::menus::invoke(app, &ctx, id, json!({}));
ui.close();
}
@@ -6048,11 +6056,54 @@ mod type_flyout_tests {
assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_none());
}
+ /// #9354 review: with no document in Standard Screen Mode, Cycle is unavailable (#9349), so
+ /// Change Screen Mode is greyed out: a click sends no viewport command.
+ #[test]
+ fn screen_mode_button_is_inert_without_a_document() {
+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", serde_json::json!({"width": 64, "height": 48})).unwrap();
+ let ctx = egui::Context::default();
+ PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
+ frame(&mut app, &ctx, 0.0, vec![]);
+ frame(&mut app, &ctx, 0.1, vec![]);
+ let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
+ // Found w
```
### #9342: fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325)
`crates/engine/src/web_cmds.rs`
```diff
diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs
index 3738aa6ca..5e2694325 100644
--- a/crates/engine/src/web_cmds.rs
+++ b/crates/engine/src/web_cmds.rs
@@ -900,7 +900,10 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
let (wdoc, sx, sy) = web_document(&doc, p, &st)?;
let buf = photocraft_compose::flatten(&wdoc);
let bw = wdoc.size.width as usize;
- let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice());
+ // An untagged document is treated as sRGB (see [`web_document`]); with Embed Color Profile
+ // on, the file says so, as Photoshop's does (#9325).
+ let srgb = (st.embed_icc && wdoc.icc_profile.is_none()).then(|| photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec());
+ let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()).or(srgb.as_deref());
let xmp = web_xmp(&doc, st.metadata);
let exif = web_exif(&doc, st.metadata);
let dpi = wdoc.resolution_dpi;
```
### #9339: fix(export): embed requested sRGB profile for untagged sources (#9325)
`crates/ui-egui/src/dialogs/export_dialog.rs`
```diff
diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs
index 29ee74b1a..06490f096 100644
--- a/crates/ui-egui/src/export_dialog.rs
+++ b/crates/ui-egui/src/export_dialog.rs
@@ -6,7 +6,8 @@ use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Mutex;
-use photocraft_doc::Document;
+use photocraft_cms::Profile;
+use photocraft_doc::{ColorMode, Document};
use serde_json::{Map, Value, json};
use photocraft_engine::export_preset_cmds::{DEFAULT_RESAMPLE, RESAMPLE, export_resample};
@@ -259,6 +260,14 @@ fn source_document(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<Arc<Do
source_document_from(app.session.active().ok_or("no document")?.doc.clone(), f)
}
+fn effective_source_profile(app: &PhotocraftApp, doc: &Document) -> Arc<Profile> {
+ doc.icc_profile
+ .as_ref()
+ .and_then(|bytes| photocraft_engine::color_cmds::profile_from_bytes(bytes).ok())
+ .filter(|profile| profile.color_space == photocraft_engine::color_cmds::mode_space(doc.mode))
+ .unwrap_or_else(|| app.session.color.working(doc.mode))
+}
+
fn s(f: &Map<String, Value>, k: &str) -> String {
f.get(k).and_then(Value::as_str).unwrap_or_default().to_string()
}
@@ -285,11 +294,16 @@ fn nearest(f: &Map<String, Value>) -> bool {
/// The document as exported: scaled (engine Image Size, with the dialog's Resample method) and
/// flattened over white when transparency is off or the format has no alpha.
-fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>) -> Result<Document, String> {
+fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>, source_profile: &Profile) -> Result<Document, String> {
let resample = resample_method(f, max_side)?;
+ let srgb = s(f, "profile") == "srgb";
+ let mut copy = doc.clone();
+ if srgb {
+ copy.icc_profile = Some(source_profile.to_bytes());
+ }
let mut session = photocraft_engine::Session::new();
- session.add_document(doc.clone(), None);
- if s(f, "profile") == "srgb" && doc.icc_profile.is_some() {
+ session.add_document(copy, None);
+ if srgb && (doc.mode != ColorMode::Rgb || !source_profile.same_bytes(photocraft_cms::Builtin::Srgb.profile())) {
session.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "perceptual"})).map_err(|e| e.to_string())?;
}
let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, max_side)?;
@@ -441,8 +455,8 @@ fn settings(f: &Map<String, Value>) -> ExportSettings {
/// The preview and size estimate share a single reduced document. Resizing every layer twice is
/// especially expensive for layered PSDs, even when the canvas itself is small.
-fn preview_proxy(doc: &Document, f: &Map<String, Value>) -> Result<Document, String> {
- export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 }))
+fn preview_proxy(doc: &Document, f: &Map<String, Value>, source_profile: &Profile) -> Result<Document, String> {
+ export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 }), source_profile)
}
fn scaled_estimate(bytes: usize, doc: &Document, proxy: &Document, f: &Map<String, Value>) -> u64 {
@@ -476,8 +490,8 @@ fn preview_pixels(proxy: &Document, estimate: Option<u64>) -> PreviewPixels {
PreviewPixels { estimate, width: img.width as usize, height: img.height as usize, rgba: img.pixels }
}
-fn preview_sync(app: &PhotocraftApp, d
```
### #9282: feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer
`crates/ui-egui/src/adjust_dialog.rs`, `crates/ui-egui/src/adjust_editors.rs`, `crates/ui-egui/src/lib.rs`, `docs/ui-parity.md`
```diff
diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs
index 18e32e486..55826aea9 100644
--- a/crates/ui-egui/src/adjust_dialog.rs
+++ b/crates/ui-egui/src/adjust_dialog.rs
@@ -802,6 +802,54 @@ mod tests {
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
}
+ /// #9263, #9265, #9268, #9270: Levels, Exposure, Black & White and Channel Mixer open on the
+ /// Default preset; choosing another loads its settings (the preview follows) and an edit by
+ /// hand reads Custom.
+ #[test]
+ fn adjustment_preset_menus_load_presets_and_read_custom_after_an_edit() {
+ use egui_kittest::kittest::{NodeT, Queryable};
+ for (kind, label, key) in [
+ ("levels", "Increase Contrast 2", "inBlack"),
+ ("exposure", "Plus 1.0", "exposure"),
+ ("blackWhite", "Red Filter", "reds"),
+ ("channelMixer", "Black & White with Red Filter (RGB)", "monochrome"),
+ ] {
+ let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
+ PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
+ let id = open(h.state_mut(), &format!("image.adjustments.{kind}")).unwrap();
+ h.run_steps(4);
+ let preset = |h: &Harness<'_, PhotocraftApp>| {
+ h.query_all_by_role(egui::accesskit::Role::ComboBox).next().and_then(|n| n.accesskit_node().value().map(|s| s.to_string()))
+ };
+ assert_eq!(preset(&h).as_deref(), Some("Default"), "{kind} opens on Default");
+ h.query_all_by_role(egui::accesskit::Role::ComboBox).next().unwrap().click();
+ h.run_steps(2);
+ h.get_by_label(label).click();
+ h.run_steps(3);
+ assert_eq!(preset(&h).as_deref(), Some(label), "{kind}: the menu shows the preset");
+ let want = crate::adjust_presets::presets(kind, false)
+ .iter()
+ .find(|p| p.label == label)
+ .and_then(|p| crate::adjust_presets::settings(kind, p, false))
+ .unwrap();
+ let fields = &h.state().ui.dialogs[0].fields;
+ for (k, v) in want.as_object().expect("preset settings") {
+ assert_eq!(fields[k], *v, "{kind}: the preset's `{k}` is loaded");
+ }
+ assert_eq!(fields[key], want[key]);
+ let edit = match kind {
+ "levels" => ("gamma", json!(1.37)),
+ "exposure" => ("offset", json!(0.05)),
+ "blackWhite" => ("blues", json!(33)),
+ _ => ("gray", json!([90, 10, 0, 0])),
+ };
+ h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(edit.0.into(), edit.1);
+ h.run_steps(3);
+ assert_eq!(preset(&h).as_deref(), Some("Custom"), "{kind}: an edit is Custom");
+ crate::dialogs::confirm(h.state_mut(), id).unwrap();
+ }
+ }
+
#[test]
fn curves_pointer_drag_previews_until_confirm_and_cancel_preserves_document() {
for confirm in [false, true] {
diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs
index 43217a10f..566309228 100644
--- a/crates/ui-egui/src/adjust_editors.rs
+++ b/crates/ui-egui/src/adjust_editors.rs
@@ -112,6 +112,43 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &E
```
### #9019: fix(xtask): reject unknown performance benchmark selections
`xtask/src/perf.rs`
```diff
diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs
index c1890d1cb..f1aa01671 100644
--- a/xtask/src/perf.rs
+++ b/xtask/src/perf.rs
@@ -670,6 +670,11 @@ pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> {
let o = parse_opts(rest)?;
let text = std::fs::read_to_string(root.join(BUDGETS)).map_err(|e| format!("read {BUDGETS}: {e}"))?;
let budgets = parse_budgets(&text)?;
+ for name in &o.benches {
+ if !budgets.bench.iter().any(|b| &b.name == name) {
+ return Err(format!("perf: unknown bench `{name}` (see {BUDGETS})"));
+ }
+ }
let mode = if o.quick { "quick" } else { "full" };
let threshold = o.threshold.unwrap_or(budgets.settings.regression_pct);
let target = target_dir()?;
@@ -1118,6 +1123,26 @@ not_measurable = "needs a frame harness"
assert_eq!(counts(&evaluate(&b, &reports, None, 15.0, true)), (1, 1, 0, 1), "the row the bench lacks counts as failing");
}
+ #[test]
+ fn unknown_bench_is_rejected_before_updating_the_baseline() {
+ let root = std::env::temp_dir().join(format!("photocraft-perf-unknown-bench-{}", std::process::id()));
+ std::fs::create_dir(&root).unwrap();
+ std::fs::create_dir(root.join("perf")).unwrap();
+ std::fs::write(root.join(BUDGETS), "[[bench]]\nname = \"known\"\npackage = \"unused\"\nrequires = \"missing.rs\"\n").unwrap();
+ let baseline = "{\"schema\":1,\"sentinel\":true}";
+ std::fs::write(root.join(BASELINE), baseline).unwrap();
+ let results = [
+ run(&root, &["--bench", "unknown", "--reuse", "--update-baseline"]),
+ run(&root, &["--bench", "known", "--bench", "unknown", "--quick", "--reuse", "--update-baseline"]),
+ ];
+ let after = std::fs::read_to_string(root.join(BASELINE)).unwrap();
+ std::fs::remove_dir_all(root).unwrap();
+ for result in results {
+ assert_eq!(result, Err(format!("perf: unknown bench `unknown` (see {BUDGETS})")));
+ }
+ assert_eq!(after, baseline, "an unknown bench must not update the baseline");
+ }
+
#[test]
fn options() {
assert!(parse_opts(&["--bogus"]).is_err());
```
### #8986: Path tools: edit the saved path selected in the Paths panel (#8478)
`crates/ui-egui/src/tools/direct_select.rs`, `crates/ui-egui/src/vector_ui.rs`
```diff
diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs
index 56036a852..2ab48a671 100644
--- a/crates/ui-egui/src/vector_ui.rs
+++ b/crates/ui-egui/src/vector_ui.rs
@@ -888,6 +888,8 @@ enum PathTarget {
Shape(u64),
/// The active layer's vector mask, when its Layers thumbnail is targeted (#196).
VectorMask(u64),
+ /// The saved path selected in the Paths panel, by name (#8478).
+ Saved(String),
Work,
}
@@ -923,8 +925,8 @@ pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool {
&& matches!(target_path(app), Some((PathTarget::Work, _)))
}
-/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, else
-/// the work path.
+/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, the
+/// saved path selected in the Paths panel (#8478), else the work path.
fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> {
if let Some((id, p)) = targeted_vector_mask(app) {
return Some((PathTarget::VectorMask(id), p));
@@ -932,6 +934,11 @@ fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> {
if let Some((id, p)) = active_shape_path(app) {
return Some((PathTarget::Shape(id), p));
}
+ if let Some(name) = app.ui.selected_path.as_deref()
+ && let Some((_, p)) = selected_saved_path_index(app)
+ {
+ return Some((PathTarget::Saved(name.to_string()), p));
+ }
app.session.active()?.doc.work_path.clone().map(|p| (PathTarget::Work, p))
}
@@ -945,6 +952,7 @@ pub(crate) fn free_transform_path(app: &PhotocraftApp) -> Option<(Value, Path)>
path.control_bounds()?;
let params = match target {
PathTarget::Shape(id) | PathTarget::VectorMask(id) => json!({"name": "layer", "layer": id}),
+ PathTarget::Saved(name) => json!({"name": name}),
PathTarget::Work => json!({"name": "work"}),
};
Some((params, path))
@@ -1049,6 +1057,10 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64
let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
app.run("layer.vectorMask.edit", json!({"layer": id, "path": photocraft_engine::vector_cmds::path_json(&moved)}))
}
+ PathTarget::Saved(name) => {
+ let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
+ app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)}))
+ }
PathTarget::Work => {
let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)}))
@@ -1165,6 +1177,13 @@ pub(crate) fn selected_saved_path(app: &PhotocraftApp) -> Option<&Path> {
app.session.active()?.doc.paths.iter().find(|p| p.name == sel).map(|p| &p.path)
}
+/// [`selected_saved_path`] with its index in the document's `paths`: the saved path Path
+/// Selection and Direct Selection edit (#8478).
+pub(crate) fn selected_saved_path_index(app: &PhotocraftApp) -> Option<(usize, Path)> {
+ let sel = app.ui.selected_path.as_deref().filter(|s| !matches!(*s, "work" | "layer"))?;
+ app.session.active()?.doc.paths.iter().enumerate().find(|(_, p)| p.name == sel).map(|(i, p)| (i, p.path.clone()))
+}
+
/// Work path / active shape path / selected saved path outlines, anch
```
### #8985: Color panel and Color Picker: clip the round cursor to the colour field
`crates/ui-egui/src/dialogs/color_picker_ui.rs`, `crates/ui-egui/src/panels.rs`
```diff
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b6bd34d9d..7c380130b 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -4191,8 +4191,10 @@ pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let (kx, ky) = if brightness { (hsva.h, 1.0 - hsva.s) } else { (hsva.s, 1.0 - hsva.v) };
let knob = pos2(field.left() + kx * field.width(), field.top() + ky * field.height());
- ui.painter().circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE));
- ui.painter().circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160)));
+ // At the extremes the knob sits on the edge: clip it to the field (#8968).
+ let painter = ui.painter().with_clip_rect(field);
+ painter.circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE));
+ painter.circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160)));
// The strip: hue (red at both ends, as Photoshop's) or brightness (the colour down to black).
let (strip, sresp) = ui.allocate_exact_size(vec2(strip_w, h), Sense::click_and_drag());
let strip_at = |f: f32| {
@@ -5237,6 +5239,36 @@ mod color_tests {
assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "hex field stays visible");
}
+ /// #8968: the round cursor sits on the field's edge at the extremes and must not paint past it
+ /// (over the hue strip or the chips).
+ #[test]
+ fn color_field_cursor_stays_inside_the_field() {
+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+ // Black is s = 0, v = 0: the bottom-left corner.
+ app.session.tools.foreground = [0.0, 0.0, 0.0, 1.0];
+ let h = field_harness(app);
+ let mut field = None;
+ let mut circles = Vec::new();
+ for clipped in &h.output().shapes {
+ match &clipped.shape {
+ // The saturation/brightness field is the 17 x 17 mesh; the hue strip is a 25 x 25 grid.
+ egui::Shape::Mesh(m) if m.vertices.len() == 17 * 17 => field = Some(m.calc_bounds()),
+ egui::Shape::Circle(c) => circles.push((c.center, c.radius, clipped.clip_rect)),
+ _ => {}
+ }
+ }
+ let field = field.expect("colour field drawn");
+ let expected_center = pos2(field.left(), field.bottom());
+ let rings: Vec<_> =
+ circles.into_iter().filter(|(center, radius, _)| center.distance(expected_center) < 0.01 && (*radius == 5.0 || *radius == 6.5)).collect();
+ assert!(rings.iter().any(|(_, radius, _)| *radius == 5.0), "inner ring sits at the field's bottom-left corner");
+ assert!(rings.iter().any(|(_, radius, _)| *radius == 6.5), "outer ring sits at the field's bottom-left corner");
+ for (center, radius, clip) in rings {
+ let visible = egui::Rect::from_center_size(center, vec2(2.0 * radius, 2.0 * radius)).intersect(clip);
+ assert!(field.contains_rect(visible), "cursor ring {visible:?} leaves the field {field:?}");
+ }
+ }
+
fn field_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> {
// 60 fps steps, so two clicks a frame apart are a double-click.
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(1.0 / 60.0).build_ui_state(
```
### #8905: fix(plugins): bound a run's total plug-in memory, not just each instance
`crates/plugins/tests/abi.rs`
```diff
diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs
index a75eedd88..d95f0edd9 100644
--- a/crates/plugins/tests/abi.rs
+++ b/crates/plugins/tests/abi.rs
@@ -317,3 +317,36 @@ fn folder_loading_skips_bad_files() {
assert!(photocraft_plugins::registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module");
std::fs::remove_dir_all(&dir).ok();
}
+
+/// #8603: one run on 16 workers must not hold 16 instances at the 512 MiB cap (8 GiB). With the
+/// default limits it holds at most four (2 GiB); a smaller per-instance cap lets every worker
+/// run, and a budget under one cap still runs one band at a time rather than none.
+#[test]
+fn parallel_bands_fit_the_total_memory_budget() {
+ let d = Limits::default();
+ assert_eq!(d.parallel_bands(16), 4);
+ assert!(d.parallel_bands(64) * d.max_memory_bytes <= d.max_total_memory_bytes);
+ assert_eq!(d.parallel_bands(2), 2, "fewer workers than the budget allows");
+ let small = Limits { max_memory_bytes: 64 << 20, ..Limits::default() };
+ assert_eq!(small.parallel_bands(16), 16, "small instances keep every worker busy");
+ let tight = Limits { max_total_memory_bytes: 1 << 20, ..Limits::default() };
+ assert_eq!(tight.parallel_bands(16), 1, "at least one band runs");
+ assert_eq!(d.parallel_bands(0), 1);
+}
+
+/// #8603: a run whose memory budget holds a single instance still covers every band on a
+/// many-worker pool, one band at a time, with the same pixels as a run of parallel bands.
+#[test]
+fn one_band_at_a_time_matches_parallel_bands() {
+ let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
+ let s = surface(PixelFormat::RGBA8, 600, 300);
+ let canvas = Rect::new(0, 0, 600, 300);
+ let run = |limits: Limits| {
+ let p = Plugin::load(INVERT, limits).unwrap();
+ pool.install(|| p.apply(&s, canvas, None, &json!({}))).unwrap()
+ };
+ let parallel = run(Limits { band_bytes: 1, ..Limits::default() });
+ let serial = run(Limits { band_bytes: 1, max_total_memory_bytes: 512 << 20, ..Limits::default() });
+ assert_eq!(serial, parallel);
+ assert_ne!(serial, s, "the filter ran");
+}
```
### #8897: fix(io): read linked smart objects for PSD export as regular files only
`crates/io/src/psd_export.rs`
```diff
diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs
index fd07adde5..e8ae58378 100644
--- a/crates/io/src/psd_export.rs
+++ b/crates/io/src/psd_export.rs
@@ -988,7 +988,9 @@ impl Ex {
return Err(format!("the linked file {path} is not read for this export"));
}
let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?;
- let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?;
+ // Regular files only, in bounded reads: a link to a FIFO would block the save
+ // and one to a device such as `/dev/zero` would never end (#8601).
+ let bytes = photocraft_format::read_file(&file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?;
let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string();
self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)"));
self.embed(sm.contents_id, &name, &bytes)
@@ -2218,4 +2220,39 @@ mod tests {
assert_eq!(back.channels[0].name, "Alpha 1");
assert!((back.channels[0].opacity - 0.5).abs() < 1e-6);
}
+
+ /// A linked smart object whose path names a FIFO is not read (#8601): opening a FIFO for
+ /// reading blocks until a writer appears, and a device such as `/dev/zero` never ends, so the
+ /// export would hang or run out of memory. The export finishes and writes the layer as pixels,
+ /// saying why. The export runs on its own thread so that a regression fails here instead of
+ /// hanging the test run.
+ #[cfg(unix)]
+ #[test]
+ fn a_linked_fifo_is_not_read() {
+ let dir = std::env::temp_dir().join(format!("photocraft-linked-fifo-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let fifo = dir.join("link.png");
+ let _ = std::fs::remove_file(&fifo);
+ assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success(), "mkfifo failed");
+ let mut doc = document(8, 6);
+ let smart = photocraft_doc::SmartObject {
+ contents_id: SmartContentsId::fresh(),
+ source: photocraft_doc::SmartSource::Linked { path: fifo.to_string_lossy().into_owned() },
+ transform: photocraft_geom::Affine::IDENTITY,
+ smart_filters: vec![],
+ cache: Some(Surface::new(doc.pixel_format())),
+ psd_raw: None,
+ filters_enabled: true,
+ filter_mask: None,
+ warp: None,
+ stack_mode: None,
+ perspective: None,
+ };
+ doc.layers.push(Layer::new("Linked", LayerContent::Smart(smart)));
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || tx.send(document_to_psd_with(&doc, &PsdExportOptions::default()).1));
+ let warnings = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the export blocked reading the FIFO");
+ let _ = std::fs::remove_dir_all(&dir);
+ assert!(warnings.iter().any(|w| w.contains("smart object written as pixels") && w.contains("not a regular file")), "{warnings:?}");
+ }
}
```
### #8892: ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte
`crates/ui-egui/src/file_ui.rs`, `crates/ui-egui/src/tools/align_bar.rs`, `crates/ui-egui/src/widgets/tab_strip.rs`
```diff
diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs
index e014ca9e3..6041f6a06 100644
--- a/crates/ui-egui/src/file_ui.rs
+++ b/crates/ui-egui/src/file_ui.rs
@@ -331,10 +331,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::<Vec<u8>>(), None),
Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{frame:?}|{size_sig}", doc.id.0), p, &px, w, h) {
Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))),
- Err(error) => {
- ui.label(error);
- (vec![0; (w * h * 4) as usize], None)
- }
+ // The error shows on the row above the panes; a label here sat beside the pane
+ // and widened the dialog (#8830).
+ Err(_) => (vec![0; (w * h * 4) as usize], None),
},
};
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba);
```
### #8347: Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment)
`crates/engine/src/cmd/type_cmds.rs`, `crates/text/src/layout.rs`, `crates/text/src/psd.rs`, `crates/ui-egui/src/type_tool.rs`, `docs/scorecard.md`, `docs/ui-parity.md`
```diff
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 4936b33c6..bc0729f14 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -457,6 +457,13 @@ struct KernSlot {
size: f32,
rtl: bool,
blank: bool,
+ /// Hangs at the line's end when the paragraph has hanging punctuation on, weighted: 1.0
+ /// for the marks that hang fully outside the margin (a period, a comma, a hyphen, a
+ /// quote, an apostrophe), 0.5 for those that hang half (a colon, a semicolon, an
+ /// ellipsis, a dash); 0.0 marks no hang. Photoshop's Roman Hanging Punctuation list,
+ /// from the Help: "quotes, apostrophes, hyphens, periods and commas hang 100%;
+ /// colons, semicolons, ellipses and dashes hang 50%; at both margins".
+ hang_w: f32,
/// Advance along the line (px, horizontal scale included).
advance: f32,
/// What horizontal scale adds to the shaped advance (px; negative below 100%).
@@ -773,6 +780,154 @@ impl Layouter {
}
let m = *line.metrics();
let LineMetrics { ascent, descent, leading } = line_metrics(&line, &out.styles, &ps, k, first_style, vertical);
+ // Hang-row detection needs the byte mapping the rest of the body uses.
+ let map = |o: usize| (prange.start + o.saturating_sub(prefix.len())).min(content_end);
+ // Hanging punctuation: a row of nothing but marks was pushed here because they
+ // didn't fit the line above; with the paragraph's hanging on, step them back
+ // onto that line so they hang past the box edge (Photoshop's Roman Hanging
+ // Punctuation): a centered line keeps its centering, a left line keeps filling
+ // the box. Horizontal lines only (vertical type has Burasagari instead) and no
+ // RTL marks; the text between the line above and the marks stays contiguous, so
+ // carets and selection keep their rows. The marks keep their style's manual
+ // kerning but drop optical pair kerning and underline decorations along the way.
+ let hang_row = ps.hanging
+ && !vertical
+ // Area type plans its rows and slots ahead of the layout; merging a mark
+ // row back would leave a slot for a row that no longer exists, so hanging
+ // applies to point and box lines only there.
+ && area.is_none()
+ // An empty row (two forced breaks in a row) has no items: `all` would read
+ // it as a hanging run and merge it into the line above, dropping the blank
+ // row.
+ && line.items().next().is_some()
+ && line.items().all(|it| match it {
+ PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().all(|c| {
+ !c.is_rtl()
+ && !c.is_space_or_nbsp()
+ && c.text_range().end > prefix.len()
+ && hang_cluster(ptext.get(c.text_range().clone()).unwrap_or(""))
+ }),
+ _ => false,
+ });
+ if hang_row
+ && let Some(prev) = out.lines.last()
+ && prev.paragraph == pi
+ {
+ // The
```
### #8074: Transform: remember selected interpolation (#8067)
`crates/ui-egui/src/prefs_ui.rs`, `crates/ui-egui/src/state.rs`, `crates/ui-egui/src/transform_tool.rs`
```diff
diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
index b9ed13083..18705ec21 100644
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -3610,15 +3610,17 @@ mod tests {
tick(&mut app, &ctx);
app.ui.tool_options.move_auto_select = false;
app.ui.tool_options.feather = 12.0;
+ app.ui.tool_options.transform_interpolation = Some(photocraft_engine::prefs::Interpolation::Nearest);
crate::tool_memory::persist(&mut app, &ctx);
tick(&mut app, &ctx);
// Only what differs from the defaults is stored.
- assert_eq!(stored(&store)["toolOptions"], json!({"move_auto_select": false, "feather": 12.0}));
+ assert_eq!(stored(&store)["toolOptions"], json!({"move_auto_select": false, "feather": 12.0, "transform_interpolation": "nearestNeighbor"}));
let saved = store.lock().unwrap().clone().unwrap();
let (mut app2, _) = app_with_saved(Some(saved));
tick(&mut app2, &ctx);
assert!(!app2.ui.tool_options.move_auto_select);
assert_eq!(app2.ui.tool_options.feather, 12.0);
+ assert_eq!(app2.ui.tool_options.transform_interpolation, Some(photocraft_engine::prefs::Interpolation::Nearest));
assert_eq!(app2.ui.tool_options.tolerance, crate::state::ToolOptions::default().tolerance);
// A hand-edited file: unreadable fields keep their defaults, the good ones still load.
for bad in [json!("junk"), json!({"move_auto_select": "no", "feather": 1e300, "tolerance": 5.0, "unknown": 1})] {
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
index 313186157..77ebcd5b1 100644
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -691,6 +691,9 @@ pub struct ToolOptions {
pub move_target: String,
#[serde(default)]
pub move_show_transform: bool,
+ /// Last interpolation selected in the Transform options bar; unset follows General's default.
+ #[serde(default)]
+ pub transform_interpolation: Option<photocraft_engine::prefs::Interpolation>,
/// Crop options bar: aspect ratio ("" = Ratio/unconstrained, "w:h", or "original") and
/// Delete Cropped Pixels.
#[serde(default)]
@@ -954,6 +957,7 @@ impl Default for ToolOptions {
move_auto_select: true,
move_target: default_move_target(),
move_show_transform: false,
+ transform_interpolation: None,
crop_ratio: String::new(),
crop_custom_ratio_width: 0.0,
crop_custom_ratio_height: 0.0,
diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs
index 65a5c7014..f63f4be0b 100644
--- a/crates/ui-egui/src/transform_tool.rs
+++ b/crates/ui-egui/src/transform_tool.rs
@@ -187,6 +187,26 @@ fn corners(r: [f64; 4]) -> [[f64; 2]; 4] {
[[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
}
+/// The transform choices supported by the options bar, using its remembered choice or the general default.
+pub(crate) fn default_interpolation(app: &PhotocraftApp) -> &'static str {
+ use photocraft_engine::prefs::Interpolation;
+ match app.ui.tool_options.transform_interpolation.unwrap_or(app.session.prefs().general.image_interpolation) {
+ Interpolation::Nearest => "nearest",
+ Interpolation::Bilinear => "bilinear",
+ _ => "bicubic",
+ }
+}
+
+fn interpolation_preference(interpolation: &str) -> Option<photocraft_engine::prefs::Interpola
```
### #8039: Compose: Extend Softer outer glows past Size (#7899)
`crates/compose/src/effects.rs`, `crates/gpu/src/fx.rs`
```diff
diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs
index cbdf7f35a..1c751225b 100644
--- a/crates/compose/src/effects.rs
+++ b/crates/compose/src/effects.rs
@@ -19,7 +19,8 @@
//! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows /
//! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons,
//! which a wide stroke shows as flat facets (#3646). Inside stroke edges and spread / choke keep the
-//! cheaper chamfer. Soft falloffs are two box blurs (a tent).
+//! cheaper chamfer. Soft falloffs are two box blurs (a tent); Softer outer glows retain a short
+//! fading tail beyond Size.
use photocraft_color::blend::BlendMode;
use photocraft_doc::Pattern;
@@ -62,7 +63,14 @@ pub fn margin(layer: &Layer) -> i32 {
for e in layer.effects.items.iter().filter(|e| e.enabled()) {
let r = match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
- Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
+ Effect::OuterGlow(g) => {
+ if g.technique == GlowTechnique::Softer {
+ g.size + softer_glow_tail_width(g.size)
+ } else {
+ g.size
+ }
+ }
+ Effect::InnerGlow(g) => g.size,
Effect::Stroke(s) => s.size,
Effect::Satin(s) => satin_distance(s) + s.size,
Effect::BevelEmboss(b) => b.size + b.soften,
@@ -1192,6 +1200,9 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
let (r, bw) = spread_split(g.size, g.spread);
let mut m = dilate(&src, r);
blur(&mut m, bw);
+ if !inner {
+ add_softer_glow_tail(&mut m, &src, g.size, g.range);
+ }
m
}
};
@@ -1463,6 +1474,39 @@ fn tent_fast(m: &mut Map, w: f32) {
m.v = transpose_map(&b, mh, mw);
}
+/// Width of the added fading tail on a Softer outer glow, capped so large settings stay local.
+pub fn softer_glow_tail_width(size: f32) -> f32 {
+ if size.is_finite() && size > 0.0 { (size * 0.1).clamp(1.0, 6.0) } else { 0.0 }
+}
+
+/// Raw coverage at the Size boundary, scaled through the glow's Range to a subtle one-percent tail.
+pub fn softer_glow_tail_amplitude(range: f32) -> f32 {
+ let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
+ 0.005 * range
+}
+
+fn add_softer_glow_tail(map: &mut Map, shape: &Map, size: f32, range: f32) {
+ let width = softer_glow_tail_width(size);
+ if width == 0.0 {
+ return;
+ }
+ let amp = softer_glow_tail_amplitude(range);
+ let distance = dist_outside(shape);
+ for (coverage, distance) in map.v.iter_mut().zip(distance) {
+ let t = (distance + 0.5 - (size - width)) / width;
+ let tail = if (0.0..=1.0).contains(&t) {
+ amp * t * t * (3.0 - 2.0 * t)
+ } else if (1.0..=2.0).contains(&t) {
+ let u = t - 1.0;
+ let remaining = 1.0 - u;
+ amp * remaining * remaining * (1.0 + 2.0 * u)
+ } else {
+ 0.0
+ };
+ *coverage = coverage.max(tail);
+ }
+}
+
/// Where a bevel's maps paint: inside the shape (with the layer), outside it (onto the
/// backdrop), or both (emboss styles: maps 0–1 inside, 2–3 outside).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -3222,6 +3266,56 @@ mod tests {
```
### #7848: fix(retouch): preserve Smudge pickup with live-pixel stroke commits
`crates/engine/src/retouch_cmds.rs`
```diff
diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs
index fdbee1cb2..1a2530d3d 100644
--- a/crates/engine/src/retouch_cmds.rs
+++ b/crates/engine/src/retouch_cmds.rs
@@ -911,6 +911,37 @@ enum DabTool {
/// One dab's edit of the working copy.
pub(crate) type DabEffect = Box<dyn FnMut(&mut Region, &Footprint)>;
+/// Pixels an effect reads beyond the dab it writes. Smudge's pickup is a fixed-size square,
+/// independent of pressure, roundness and the dab's current angle.
+#[derive(Clone, Copy)]
+enum DabNeighborhood {
+ Halo(i32),
+ Smudge(i32),
+}
+
+impl DabNeighborhood {
+ fn halo(self) -> i32 {
+ match self {
+ Self::Halo(radius) | Self::Smudge(radius) => radius,
+ }
+ }
+
+ fn read_rect(self, rect: Rect, dab: &photocraft_paint::Dab) -> Rect {
+ match self {
+ Self::Halo(radius) => rect.inflate(radius),
+ Self::Smudge(radius) => {
+ let (x, y) = (dab.center.x.floor() as i32, dab.center.y.floor() as i32);
+ rect.union(&Rect::new(
+ x.saturating_sub(radius),
+ y.saturating_sub(radius),
+ x.saturating_add(radius).saturating_add(1),
+ y.saturating_add(radius).saturating_add(1),
+ ))
+ }
+ }
+ }
+}
+
impl DabTool {
/// The tool for `cmd` and its history label.
fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> {
@@ -952,10 +983,11 @@ impl DabTool {
}
/// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood).
- fn halo(&self) -> i32 {
+ fn neighborhood(&self) -> DabNeighborhood {
match self {
- Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1,
- _ => 0,
+ Self::Focus { sigma, .. } => DabNeighborhood::Halo((sigma * 3.0).ceil() as i32 + 1),
+ Self::Smudge { max_size, .. } => DabNeighborhood::Smudge(Smudge::sampling_radius(*max_size)),
+ _ => DabNeighborhood::Halo(0),
}
}
@@ -1020,7 +1052,7 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let fmt = surf.format();
let mut effect = tool.effect(fmt, stroke.brush.spacing);
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
- Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.halo(), |work, fp| match &mut sampler {
+ Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler {
Some(all) => all.dab(work, fp, |w, f| effect(w, f)),
None => effect(work, fp),
}))
@@ -1120,7 +1152,7 @@ pub struct LiveDab {
renderer: photocraft_paint::StrokeRenderer,
done: usize,
effect: DabEffect,
- halo: i32,
+ neighborhood: DabNeighborhood,
pre_surf: Surface,
work: WorkTiles,
id: Option<LayerId>,
@@ -1155,9 +1187,9 @@ impl LiveDab {
}
let (stroke, id) = parse_brush(s, p, cmd)?;
let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?;
- let (halo, spacing, brush) = (tool.halo(), stroke.brush.spacing, Box::new(stroke.brush.clone()));
+ let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone()));
let effect = tool.clone();
- let mut live = Self::with_effect(s, cmd, p, stroke, id, halo, |pre| effect.effect(pr
```
### #7742: Actions: a fractional setting an integer field cannot hold is refused, not rounded
`crates/engine/src/atn_cmds.rs`, `crates/engine/src/atn_cmds/tests.rs`
```diff
diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs
index 9e8eb6cb7..decaed9a7 100644
--- a/crates/engine/src/atn_cmds.rs
+++ b/crates/engine/src/atn_cmds.rs
@@ -186,6 +186,15 @@ fn long(v: f64) -> D {
D::Integer(v.round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32)
}
+/// `v` as a Photoshop integer field, or `None` when it is not one. A fractional setting written
+/// here would export an edit the user did not record -- `cellSize: 5.4` as `ClSz 5` plays back as
+/// a different image, with nothing in the file to say so (#7669, #7670). The step is refused
+/// instead: strict export names it, and `skipUnsupported` leaves it out.
+fn long_exact(v: f64) -> Option<D> {
+ let whole = v.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&v);
+ whole.then_some(D::Integer(v as i32))
+}
+
fn text(s: &str) -> D {
D::Text(UnicodeString::new_nul(s))
}
@@ -389,6 +398,11 @@ enum Kind {
Pct,
Ang,
Long,
+ /// An integer field whose command uses the setting as written, so a fraction cannot be
+ /// exported: see [`long_exact`]. Only for the fields that have been checked against their
+ /// command -- an event whose command rounds the setting itself can hold it after all
+ /// (`edit.stroke` ceils its width, so 2.4 really is 3; #7742 review).
+ LongExact,
Dbl,
Bool,
/// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs.
@@ -406,7 +420,7 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option<Map<String, Value>> {
let mut m = Map::new();
for F(key, param, kind) in fields {
let v = match kind {
- Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from),
+ Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::LongExact | Kind::Dbl => get_num(d, key).map(Value::from),
Kind::Bool => get_bool(d, key).map(Value::from),
Kind::Enum(_, table) => match get_enum(d, key) {
Some(e) => Some(Value::from(lookup(table, &e)?)),
@@ -436,6 +450,7 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option<Descriptor> {
Kind::Pct => pct(v.as_f64()?),
Kind::Ang => ang(v.as_f64()?),
Kind::Long => long(v.as_f64()?),
+ Kind::LongExact => long_exact(v.as_f64()?)?,
Kind::Dbl => D::Double(v.as_f64()?),
Kind::Bool => D::Boolean(v.as_bool()?),
Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?),
@@ -2727,8 +2742,8 @@ const EVENTS: &[Event] = &[
"filter.other.offset",
&[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))]
),
- ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
- ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]),
+ ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::LongExact), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
+ ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::LongExact)]),
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
// Adjustments (I
```
### #7255: fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223)
`crates/engine/src/image_cmds.rs`
```diff
diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs
index 6d5342b93..d4308fb7e 100644
--- a/crates/engine/src/image_cmds.rs
+++ b/crates/engine/src/image_cmds.rs
@@ -720,11 +720,24 @@ fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect {
Rect::new((x0 - KERNEL_REACH).floor() as i32, (y0 - KERNEL_REACH).floor() as i32, (x1 + KERNEL_REACH).ceil() as i32, (y1 + KERNEL_REACH).ceil() as i32)
}
+/// Flatten the document only if its dimensions are within the compositor's render limit.
+fn flatten_within_render_limit(doc: &Document, op: &str) -> Result<photocraft_compose::Buffer> {
+ let bounds = doc.bounds();
+ if !bounds.is_empty() && u64::from(bounds.width()) * u64::from(bounds.height()) > photocraft_compose::MAX_RENDER_PIXELS {
+ return Err(EngineError::Other(format!("the document is too large to {op}")));
+ }
+ let buf = photocraft_compose::flatten(doc);
+ if buf.px.is_empty() && !bounds.is_empty() {
+ return Err(EngineError::Other(format!("the document is too large to {op}")));
+ }
+ Ok(buf)
+}
+
/// Image → Trim.
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
- let buf = photocraft_compose::flatten(&d.doc);
+ let buf = flatten_within_render_limit(&d.doc, "trim")?;
let based = p.get("basedOn").and_then(Value::as_str).unwrap_or("transparent");
let w = canvas.width() as usize;
let reference = match based {
@@ -803,7 +816,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
doc.id = photocraft_doc::DocId::fresh();
doc.name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{} copy", d.doc.name));
if p.get("mergedOnly").and_then(Value::as_bool).unwrap_or(false) {
- let buf = photocraft_compose::flatten(&doc);
+ let buf = flatten_within_render_limit(&doc, "duplicate merged")?;
let fmt = doc.pixel_format();
let mut surf = Surface::new(fmt);
let vals: Vec<f32> = buf.px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
@@ -1687,4 +1700,31 @@ mod tests {
assert_eq!(doc(&s).layers.len(), 1);
assert!(doc(&s).name.ends_with("copy"));
}
+
+ #[test]
+ fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() {
+ let mut s = Session::new();
+ s.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap();
+ let initial_layers = doc(&s).layers.len();
+ let initial_bounds = doc(&s).bounds();
+ let initial_rev = s.active().unwrap().revision;
+ let initial_docs = s.documents().len();
+
+ let trim_res = s.execute("image.trim", json!({"basedOn": "topLeft"}));
+ assert!(trim_res.is_err(), "trim on too-large document must return error");
+ let err = trim_res.unwrap_err().to_string();
+ assert!(err.contains("too large to trim"), "error should explain document is too large: {err}");
+ assert_eq!(doc(&s).layers.len(), initial_layers, "layer count unchanged after failed trim");
+ assert_eq!(doc(&s).bounds(), initial_bounds, "canvas size unchanged after failed trim");
+ assert_eq!(s.active().unwrap().revision, initial_rev, "revision unchanged after failed trim");
+
+ let dup_res = s.execute("image.duplicate", json!({"mergedOnly": true}));
+
```
### #6621: Crop: right-click on the pending crop opens the crop menu (#6561)
`crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/canvas_tool_menu.rs`, `crates/ui-egui/src/control.rs`, `crates/ui-egui/src/crop_ui.rs`, `crates/ui-egui/src/transform_type_tests.rs`, `docs/context-menu-parity.md`, `docs/scorecard.md`
```diff
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ab3e6939a..948e24e8f 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -4272,6 +4272,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// menu belongs to the selected Zoom or Hand, not to Space or a held zoom key.
if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) {
crate::canvas_tool_menu::open_pasteboard(app, &ctx, tool, [p.x, p.y]);
+ } else if tool == Tool::Crop && crate::crop_ui::pending(app) {
+ // On a pending crop frame: the crop menu (#6561).
+ crate::canvas_tool_menu::open_crop(app, &ctx, [p.x, p.y]);
} else if crate::canvas_tool_menu::is_view_tool(tool) {
if tool == app.ui.tool {
crate::canvas_tool_menu::open_view(app, &ctx, tool, [p.x, p.y], xf.to_doc(p));
@@ -6840,12 +6843,14 @@ pub fn commit_crop(app: &mut PhotocraftApp) {
// The crop's default mode leaves the view upright (`crop_mode`).
crate::crop_mode::upright(app);
// The untouched default frame around the whole canvas crops nothing (Photoshop's ↵ on it does
- // nothing); one framing the selection's bounds crops to them (#1789).
- if std::mem::take(&mut app.crop.default_frame) {
- let whole = app.session.active().map(|st| st.doc.bounds()).map(|b| [f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)]);
- if whole.is_none_or(|b| b == r) {
- return;
- }
+ // nothing); one framing the selection's bounds crops to them (#1789). The crop menu's Reset
+ // (#6561) leaves a full-canvas frame that stays pending: committing it crops nothing either,
+ // unless it was turned since or W x H x Resolution resamples it.
+ let default_frame = std::mem::take(&mut app.crop.default_frame);
+ let reset_frame = std::mem::take(&mut app.crop.reset_frame);
+ let whole = app.session.active().map(|st| st.doc.bounds()).map(|b| [f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)]);
+ if whole.is_none_or(|b| b == r) && (default_frame || (reset_frame && crate::crop_ui::angle(app) == 0.0 && !crate::crop_size::resamples(app))) {
+ return;
}
let (x, y) = (r[0].round(), r[1].round());
let (w, h) = ((r[2] - r[0]).round().max(1.0), (r[3] - r[1]).round().max(1.0));
diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs
index e4256949e..6cadb706a 100644
--- a/crates/ui-egui/src/canvas_tool_menu.rs
+++ b/crates/ui-egui/src/canvas_tool_menu.rs
@@ -42,6 +42,13 @@ pub struct CanvasToolMenu {
/// the menu.
#[serde(default)]
pub spelling: Option<Spelling>,
+ /// Opened on a pending crop frame: the crop menu (#6561).
+ #[serde(default)]
+ pub crop: bool,
+ /// The saved crop preset names, snapshotted when the crop menu opened: `crop.preset.<n>`
+ /// picks the n-th, so the rows cannot shift under an open menu.
+ #[serde(default)]
+ pub crop_presets: Vec<String>,
}
/// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry).
@@ -196,6 +203,63 @@ pub const GUIDE_MENU: &[Row] = &[
Some(("Clear Selected Artboard Guides", "view.clearSelectedArtboardGuides")),
];
+/// The crop menu's ratio rows: `crop.ratio.<key>` sets that options-bar ratio (`crop_ratio`) and
+/// fits the frame, exactly
```
### #6492: Fail control startup promptly when its port is occupied
`apps/photocraft/src/control_server.rs`, `apps/photocraft/src/main.rs`, `apps/photocraft/tests/it/control_exit_code.rs`
```diff
diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs
index 30ae610b7..f8462e73f 100644
--- a/apps/photocraft/src/control_server.rs
+++ b/apps/photocraft/src/control_server.rs
@@ -16,16 +16,12 @@ use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
use serde_json::{Value, json};
-pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
+/// Start serving a listener already bound during startup, before a window is opened.
+pub fn start(listener: TcpListener, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
- let listener = match TcpListener::bind(("127.0.0.1", port)) {
- Ok(l) => l,
- Err(e) => {
- eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {e}");
- return rx;
- }
- };
- eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
+ if let Ok(address) = listener.local_addr() {
+ eprintln!("photocraft: control server listening on {address}");
+ }
std::thread::spawn(move || {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
@@ -131,6 +127,30 @@ mod tests {
use super::*;
use std::io::BufRead;
+ #[test]
+ fn a_prebound_listener_authenticates_and_dispatches_requests() {
+ const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let address = listener.local_addr().unwrap();
+ let requests = start(listener, TOKEN.into(), egui::Context::default());
+ let mut stream = TcpStream::connect(address).unwrap();
+ stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+ let mut replies = BufReader::new(stream.try_clone().unwrap());
+ writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
+ let mut line = String::new();
+ replies.read_line(&mut line).unwrap();
+ assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
+ writeln!(stream, "{}", json!({"id": 2, "method": "test.bound"})).unwrap();
+ let request = requests.recv_timeout(Duration::from_secs(5)).unwrap();
+ assert_eq!(request.method, "test.bound");
+ request.reply.send(json!({"ok": true, "result": "served"})).unwrap();
+ line.clear();
+ replies.read_line(&mut line).unwrap();
+ let reply: Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(reply["id"], 2);
+ assert_eq!(reply["result"], "served");
+ }
+
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs
index c0f93bd31..d8ead0674 100644
--- a/apps/photocraft/src/main.rs
+++ b/apps/photocraft/src/main.rs
@@ -284,6 +284,43 @@ fn main() -> eframe::Result {
std::process::exit(code);
}
+ // Complete control setup before display/GPU initialization: a failed grant must exit 2,
+ // including an occupied port (#6428), rather than open a window without a control server.
+ let control = if let Some(port) = control_port {
+ let listener = match std::net
```
### #6449: Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop
`crates/ui-egui/src/input/hold_keys.rs`, `crates/ui-egui/src/input/hold_keys/tests.rs`, `docs/ui-parity.md`
```diff
diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs
index 3f2845cae..1da497021 100644
--- a/crates/ui-egui/src/hold_keys.rs
+++ b/crates/ui-egui/src/hold_keys.rs
@@ -16,6 +16,8 @@
//! selection tools do their own ⌘ handling in `canvas` (⌘ inside the selection cuts the
//! selected pixels, outside it moves the layer, #896), and the Hand, Zoom, Crop, Slice, Path
//! Selection, shape, Pen and Type tools keep ⌘ for themselves.
+//! - With the Hand tool, ⌘ (Ctrl off the Mac) held is Zoom In and ⌥ (Alt) Zoom Out, ⌘⌥ too
+//! ([`hand_zoom_held`], #6434).
//! - `~` (the backtick key) with the Brush or Pencil: a stroke started while it is held erases,
//! with that tool's own brush ([`erase_held`], #3439).
//!
@@ -118,6 +120,23 @@ fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool {
i.modifiers.command && cmd_moves(app.ui.tool) && app.ui.transform.is_none() && app.ui.text_edit.is_none()
}
+/// The Hand tool's own temporary zoom (#6434): ⌘ held is Zoom In, ⌥ (with or without ⌘) Zoom Out,
+/// as in Photoshop. Not while a Free Transform keeps ⌘ for its handles, and not from the middle of
+/// a Hand drag: a modifier pressed while panning must not stop the pan.
+fn hand_zoom_held(app: &PhotocraftApp, i: &InputState) -> Option<Temporary> {
+ let panning = i.pointer.primary_down() && !i.pointer.primary_pressed();
+ if app.ui.tool != Tool::Hand || app.ui.transform.is_some() || panning {
+ return None;
+ }
+ if i.modifiers.alt {
+ Some(Temporary::ZoomOut)
+ } else if i.modifiers.command {
+ Some(Temporary::ZoomIn)
+ } else {
+ None
+ }
+}
+
/// Is `id` a held temporary tool rather than a command?
pub fn is_temporary(id: &str) -> bool {
photocraft_engine::prefs::TEMPORARY_TOOLS.iter().any(|t| t.0 == id)
@@ -159,6 +178,7 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary>
};
let mut best: Option<(u8, Temporary, KeyboardShortcut)> = None;
let mut cmd_move = false;
+ let mut hand_zoom = None;
let mut alt = false;
ctx.input(|i| {
for t in Temporary::BOUND {
@@ -170,13 +190,14 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary>
}
}
cmd_move = cmd_move_held(app, i);
+ hand_zoom = hand_zoom_held(app, i);
alt = i.modifiers.alt;
});
// ⌥ turns a held Zoom In around, as it does the Zoom tool: ⌘⌥Space zooms out (and wins the
// tie with ⌥Space), unless ⌥ is part of the Zoom In key itself.
let held = best.map(|(_, t, sc)| if t == Temporary::ZoomIn && alt && !sc.modifiers.alt { Temporary::ZoomOut } else { t });
- // A bound key (⌘Space is Zoom In) wins over ⌘ alone.
- held.or(cmd_move.then_some(Temporary::Move))
+ // A bound key (⌘Space is Zoom In) wins over ⌘ or ⌥ alone.
+ held.or(cmd_move.then_some(Temporary::Move)).or(hand_zoom)
}
/// The binding id of the Brush and Pencil erase key (`prefs::TEMPORARY_TOOLS`).
diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs
index cfe89d464..9e3dbb604 100644
--- a/crates/ui-egui/src/hold_keys/tests.rs
+++ b/crates/ui-egui/src/hold_keys/tests.rs
@@ -643,13 +643,67 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() {
let mut h = harness(tool);
h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND)));
h.run_steps(1);
- assert_eq
```
### #6236: Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098)
`crates/ui-egui/src/brush_resize.rs`, `crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/paint_mouse.rs`
```diff
diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs
index 7d41aaa1b..a454932d3 100644
--- a/crates/ui-egui/src/brush_resize.rs
+++ b/crates/ui-egui/src/brush_resize.rs
@@ -49,9 +49,10 @@ pub fn is_gesture(mods: egui::Modifiers) -> bool {
}
/// Alt (Option) with the right button: Photoshop's Windows gesture (#297). The left button with
-/// Alt alone stays the painting tools' eyedropper.
+/// Alt alone stays the painting tools' eyedropper. Shift+Alt+right is Photoshop's HUD Color
+/// Picker (#6098), so Shift disqualifies the resize gesture.
pub fn is_right_gesture(mods: egui::Modifiers) -> bool {
- mods.alt
+ mods.alt && !mods.shift
}
/// The diameter and hardness a drag from `start` by (`dx`, `dy`) document pixels gives at `zoom`.
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ab3e6939a..a48307db7 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2587,6 +2587,8 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::layer_transfer::finish(app, ui.ctx());
// Once a frame, not per document view: the right-click or options-bar Brush Preset picker.
crate::paint_mouse::show_picker(app, ui.ctx());
+ // And the Shift+Alt+right-click HUD Color Picker (#6098), while its gesture is held.
+ crate::color_hud::show(app, ui.ctx());
}
/// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 4db3dc2a3..ae902e5fa 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -60,6 +60,7 @@ pub mod channels_panel;
pub mod chrome_ui;
pub mod cjk_fonts;
pub mod clip_line_ui;
+pub mod color_hud;
pub mod color_panel_ui;
pub mod color_picker_ui;
pub mod color_range_ui;
diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs
index 84695522d..75a8d6392 100644
--- a/crates/ui-egui/src/paint_mouse.rs
+++ b/crates/ui-egui/src/paint_mouse.rs
@@ -136,12 +136,24 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool)
// ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307).
let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods);
let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu;
+ // Shift+Alt+right-press opens Photoshop's HUD Color Picker (#6098) instead of the picker.
+ // Tools whose right-click is the canvas tool menu (Quick Selection, #7268) keep that menu.
+ let sticky = crate::workspace_ui::sticky_mods(app, mods);
+ let hud_gesture = right_click_picker(tool) && !layer_menu && !erase && sticky.shift && sticky.alt;
+ let right_press = response.ctx.input(|i| i.pointer.button_pressed(PointerButton::Secondary)) && response.contains_pointer();
+ if hud_gesture
+ && right_press
+ && let Some(p) = response.ctx.input(|i| i.pointer.hover_pos())
+ {
+ crate::color_hud::open(app, [p.x, p.y], true);
+ }
let right_stroke = erase && app.drag.is_some();
let right_start = erase && right_started;
let right_click = response.secondary_clicked();
if right_click
&& !erase
&& !layer_menu
+ && !crate::color_hud::is_open(&app.ui)
&& right_click_picker(tool)
&& let Some(p) = response.interact_pointer_pos()
{
@@ -164,6 +176,22 @@ pub fn canvas_buttons(app: &mut
```
### #6139: Fix painting workflows, File Info and layer selection
`crates/engine/src/cmd/brush_cmds.rs`, `crates/paint/src/render.rs`, `crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/dialogs.rs`, `crates/ui-egui/src/i18n/cs.tsv`, `crates/ui-egui/src/i18n/de.tsv`, `crates/ui-egui/src/i18n/hu.tsv`, `crates/ui-egui/src/i18n/it.tsv`, `crates/ui-egui/src/i18n/ja.tsv`, `crates/ui-egui/src/i18n/pt-br.tsv`, `crates/ui-egui/src/i18n/tr.tsv`, `crates/ui-egui/src/i18n/uk.tsv`, `crates/ui-egui/src/i18n/zh-hans.tsv`, `crates/ui-egui/src/i18n/zh-hant.tsv`, `crates/ui-egui/src/input_tests.rs`, `crates/ui-egui/src/menus.rs`, `crates/ui-egui/src/move_mods.rs`, `crates/ui-egui/src/panels.rs`, `crates/ui-egui/src/vector_ui.rs`, `crates/ui-egui/src/view_cmds.rs`
```diff
diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs
index 5f59aafad..c1db2498c 100644
--- a/crates/paint/src/render.rs
+++ b/crates/paint/src/render.rs
@@ -1038,7 +1038,21 @@ impl StrokeRenderer {
src[ch] = p[ch] / c;
}
}
- if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
+ if b.behind {
+ let Some(a) = a_idx else { continue };
+ let da = px[a];
+ if lock_transparency || da >= 1.0 {
+ continue;
+ }
+ let underneath = src[a] * k * (1.0 - da);
+ let alpha = da + underneath;
+ if alpha > 0.0 {
+ for channel in 0..a {
+ px[channel] = (px[channel] * da + src[channel] * underneath) / alpha;
+ }
+ px[a] = alpha;
+ }
+ } else if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
over_native(&fmt, px, &src[..n], k, lock_transparency);
} else {
let d = to_rgba(&fmt, px);
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 391ad464f..6fbb502fe 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -3272,6 +3272,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::crop_ui::cancel_stale(app);
crate::crop_ui::ensure_frame(app);
let mut drawing = crate::crop_ui::active(app);
+ if let Some(pen) = app.ui.pen.as_mut().filter(|pen| pen.dragging && !pen.curvature) {
+ pen.reposition = reposition;
+ drawing = true;
+ }
if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) {
d.reposition = reposition;
drawing = true;
@@ -4009,6 +4013,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
pen_cursor(ui.ctx(), tool, p)
}
}
+ // The native copy pointer signals Alt-drag duplication without app-specific artwork.
+ Tool::Move if crate::move_mods::copying(app, alt) => egui::CursorIcon::Copy,
// Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool.
Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise =>
@@ -4295,7 +4301,7 @@ pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator<
};
for (pa, pb) in lines {
// A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon).
- if !(pa.is_finite() && pb.is_finite()) || !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) {
+ if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) {
continue;
}
let (pa, pb) = if pa.distance(pb) > LONG {
@@ -4993,9 +4999,12 @@ fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f6
```
### #6026: Marquee: Fixed Size selects exactly the entered width and height (fixes #6015)
`crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/marquee_tests.rs`
```diff
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 391ad464f..96c979311 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -237,14 +237,16 @@ pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -
/// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. `None`
/// while the gesture is still a click (it never left the press point by more than a few screen
/// pixels at `zoom`, points per document pixel) or the rectangle has no area: a release then
-/// deselects. A one-pixel row or column is a selection (#2391).
+/// deselects. A one-pixel row or column is a selection (#2391). With the Fixed Size style a
+/// click places the selection, as in Photoshop.
pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: f32) -> Option<[f64; 4]> {
- if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || crate::shape_dialog::is_click(d.start, &d.points, zoom) {
+ let fixed = o.marquee_style == "fixedSize";
+ if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || (!fixed && crate::shape_dialog::is_click(d.start, &d.points, zoom)) {
return None;
}
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
let (a, b) = marquee_corners(o, d, last);
- let mut r = marquee_px(a, b);
+ let mut r = if fixed { fixed_size_px(a, b, o.marquee_width, o.marquee_height) } else { marquee_px(a, b) };
if marquee_is_square(o, d) {
r = square_px(r, d.start, last);
}
@@ -256,6 +258,16 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
[a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()]
}
+/// The pixel rectangle of a Fixed Size marquee between the corners `a` and `b`: exactly `w` × `h`
+/// whole pixels. Snapping both edges outwards (`marquee_px`) added a pixel to each side whenever
+/// the press point sat between pixels, so 10 × 10 selected 11 × 11 (#6015). The near edge goes
+/// to the closest pixel edge and the far one is the size away.
+pub fn fixed_size_px(a: [f64; 2], b: [f64; 2], w: f32, h: f32) -> [f64; 4] {
+ let (w, h) = (f64::from(w).round().max(1.0), f64::from(h).round().max(1.0));
+ let (x0, y0) = (a[0].min(b[0]).round(), a[1].min(b[1]).round());
+ [x0, y0, x0 + w, y0 + h]
+}
+
/// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with
/// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`.
fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool {
@@ -6043,6 +6055,19 @@ mod tests {
assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]);
}
+ /// #6015: a Fixed Size marquee pressed between pixels selected one pixel more on each side.
+ #[test]
+ fn fixed_size_px_is_exactly_the_size() {
+ use super::fixed_size_px;
+ // Down-right from (10.4, 20.6): 10 × 10 from the closest pixel edges.
+ assert_eq!(fixed_size_px([10.4, 20.6], [20.4, 30.6], 10.0, 10.0), [10.0, 21.0, 20.0, 31.0]);
+ // Up-left: the far corner is the low one; still exactly the size.
+ assert_eq!(fixed_size_px([10.4, 20.6], [-53.6, -11.4], 64.0, 32.0), [-54.0, -11.0, 10.0, 21.0]);
+ // 1 × 1 is one pixel, and a fractional size rounds to whole pixels.
+ assert_eq!(fixed_size_px([3.5, 3.5], [4.5,
```
### #5744: fix(ui): keep detached documents in one interactive window
`crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/menus.rs`, `crates/ui-egui/src/view_cmds.rs`
```diff
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 7a9592b69..413e29872 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -1968,11 +1968,28 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the
/// active document fills the area.
+fn docked_document_indices(app: &PhotocraftApp) -> Vec<usize> {
+ (0..app.session.documents().len()).filter(|&doc| !crate::view_cmds::document_is_floating(app, doc)).collect()
+}
+
+fn main_canvas_document(app: &PhotocraftApp) -> Option<usize> {
+ let active = app.session.active_index()?;
+ if !crate::view_cmds::document_is_floating(app, active) {
+ return Some(active);
+ }
+ docked_document_indices(app).into_iter().next()
+}
+
pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option<Vec<(usize, Rect)>> {
- let idx = app.session.active_index()?;
- let n = app.session.documents().len();
+ let documents = docked_document_indices(app);
+ if documents.is_empty() {
+ return None;
+ }
+ let active = app.session.active_index();
+ let start = active.and_then(|doc| documents.iter().position(|&d| d == doc)).unwrap_or(0);
+ let n = documents.len();
let cells = crate::view_cmds::cells(&app.ui.view.arrange, rect, n)?;
- let mut shown: Vec<(usize, Rect)> = (0..n).map(|k| (idx + k) % n).zip(cells).collect();
+ let mut shown: Vec<(usize, Rect)> = (0..n).map(|k| documents[(start + k) % n]).zip(cells).collect();
shown.sort_by_key(|(d, _)| *d);
Some(shown)
}
@@ -1996,6 +2013,12 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots);
return;
}
+ if n > 0 && !opening && docked_document_indices(app).is_empty() {
+ // A torn-off document belongs to its OS window. Leaving its canvas in the main
+ // viewport would make the same page appear twice, which is what “New Window” does.
+ paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots);
+ return;
+ }
if !app.ui.view.hides_tabs() || opening {
// Flat Spectrum tabs sit directly against the canvas. The parent's default vertical
// item spacing would otherwise leave a visible gap below the tab strip (#2188).
@@ -2011,7 +2034,12 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::jobs_ui::open_card(app, ui, job);
return;
}
- let Some(idx) = app.session.active_index() else { return };
+ let Some(active_doc) = app.session.active_index() else { return };
+ let Some(idx) = main_canvas_document(app) else {
+ paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots);
+ return;
+ };
+ let main_focused = ui.ctx().input(|i| i.viewport().focused.unwrap_or(true));
let rect = ui.available_rect_before_wrap();
app.last_canvas_rect = rect;
app.drop_canvas_rect = Some(rect);
@@ -2022,20 +2050,23 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
for (d, cell) in shown {
let cell = cell.shrink(1.0);
let view = app.ui.views[d].clone();
- let v = canvas_view(app, ui, d, cell, view, d == idx);
- if d != idx {
+ let v = canvas_view(app,
```
### #5650: Guides: ⌥ turns a dragged guide; dropping one on either ruler deletes it (#5034)
`crates/engine/src/edit_cmds.rs`, `crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/rulers.rs`, `crates/ui-egui/src/tools/move_hover.rs`, `docs/ui-parity.md`
```diff
diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs
index 20ef23a41..fec1c4fc8 100644
--- a/crates/engine/src/edit_cmds.rs
+++ b/crates/engine/src/edit_cmds.rs
@@ -838,7 +838,18 @@ fn guide_color(p: &Value) -> Result<Option<[u8; 3]>> {
/// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
- let pos = guide_position(s, p, vertical)?;
+ // Move Guide can turn the guide (⌥ while dragging it, as in Photoshop): `toOrientation`.
+ let to_vertical = match p.get("toOrientation").map(|v| v.as_str()) {
+ None => vertical,
+ Some(Some("vertical")) => true,
+ Some(Some("horizontal")) => false,
+ Some(_) => return Err(EngineError::Other("`toOrientation` must be \"horizontal\" or \"vertical\"".into())),
+ };
+ if to_vertical != vertical && op != "move" {
+ return Err(EngineError::Other("`toOrientation` applies to view.moveGuide only".into()));
+ }
+ // A % position is of the axis the guide ends up on.
+ let pos = guide_position(s, p, to_vertical)?;
let color = if op == "new" { guide_color(p)? } else { None };
let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize);
let label = match op {
@@ -861,10 +872,20 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
"move" => {
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
let to = pos.ok_or(EngineError::Other("missing `position`".into()))?;
- let from = std::mem::replace(&mut g[i], to);
+ let from = if to_vertical == vertical {
+ std::mem::replace(&mut g[i], to)
+ } else {
+ let from = g.remove(i);
+ if to_vertical {
+ guides.vertical.push(to)
+ } else {
+ guides.horizontal.push(to)
+ }
+ from
+ };
// The guide takes its colour along; another guide left at the old position keeps it.
if let Some(rgb) = guides.color(vertical, from) {
- guides.set_color(vertical, to, Some(rgb));
+ guides.set_color(to_vertical, to, Some(rgb));
}
guides.prune_colors();
}
@@ -1010,7 +1031,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Guide",
&[],
None,
- r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>"} (units as for view.newGuide)"##,
+ r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>","toOrientation":"horizontal|vertical"?} (units as for view.newGuide; toOrientation turns the guide, its position then on the new axis)"##,
has_doc,
|s, p| guide_cmd(s, p, "move")
),
@@ -1969,6 +1990,35 @@ mod tests {
assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err());
}
+ /// #5034: Move Guide turns a guide with `toOrientation` (⌥ while dragging it), in one undo
+ /// step, taking its colour along; a % position is of the new axis.
+
```
### #5515: Selecting subject now automatically applies to selected layer, and us…
`crates/engine/src/cmd/smartselect_cmds.rs`
```diff
diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs
index 380b6a720..6a2ee6199 100644
--- a/crates/engine/src/smartselect_cmds.rs
+++ b/crates/engine/src/smartselect_cmds.rs
@@ -294,7 +294,8 @@ impl SubjectCache {
}
fn select_subject(s: &mut Session, p: &Value) -> Result<Value> {
- let all_layers = b(p, "sampleAllLayers", true);
+ // The selected layer's pixels; the composite only when no pixel layer is selected.
+ let all_layers = b(p, "sampleAllLayers", false);
let d = s.active().ok_or(EngineError::NoDocument)?;
// What `with_doc_sampler` samples: the active layer's pixels, else the composite.
let target = d.active_layer.filter(|id| !all_layers && d.doc.layer(*id).and_then(Layer::surface).is_some());
@@ -516,7 +517,7 @@ pub fn specs() -> Vec<CommandSpec> {
"select.subject",
"Subject",
["Select"],
- r##"{"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##,
+ r##"{"sampleAllLayers":bool=false,"mode":"replace|add|subtract|intersect"="replace"} (samples the selected layer; the composite when no pixel layer is selected)"##,
has_doc,
select_subject
),
@@ -778,6 +779,23 @@ mod tests {
assert!(s.active().unwrap().doc.selection.is_none());
}
+ /// Select Subject looks at the selected layer only; with no layer selected it looks at the
+ /// whole image.
+ #[test]
+ fn select_subject_samples_the_selected_layer_else_the_composite() {
+ let (mut s, inside) = disc(8);
+ // A layer above holding only a small square in the corner: the disc is not on it.
+ s.execute("layer.new.layer", json!({})).unwrap();
+ s.execute("paint.pencil", json!({"points": [[10, 10], [30, 10]], "size": 3, "color": "#000000"})).unwrap();
+ s.execute("select.subject", json!({})).unwrap();
+ assert!(cov(&s, 100, 80) < 0.5, "the disc on the layer below was selected");
+ s.execute("select.deselect", json!({})).unwrap();
+ // No layer selected: the whole image, so the disc is found.
+ s.execute("select.deselectLayers", json!({})).unwrap();
+ s.execute("select.subject", json!({})).unwrap();
+ assert!(sel_iou(&s, 200, 160, &inside) > 0.9);
+ }
+
/// #2865: a repeat on unchanged pixels reuses the last mask (an identical selection, even
/// after Deselect); sampling something else or a pixel edit computes it again.
#[test]
@@ -795,8 +813,8 @@ mod tests {
s.execute("select.subject", json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&mask, &cached(&s)), "the repeat recomputed the mask");
assert_eq!(sel(&s), first);
- // Another sampled target (the layer rather than the composite) is a miss.
- s.execute("select.subject", json!({"sampleAllLayers": false})).unwrap();
+ // Another sampled target (the composite rather than the layer) is a miss.
+ s.execute("select.subject", json!({"sampleAllLayers": true})).unwrap();
assert!(!std::sync::Arc::ptr_eq(&mask, &cached(&s)));
// A pixel edit drops the entry; the next run recomputes it.
s.edit("paint", |doc, _| {
```
### #5493: Import Photoshop custom shape sets into the Shapes panel
`docs/file-format-parity.md`, `docs/gaps.md`
### #5453: Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1
`crates/compose/src/lib.rs`, `crates/compose/src/shape_split.rs`, `crates/engine/src/vector_cmds.rs`, `crates/gpu/src/plan.rs`, `crates/vector/src/lib.rs`, `docs/control-protocol.md`
```diff
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 8813b122d..323f243e9 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1445,7 +1445,7 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option
fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
let LayerContent::Shape(sh) = &layer.content else { return None };
sh.stroke.as_ref()?;
- let (fs, ss) = shape_split::split(sh, cx.canvas, cx.depth)?;
+ let (fs, ss) = shape_split::split_with_patterns(sh, cx.canvas, cx.depth, cx.patterns.source(), cx.mode)?;
Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
}
diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs
index 9f0143f88..d541de7ec 100644
--- a/crates/compose/src/shape_split.rs
+++ b/crates/compose/src/shape_split.rs
@@ -6,14 +6,15 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Mutex, OnceLock};
-use photocraft_color::{PixelFormat, SampleType};
+use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::vector::ShapeLayer;
+use photocraft_doc::{Fill, Pattern};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
/// their tile addresses, part of the key, can't be reused).
-type Entry = ((Surface, Surface), u64, Option<Surface>);
+type Entry = ((Surface, Surface), u64, Option<Surface>, Vec<Pattern>);
fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new();
@@ -21,24 +22,26 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
}
const CAPACITY: usize = 32;
-/// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces,
-/// so 32 of them at 24 MP could hold several GB outside every memory budget.
+/// Most pixel bytes held by split parts and their pinned patterns (#4530).
#[cfg(not(test))]
const BUDGET: usize = 256 << 20;
/// Small under test, so a few 512² shapes exercise the eviction.
#[cfg(test)]
const BUDGET: usize = 8 << 20;
-/// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's).
-fn entry_bytes(parts: &(Surface, Surface)) -> usize {
- [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::<usize>()).sum()
+/// Pixel bytes held by one entry, including pinned source tiles. Shared pattern tiles count once
+/// per entry, conservatively matching their retained lifetime without cross-entry bookkeeping.
+fn entry_bytes(parts: &(Surface, Surface), patterns: &[Pattern]) -> usize {
+ let part_bytes = [&parts.0, &parts.1].iter().flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add);
+ let pattern_bytes = patterns.iter().flat_map(|p| p.surface.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add);
+ part_bytes.saturating_add(pattern_bytes)
}
-/// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may
-/// still hold returned surfaces, so this is not a measure of freed process memory.
+/// Drop every cached split (Edit › Purge › All), returning accounted pixel bytes. Callers may
+/// still hold returned surfaces and patterns, so this is not a measure of freed p
```
### #5270: Layer Style: Gradient Overlay takes any number of colour stops (#5177)
`crates/engine/src/layer_style.rs`, `crates/ui-egui/src/control.rs`, `crates/ui-egui/src/dialogs/layer_style.rs`, `crates/ui-egui/src/dialogs/layer_style/gradient_field.rs`, `docs/control-protocol.md`
```diff
diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs
index fbbc949e2..23b0e4f99 100644
--- a/crates/engine/src/layer_style.rs
+++ b/crates/engine/src/layer_style.rs
@@ -64,9 +64,16 @@ fn gradient_stops(p: &Value, from: Color, to: Color) -> Vec<(f32, Color)> {
None => stops,
}
}
+/// The gradient of a stroke or overlay's params. Bad `stops` are ignored here, as bad values of
+/// any other key are; the commands refuse them first ([`check_gradient_params`]).
fn gradient(p: &Value) -> Gradient {
+ let stops = match stops_param(p, "layer.layerStyle").ok().flatten() {
+ Some(stops) => stops,
+ None => gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])),
+ };
Gradient {
- stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])),
+ stops,
+ opacity_stops: opacity_stops_param(p, "layer.layerStyle").ok().flatten().unwrap_or_default(),
style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")),
angle: f(p, "angle", 90.0),
scale: f(p, "scale", 100.0) / 100.0,
@@ -75,6 +82,61 @@ fn gradient(p: &Value) -> Gradient {
}
}
+/// `Err` when `stops` or `opacityStops` are present but malformed, so a command refuses them
+/// instead of silently keeping the old gradient.
+pub fn check_gradient_params(p: &Value, cmd: &str) -> Result<()> {
+ stops_param(p, cmd)?;
+ opacity_stops_param(p, cmd)?;
+ Ok(())
+}
+
+/// Most stops a gradient parameter may carry.
+const MAX_STOPS: usize = 256;
+
+/// Explicit colour stops (#5177): `"stops": [[position 0..1, "#rrggbb" | [r, g, b]], …]`, at
+/// least 2 and at most [`MAX_STOPS`], returned sorted by position. `None` without the key.
+fn stops_param(p: &Value, cmd: &str) -> Result<Option<Vec<(f32, Color)>>> {
+ let Some(v) = p.get("stops") else { return Ok(None) };
+ let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg };
+ let a = v.as_array().ok_or_else(|| bad("\"stops\" must be an array of [position, colour]".into()))?;
+ if a.len() < 2 {
+ return Err(bad("\"stops\" needs at least 2 stops".into()));
+ }
+ if a.len() > MAX_STOPS {
+ return Err(bad(format!("\"stops\": at most {MAX_STOPS} stops")));
+ }
+ let mut stops = Vec::with_capacity(a.len());
+ for (i, s) in a.iter().enumerate() {
+ let t = s.get(0).and_then(Value::as_f64).filter(|t| t.is_finite()).ok_or_else(|| bad(format!("\"stops\"[{i}] must be [position 0..1, colour]")))?;
+ let c = s.get(1).and_then(|c| color_of(&json!({"c": c}), "c")).ok_or_else(|| bad(format!("\"stops\"[{i}]: no colour (\"#rrggbb\" or [r, g, b])")))?;
+ stops.push((t.clamp(0.0, 1.0) as f32, c));
+ }
+ stops.sort_by(|a, b| a.0.total_cmp(&b.0));
+ Ok(Some(stops))
+}
+
+/// Explicit transparency stops: `"opacityStops": [[position 0..1, opacity 0..100], …]`; an empty
+/// array means fully opaque. `None` without the key.
+fn opacity_stops_param(p: &Value, cmd: &str) -> Result<Option<Vec<(f32, f32)>>> {
+ let Some(v) = p.get("opacityStops") else { return Ok(None) };
+ let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg };
+ let a = v.as_array().ok_or_else(|| bad("\"opacityStops\" must be an array of [position, opacity]".into()))?;
+ if a.len() > MAX_STOPS {
+ return Err(bad(format!("\"opacityStops\": at most {MAX_STOPS} stops")));
+ }
+ if a.len() == 1 {
+ return E
```
### #5176: Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale
`crates/engine/src/actions_cmds.rs`, `crates/engine/src/atn_cmds.rs`, `crates/engine/src/atn_cmds/tests.rs`, `crates/engine/src/cmd.rs`, `crates/engine/src/cmd/history_cmds.rs`, `crates/engine/src/cmd/pattern_cmds.rs`, `crates/engine/src/lib.rs`, `crates/engine/src/preset_store.rs`, `crates/engine/src/preset_store/tests.rs`, `crates/engine/src/smart_cmds.rs`, `crates/ui-egui/src/actions.rs`, `crates/ui-egui/src/panels/preset_panels.rs`, `docs/file-format-parity.md`, `docs/gaps.md`, `docs/target-app-parity.md`
```diff
diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index d881a6112..0eb136f23 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -128,6 +128,14 @@ pub fn shell_save_command(id: &str) -> bool {
matches!(id, "file.save" | "file.saveAs" | "file.saveACopy")
}
+/// Save in an Edit Contents document writes it back into its smart object, as File › Save does
+/// there: the step needs no file and plays headless too.
+pub fn saves_smart_contents(s: &Session, id: &str) -> bool {
+ id == "file.save" && s.is_enabled(SAVE_CONTENTS)
+}
+
+pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents";
+
/// View-only menu commands recorded by the desktop shell. Headless playback reports
/// them as unsupported instead of silently dropping them; Batch and droplets skip them.
pub fn shell_view_command(id: &str) -> bool {
@@ -317,7 +325,8 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
break;
}
- match s.execute(id, params.clone()) {
+ let result = if saves_smart_contents(s, id) { s.execute(SAVE_CONTENTS, json!({})) } else { s.execute(id, params.clone()) };
+ match result {
Ok(value) => {
if let Some(error) = nested_failure(id, &value) {
failed = Some(json!({"step": i, "id": id, "error": error}));
@@ -692,6 +701,30 @@ mod tests {
s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
}
+ /// CopyCat's shape: Edit Contents, change the contents, Save. The Save step has no file to
+ /// write: it updates the smart object, headless too.
+ #[test]
+ fn save_in_edit_contents_updates_the_smart_object() {
+ let mut s = Session::new();
+ s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
+ s.execute("layer.new.layer", json!({})).unwrap();
+ s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
+ s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
+ s.execute("layer.smartObjects.editContents", json!({})).unwrap();
+ let steps = vec![("edit.fill".to_string(), json!({"contents": "color", "color": "#0000ff"})), ("file.save".to_string(), json!({}))];
+ s.actions.list.push(Action::new("Edit and save", steps));
+ let played = s.execute("actions.play", json!({"action": "Edit and save"})).unwrap();
+ assert!(played.get("failed").is_none(), "{played}");
+ s.set_active(0);
+ let doc = &s.active().unwrap().doc;
+ let flat = crate::file_cmds::flattened(doc, doc.pixel_format());
+ let p = flat.pixel(2, 2);
+ assert!(p[0] < 0.01 && p[2] > 0.99, "the smart object shows the saved contents: {p:?}");
+ // With no Edit Contents document, Save stays the shell's (it needs the document's file).
+ s.actions.list.push(Action::new("Save", vec![("file.save".to_string(), json!({}))]));
+ assert!(s.execute("actions.play", json!({"action": "Save"})).unwrap().get("failed").is_some());
+ }
+
#[test]
fn unchecked_steps_are_skipped_and_follow_move_and_delete() {
let mut s = Session::new();
diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs
index ebeda4868..6d2793a22 100644
--- a/crates/engine/src/atn_cmds.rs
+++ b/crates/engin
```
### #5090: Actions: add editable PhotoCraft Essentials starter presets
`crates/engine/src/actions_cmds.rs`, `crates/engine/src/lib.rs`, `crates/engine/src/preset_store.rs`, `crates/ui-egui/src/actions.rs`
```diff
diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index d881a6112..445a67675 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -78,6 +78,9 @@ impl Action {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ActionsFile {
pub version: u32,
+ /// Starter set last seeded. Older stores default to zero; deletions after seeding persist.
+ #[serde(default)]
+ pub defaults_version: u32,
pub actions: Vec<Action>,
}
@@ -1386,8 +1389,8 @@ mod tests {
let mut t = Session::new();
t.attach_preset_store(crate::preset_store::open_dir(&dir));
let listed = t.execute("actions.list", json!({})).unwrap();
- assert_eq!(listed["actions"][0]["name"], "Kept");
- assert_eq!(listed["actions"][0]["steps"], 1);
+ let kept = listed["actions"].as_array().unwrap().iter().find(|a| a["name"] == "Kept").unwrap();
+ assert_eq!(kept["steps"], 1);
assert_eq!(t.execute("actions.get", json!({"action": "Kept"})).unwrap()["steps"][0][0], "layer.new.layer");
// An action created before the store finishes loading is kept, and a different one on
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index d2aecf37d..5eb66d860 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+pub mod action_defaults;
pub mod actions_cmds;
pub mod actions_edit_cmds;
pub mod adjust_cmds;
@@ -17,6 +18,7 @@ pub mod analysis_cmds;
pub mod artboard_cmds;
pub mod atn_cmds;
pub mod automate_cmds;
+pub mod background_blur_cmds;
pub mod background_cmds;
pub mod brush_cmds;
pub mod brush_key_cmds;
diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs
index 83cb16a83..a52d33827 100644
--- a/crates/engine/src/preset_store.rs
+++ b/crates/engine/src/preset_store.rs
@@ -548,6 +548,8 @@ pub struct PresetStore {
index: Option<IndexFile>,
/// [`crate::actions_cmds::ActionState::rev`] last written (or loaded).
actions_rev: u64,
+ /// Starter set last offered to this store, independent of later user deletions or renames.
+ actions_defaults_version: u32,
/// `actions.json` exists but failed to load: left on disk untouched, so it is never written.
actions_skipped: bool,
warnings: Vec<String>,
@@ -643,7 +645,7 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
let loaded_actions = load_actions(backend.as_ref(), &mut warnings);
let actions_skipped = loaded_actions.is_none();
- let actions = loaded_actions.unwrap_or_default();
+ let (actions, actions_defaults_version) = loaded_actions.map(|f| (f.actions, f.defaults_version)).unwrap_or_default();
let mut store = PresetStore {
backend,
synced_rev: None,
@@ -652,6 +654,7 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
tips: HashMap::new(),
index: None,
actions_rev: 0,
+ actions_defaults_version,
actions_skipped,
warnings: Vec::new(),
cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }),
@@ -715,17 +718,17 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
/// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning.
/// The saved actio
```
### #5082: fix(mixer-brush): Alt-click samples the foreground colour like the Brush (#4873)
`crates/engine/src/cmd/brush_cmds.rs`, `crates/paint/src/mixer.rs`, `crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/input/alt_grab.rs`
```diff
diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs
index afb8092cd..e4668638e 100644
--- a/crates/paint/src/mixer.rs
+++ b/crates/paint/src/mixer.rs
@@ -44,6 +44,9 @@ pub struct MixerState {
pub amount: f32,
/// Colour picked up from the canvas (straight RGBA), None = clean.
pub pickup: Option<[f32; 4]>,
+ /// The foreground colour in effect when the reservoir was loaded from the canvas
+ /// (`load_sampled`), None when it holds the foreground itself.
+ pub sampled_over: Option<[f32; 4]>,
}
impl MixerState {
@@ -51,6 +54,17 @@ impl MixerState {
pub fn load(&mut self, color: [f32; 4]) {
self.reservoir = Some(color);
self.amount = 1.0;
+ self.sampled_over = None;
+ }
+ /// Load the reservoir with a colour sampled from the canvas (⌥-click) while `foreground` is
+ /// the foreground colour; `holds_sample` stays true until the foreground changes.
+ pub fn load_sampled(&mut self, color: [f32; 4], foreground: [f32; 4]) {
+ self.load(color);
+ self.sampled_over = Some(foreground);
+ }
+ /// Whether the reservoir holds a canvas sample taken while `foreground` was the foreground.
+ pub fn holds_sample(&self, foreground: [f32; 4]) -> bool {
+ self.reservoir.is_some() && self.sampled_over == Some(foreground)
}
/// Clean the brush (drop the picked-up colour).
pub fn clean(&mut self) {
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 91644b5f7..19e1737f8 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -4017,7 +4017,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None,
// ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too,
// unless Preferences › Cursors › Other Cursors asks for the precise crosshair.
- t if app.alt_sampling || (app.drag.is_none() && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => {
+ t if app.alt_sampling || (app.drag.is_none() && alt_picks(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => {
// While it samples (button held), the comparison ring replaces the cursor (#213).
if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) {
icon
@@ -5056,6 +5056,18 @@ pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool {
mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket)
}
+/// ⌥ with the Mixer Brush loads its reservoir from the canvas (`paint.mixerLoad`), as in
+/// Photoshop, instead of setting the foreground colour (#4873). It shows the Eyedropper cursor.
+pub(crate) fn alt_loads_mixer(tool: Tool, mods: egui::Modifiers) -> bool {
+ // Control+Alt is the brush-resize drag (`brush_resize`), not sampling.
+ mods.alt && !mods.ctrl && tool == Tool::MixerBrush
+}
+
+/// Tools whose ⌥ press is a sample: the Eyedropper cursor shows and no stroke starts.
+pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool {
+ alt_samples(tool, mods) || alt_loads_mixer(tool, mods)
+}
+
/// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the
/// standard cu
```
### #5010: Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938]
`crates/ui-egui/src/dialogs.rs`, `crates/ui-egui/src/dialogs/layer_style.rs`, `crates/ui-egui/src/panels.rs`, `crates/ui-egui/src/widgets.rs`
```diff
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 013c79e82..2b577b32d 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -108,11 +108,42 @@ fn resizable_dialog(fields: &serde_json::Map<String, Value>) -> bool {
fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields)
}
+/// How far the title band reaches past the title into the frame's padding (#1921).
+const TITLE_REACH: f32 = 7.0;
+
+/// The size of a dialog whose body fills the size it is given, rather than setting it.
+struct Sizing {
+ /// Where egui keeps the size.
+ id: egui::Id,
+ /// At most the window, less a margin.
+ default: egui::Vec2,
+ /// Also the floor on a window smaller than that.
+ min: egui::Vec2,
+ /// A grip in the bottom-right corner resizes it.
+ resizable: bool,
+ /// Outline the sized area.
+ stroke: bool,
+}
+
+/// The dialogs sized here: the web page viewer and Preferences (resizable), and Layer Style, which
+/// keeps one size on every page like Photoshop's (not resizable there either); its effect list and
+/// settings page scroll when they are taller, so OK and Cancel stay on a short screen.
+fn sizing_of(d: &Dialog, id: egui::Id) -> Option<Sizing> {
+ let id = id.with("resize");
+ if resizable_dialog(&d.fields) {
+ Some(Sizing { id, default: egui::vec2(960.0, 900.0), min: egui::vec2(560.0, 400.0), resizable: true, stroke: true })
+ } else if d.kind == DialogKind::LayerStyle {
+ // Photoshop's shape: wide and short, with OK and Cancel in a column on the right.
+ Some(Sizing { id, default: egui::vec2(800.0, 550.0), min: egui::vec2(640.0, 360.0), resizable: false, stroke: false })
+ } else {
+ None
+ }
+}
+
fn show_modal<T>(
modal: egui::Modal,
ctx: &egui::Context,
- id: egui::Id,
- resizable: bool,
+ sizing: Option<&Sizing>,
interactive: bool,
contents: impl FnOnce(&mut egui::Ui) -> T,
) -> egui::ModalResponse<T> {
@@ -121,14 +152,17 @@ fn show_modal<T>(
if !interactive {
ui.disable();
}
- if resizable {
- let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0));
- egui::Resize::default()
- .id(id.with("resize"))
- .default_size(egui::vec2(960.0, 900.0).min(max))
- .min_size(egui::vec2(560.0, 400.0))
- .max_size(max)
- .show(ui, contents)
+ if let Some(s) = sizing {
+ let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(s.min);
+ let resize = egui::Resize::default().id(s.id).with_stroke(s.stroke);
+ let resize =
+ if s.resizable { resize.default_size(s.default.min(max)).min_size(s.min).max_size(max) } else { resize.fixed_size(s.default.min(max)) };
+ resize.show(ui, |ui| {
+ // The Resize clips to its area, which cut off the title band's reach into the
+ // frame's margin (#1921): only the title band is drawn or hit there.
+ ui.set_clip_rect(ui.clip_rect().expand(TITLE_REACH + 1.0));
+ contents(ui)
+ })
} else {
contents(ui)
}
@@ -207,7 +241,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast.
let web
```
### #4993: Prevent control-channel engine commands from editing behind modal dialogs
`crates/ui-egui/src/control.rs`
```diff
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index e797b27cc..4fc782442 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -407,18 +407,16 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
"engine.execute" | "ui.menu.invoke" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().unwrap_or(json!({}));
- // A control-channel menu click must respect the same modal gate as the native menu.
- // Camera Raw's documented `ui` requests operate its already-open dialog rather
- // than click a menu. Only that continuation may pass its own modal gate.
- // engine.execute is deliberately not subject to the UI's menu-click semantics.
+ // Both command entry points must respect open modal dialogs; Camera Raw's
+ // documented `ui` continuation acts on that dialog rather than the document.
let camera_raw_continuation = id == "filter.cameraRaw"
&& app.camera_raw.is_some()
&& params.as_object().is_some_and(|fields| fields.len() == 1 && fields.get("ui").is_some_and(Value::is_object))
&& app.ui.dialogs.is_empty()
&& app.discard.is_none()
&& !crate::crop_ui::blocks(app, id);
- if req.method == "ui.menu.invoke" && !camera_raw_continuation && !crate::menus::modal_allows(app, id) {
- return err("menu command is unavailable while a modal dialog is open");
+ if !camera_raw_continuation && !crate::menus::modal_allows(app, id) {
+ return err("command is unavailable while a modal dialog is open");
}
if let Some(authorize) = app.services.automation_command.as_ref()
&& let Err(error) = authorize(id, &params)
@@ -1842,6 +1840,31 @@ mod tests {
assert!(app.session.active().unwrap().revision > revision);
}
+ #[test]
+ fn engine_execute_cannot_change_document_behind_adjustment_dialog() {
+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+ let ctx = egui::Context::default();
+ app.run("file.new", json!({"width": 60, "height": 40, "background": "#3366cc"})).unwrap();
+ let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id": "image.adjustments.hueSaturation"}));
+ assert_eq!(opened["ok"], true, "{opened}");
+ let dialog = opened["result"]["dialog"].as_u64().unwrap();
+ let revision = app.session.active().unwrap().revision;
+ let fields = call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["dialogs"][0]["fields"].clone();
+
+ for method in ["engine.execute", "ui.menu.invoke"] {
+ let blocked = call(&mut app, &ctx, method, json!({"command": "image.adjustments.invert"}));
+ assert_eq!(blocked["ok"], false, "{method}: {blocked}");
+ assert_eq!(app.session.active().unwrap().revision, revision, "{method} changed the document");
+ let snapshot = call(&mut app, &ctx, "ui.inspect", json!({}));
+ assert_eq!(snapshot["result"]["dialogs"][0]["id"], dialog);
+ assert_eq!(snapshot["result"]["dialogs"][0]["fields"], fields);
+ }
+
+ assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"]
```
### #4992: Preserve Dissolve coverage when compositing clipped layers
`crates/compose/src/lib.rs`, `crates/compose/src/tests.rs`, `crates/gpu/src/compose.wgsl`, `crates/gpu/tests/it/parity.rs`, `crates/ui-egui/src/rotate_view.rs`, `crates/ui-egui/src/wheel_nav.rs`
```diff
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 8e88675fd..3fbf3c449 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1736,14 +1736,23 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
let Some(content) = render_content(layer, rect, cx) else { return };
let opacity = layer.opacity * layer.fill_opacity;
let gamma = text_gamma(layer);
+ let width = rect.width() as usize;
for (i, p) in base.px.iter_mut().enumerate() {
let alpha = p[3];
if alpha <= 0.0 {
continue;
}
- let s = content.px[i];
+ let mut s = content.px[i];
// Blend as if the base were opaque, then keep the base's alpha.
- let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma);
+ // Dissolve needs a binary coverage decision before compositing.
+ let r = if layer.blend == BlendMode::Dissolve {
+ let x = rect.x0 + (i % width) as i32;
+ let y = rect.y0 + (i / width) as i32;
+ s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 };
+ blend::composite(BlendMode::Normal, [p[0], p[1], p[2], 1.0], s, 1.0)
+ } else {
+ blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma)
+ };
*p = [r[0], r[1], r[2], alpha];
}
}
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 6680f11a9..27ddbcc05 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -316,6 +316,35 @@ fn clipping_mask_restricts_to_base_alpha() {
assert!(close4(px(&d, 6, 1), [1.0; 4]), "outside base: clipped layer hidden");
}
+/// #4921: Dissolve coverage must survive clipping and use Opacity × Fill.
+#[test]
+fn clipped_dissolve_is_binary_and_preserves_the_base_shape() {
+ let mut d = doc_white(40, 12);
+ d.layers.push(solid_layer("base", Rect::new(0, 0, 20, 12), [0.0, 0.0, 1.0, 1.0]));
+ let mut clip = solid_layer("clip", d.bounds(), [1.0, 0.0, 0.0, 1.0]);
+ clip.clipped = true;
+ clip.blend = BlendMode::Dissolve;
+ clip.opacity = 0.6;
+ clip.fill_opacity = 0.5;
+ d.layers.push(clip);
+ let mut red = 0;
+ let mut blue = 0;
+ for y in 0..12 {
+ for x in 0..20 {
+ let p = px(&d, x, y);
+ if close4(p, [1.0, 0.0, 0.0, 1.0]) {
+ red += 1;
+ } else {
+ assert!(close4(p, [0.0, 0.0, 1.0, 1.0]), "Dissolve must not interpolate: {p:?} at ({x},{y})");
+ blue += 1;
+ }
+ }
+ assert!(close4(px(&d, 30, y), [1.0; 4]), "outside the base the clip is invisible");
+ }
+ assert!(red > 0 && blue > 0, "Dissolve must expose both source and base: {red}/{blue}");
+ assert!((red as f32 / (red + blue) as f32 - 0.3).abs() < 0.1, "coverage should follow 60% opacity × 50% fill: {red}/{blue}");
+}
+
#[test]
fn hidden_base_hides_clipping_group() {
let mut d = doc_white(4, 4);
@@ -328,6 +357,33 @@ fn hidden_base_hides_clipping_group() {
assert!(close4(px(&d, 1, 1), [1.0; 4]));
}
+#[test]
+fn clipped_dissolve_matches_whole_render_at_offset_regions_and_depths() {
+ for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
+ let mut d = Document::new("coverage", Size::new(32, 18), ColorMode::Rgb, fmt.sample);
+ let bounds = Rect::new(-3, -2, 32, 18);
+ let mut base = Layer::rast
```
### #4938: Layer Style: one dialog size on every page, and it fits short screens
`crates/ui-egui/src/dialogs.rs`, `crates/ui-egui/src/dialogs/layer_style.rs`
```diff
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 013c79e82..e40288aab 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -108,11 +108,42 @@ fn resizable_dialog(fields: &serde_json::Map<String, Value>) -> bool {
fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields)
}
+/// How far the title band reaches past the title into the frame's padding (#1921).
+const TITLE_REACH: f32 = 7.0;
+
+/// The size of a dialog whose body fills the size it is given, rather than setting it.
+struct Sizing {
+ /// Where egui keeps the size.
+ id: egui::Id,
+ /// At most the window, less a margin.
+ default: egui::Vec2,
+ /// Also the floor on a window smaller than that.
+ min: egui::Vec2,
+ /// A grip in the bottom-right corner resizes it.
+ resizable: bool,
+ /// Outline the sized area.
+ stroke: bool,
+}
+
+/// The dialogs sized here: the web page viewer and Preferences (resizable), and Layer Style, which
+/// keeps one size on every page like Photoshop's (not resizable there either); its effect list and
+/// settings page scroll when they are taller, so OK and Cancel stay on a short screen.
+fn sizing_of(d: &Dialog, id: egui::Id) -> Option<Sizing> {
+ let id = id.with("resize");
+ if resizable_dialog(&d.fields) {
+ Some(Sizing { id, default: egui::vec2(960.0, 900.0), min: egui::vec2(560.0, 400.0), resizable: true, stroke: true })
+ } else if d.kind == DialogKind::LayerStyle {
+ // Room for the tallest page (Bevel & Emboss) on a 900-point screen.
+ Some(Sizing { id, default: egui::vec2(600.0, 760.0), min: egui::vec2(540.0, 380.0), resizable: false, stroke: false })
+ } else {
+ None
+ }
+}
+
fn show_modal<T>(
modal: egui::Modal,
ctx: &egui::Context,
- id: egui::Id,
- resizable: bool,
+ sizing: Option<&Sizing>,
interactive: bool,
contents: impl FnOnce(&mut egui::Ui) -> T,
) -> egui::ModalResponse<T> {
@@ -121,14 +152,17 @@ fn show_modal<T>(
if !interactive {
ui.disable();
}
- if resizable {
- let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0));
- egui::Resize::default()
- .id(id.with("resize"))
- .default_size(egui::vec2(960.0, 900.0).min(max))
- .min_size(egui::vec2(560.0, 400.0))
- .max_size(max)
- .show(ui, contents)
+ if let Some(s) = sizing {
+ let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(s.min);
+ let resize = egui::Resize::default().id(s.id).with_stroke(s.stroke);
+ let resize =
+ if s.resizable { resize.default_size(s.default.min(max)).min_size(s.min).max_size(max) } else { resize.fixed_size(s.default.min(max)) };
+ resize.show(ui, |ui| {
+ // The Resize clips to its area, which cut off the title band's reach into the
+ // frame's margin (#1921): only the title band is drawn or hit there.
+ ui.set_clip_rect(ui.clip_rect().expand(TITLE_REACH + 1.0));
+ contents(ui)
+ })
} else {
contents(ui)
}
@@ -207,7 +241,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast.
let web = fields.cont
```
### #4890: Add scrollbar visibility preferences (#3262)
`crates/engine/src/prefs.rs`, `crates/engine/src/prefs/tests.rs`, `crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/prefs_ui.rs`, `crates/ui-egui/src/theme.rs`, `docs/gaps.md`, `docs/scorecard.md`, `docs/ui-parity.md`
```diff
diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
index 378f71c18..247ad5e13 100644
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -83,6 +83,7 @@ choice!(
LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto
);
choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small);
+choice!(ScrollbarVisibility { Default = "default", Always = "always", AutoHide = "autoHide", Hidden = "hidden" } default Default);
choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata);
choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise);
choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always);
@@ -265,6 +266,8 @@ pub struct Interface {
/// Command ids and the control protocol stay English.
pub language: String,
pub ui_font_size: UiFontSize,
+ /// Default follows the theme; other modes apply to panel and canvas scrollbars (#3262).
+ pub scrollbar_visibility: ScrollbarVisibility,
pub show_channels_in_color: bool,
pub dynamic_color_sliders: bool,
/// Draw menu item colours set with Edit › Menus.
@@ -302,6 +305,7 @@ impl Default for Interface {
ui_scale: UiScale::Auto,
language: "auto".into(),
ui_font_size: UiFontSize::Small,
+ scrollbar_visibility: ScrollbarVisibility::Default,
show_channels_in_color: false,
dynamic_color_sliders: true,
show_menu_colors: true,
@@ -1101,6 +1105,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
"interface.canvasBorder" => CanvasBorder::NAMES,
"interface.uiScale" => UiScale::NAMES,
"interface.uiFontSize" => UiFontSize::NAMES,
+ "interface.scrollbarVisibility" => ScrollbarVisibility::NAMES,
"historyLog.destination" => LogDestination::NAMES,
"historyLog.detail" => LogDetail::NAMES,
"fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES,
diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs
index 6bb34a970..704e5cc43 100644
--- a/crates/engine/src/prefs/tests.rs
+++ b/crates/engine/src/prefs/tests.rs
@@ -22,6 +22,26 @@ fn defaults_match_photoshop() {
}
}
+#[test]
+fn scrollbar_visibility_round_trips_and_old_preferences_keep_the_theme_default() {
+ let old = serde_json::from_value::<Preferences>(json!({"interface": {"uiFontSize": "small"}})).unwrap();
+ assert_eq!(old.interface.scrollbar_visibility, ScrollbarVisibility::Default);
+ assert_eq!(choices("interface.scrollbarVisibility"), Some(ScrollbarVisibility::NAMES));
+ let mut s = Session::new();
+ for mode in ScrollbarVisibility::NAMES {
+ s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": mode}})).unwrap();
+ let saved = s.prefs().to_json();
+ let loaded: Preferences = serde_json::from_value(saved.clone()).unwrap();
+ assert_eq!(loaded.interface.scrollbar_visibility, s.prefs().interface.scrollbar_visibility);
+ assert_eq!(saved["interface"]["scrollbarVisibility"], json!(mode));
+ }
+ let before = s.prefs().to_json();
+ assert!(s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": "sometimes"}})).is_err());
+ assert_eq!(s.prefs().to_json(), before);
+ s.execute("prefs.reset", json!({"
```
### #4783: Fix/4758 type extreme finite geometry 20261013s1215
`crates/io/src/blocks.rs`, `crates/io/tests/it/psd_type_bounds.rs`, `crates/text/src/psd.rs`
```diff
diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs
index 87fda102f..5541fd207 100644
--- a/crates/io/src/blocks.rs
+++ b/crates/io/src/blocks.rs
@@ -578,6 +578,11 @@ pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> {
for (i, v) in m.iter_mut().enumerate() {
*v = f(2 + i * 8)?;
}
+ // This best-effort fallback cannot bypass the text parser's geometry safety gate.
+ // Unusable matrices use identity instead of corrupting canvas or panel rectangles.
+ if !photocraft_text::psd::usable_tysh_transform(&m) {
+ return None;
+ }
// text version (2) at 50, descriptor version (4) at 52, descriptor at 56.
let rest = data.get(52..)?;
let text = parse_prefix_versioned(rest).and_then(|d| match d.get("Txt ") {
@@ -1247,6 +1252,20 @@ mod more_tests {
assert!(effects_enabled(&[0, 0, 0, 0]));
}
+ #[test]
+ fn tysh_fallback_cannot_reintroduce_unusable_finite_or_nonfinite_transform() {
+ let mut tysh = vec![0u8; 52];
+ tysh[..2].copy_from_slice(&1u16.to_be_bytes());
+ for (i, value) in [1.0f64, 0.0, 0.0, 1.0, 30.0, 40.0].iter().enumerate() {
+ tysh[2 + 8 * i..10 + 8 * i].copy_from_slice(&value.to_be_bytes());
+ }
+ assert!(parse_tysh(&tysh).is_some(), "normal matrices still import");
+ for value in [1e62f64, -1e62, f64::INFINITY, f64::NAN] {
+ tysh[2..10].copy_from_slice(&value.to_be_bytes());
+ assert!(parse_tysh(&tysh).is_none(), "invalid matrix {value} cannot reach UI");
+ }
+ }
+
#[test]
fn tysh_garbage_is_none() {
assert!(parse_tysh(&[0; 10]).is_none());
diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs
index 8c502c75a..b9c40dac4 100644
--- a/crates/text/src/psd.rs
+++ b/crates/text/src/psd.rs
@@ -31,12 +31,22 @@ pub struct TySh {
}
/// Parses a `TySh` block.
+/// Return whether a TySh affine can safely be passed into text layout and UI geometry.
+pub fn usable_tysh_transform(matrix: &[f64; 6]) -> bool {
+ matrix.iter().all(|v| v.is_finite() && v.abs() <= 1.0e9)
+}
+
pub fn parse_tysh(data: &[u8]) -> Option<TySh> {
let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8])));
let mut m = [0.0; 6];
for (i, v) in m.iter_mut().enumerate() {
*v = f(2 + i * 8)?;
}
+ // A plausible type matrix must not create enormous or nonfinite geometry.
+ // The original byte layout is still retained for lossless PSD round-trips.
+ if !usable_tysh_transform(&m) {
+ return None;
+ }
let (text, used) = VersionedDescriptor::parse_prefix(data.get(52..)?).ok()?;
let rest = data.get(52 + used..).unwrap_or(&[]);
let (warp, wused) = match rest.get(2..).and_then(|r| VersionedDescriptor::parse_prefix(r).ok()) {
@@ -381,6 +391,9 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
let [a, b, c, d, e, f] = layer.transform.m;
layer.transform.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
}
+ if !usable_tysh_transform(&layer.transform.m) {
+ return None;
+ }
layer.sync_summary();
Some(layer)
}
@@ -410,6 +423,9 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> {
return None;
}
let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f];
+ if !shifted.iter().all(|v| v.is_finite()) {
+ return None;
+ }
let origin_distance = e
```
### #4763: refactor(liquify): reuse native brush resizing and anchored HUD feedback
`crates/ui-egui/src/brush_resize.rs`, `crates/ui-egui/src/dialogs/liquify_ui.rs`
```diff
diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs
index dfba8e516..297e7fc48 100644
--- a/crates/ui-egui/src/brush_resize.rs
+++ b/crates/ui-egui/src/brush_resize.rs
@@ -170,7 +170,7 @@ fn preview_tint(app: &PhotocraftApp) -> Color32 {
/// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear
/// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush.
-fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
+pub(crate) fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
let clip = painter.clip_rect();
if !clip.expand(radius).contains(c) {
return;
```
### #4711: Treat Dissolve as a per-pixel choice
`crates/compose/src/effects.rs`, `crates/compose/src/lib.rs`, `crates/compose/src/tests.rs`, `crates/gpu/src/compose.wgsl`, `crates/gpu/tests/it/parity.rs`
```diff
diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs
index 5acdd7931..4728e8d46 100644
--- a/crates/compose/src/effects.rs
+++ b/crates/compose/src/effects.rs
@@ -2172,7 +2172,8 @@ pub(crate) fn composite_with_effects_prepared(
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
let a = before.px[i];
let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a };
- backdrop.px[i] = mix_premul(a, b, op);
+ // Dissolve chooses the finished layer or the backdrop. Mixing by opacity grays it (#3051).
+ backdrop.px[i] = if layer.blend == BlendMode::Dissolve { if super::dissolve_noise(x, y) < op { b } else { a } } else { mix_premul(a, b, op) };
}
}
}
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 3cf2f1dc1..8062640b6 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1440,6 +1440,13 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
// Only `adjusted` has changed; this pixel's original backdrop is still in place.
let b = backdrop.px[i];
let a = adjusted.px[i];
+ // Dissolve is a per-pixel choice, not a partial mix (#3050).
+ if layer.blend == BlendMode::Dissolve {
+ if dissolve_noise(x, y) < k {
+ backdrop.px[i] = [a[0], a[1], a[2], b[3]];
+ }
+ continue;
+ }
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
}
@@ -1785,6 +1792,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
for (i, p) in base.px.iter_mut().enumerate() {
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
let a = adjusted.px[i];
+ if layer.blend == BlendMode::Dissolve {
+ let x = rect.x0 + (i as i32 % rect.width() as i32);
+ let y = rect.y0 + (i as i32 / rect.width() as i32);
+ if dissolve_noise(x, y) < k {
+ *p = [a[0], a[1], a[2], p[3]];
+ }
+ continue;
+ }
let bl = blend::blend_rgb(layer.blend, [p[0], p[1], p[2]], [a[0], a[1], a[2]]);
for c in 0..3 {
p[c] += (bl[c] - p[c]) * k;
@@ -1833,14 +1848,23 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
let Some(content) = render_content(layer, rect, cx) else { return };
let opacity = layer.opacity * layer.fill_opacity;
let gamma = text_gamma(layer);
+ let width = rect.width() as usize;
for (i, p) in base.px.iter_mut().enumerate() {
let alpha = p[3];
if alpha <= 0.0 {
continue;
}
- let s = content.px[i];
+ let mut s = content.px[i];
// Blend as if the base were opaque, then keep the base's alpha.
- let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma);
+ // Dissolve needs a binary coverage decision before compositing.
+ let r = if layer.blend == BlendMode::Dissolve {
+
```
### #4305: Filter > Other > Frequency Separation (#1714)
`crates/engine/src/lib.rs`, `crates/ui-egui/src/filter_dialog.rs`, `crates/ui-egui/src/menus.rs`
```diff
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index d2aecf37d..23c65523a 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -48,6 +48,7 @@ pub mod filters;
pub mod filters_ext;
pub mod float_cmds;
mod frame_cmds;
+pub mod frequency_separation_cmds;
pub mod fx_view_cmds;
pub mod fx_visibility_cmds;
pub mod gallery_cmds;
diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs
index fa7cb9524..3e3d1eeb0 100644
--- a/crates/ui-egui/src/filter_dialog.rs
+++ b/crates/ui-egui/src/filter_dialog.rs
@@ -300,6 +300,7 @@ pub fn has_dialog(command: &str) -> bool {
| "image.mode.colorTable"
| "edit.definePattern"
| "select.convertToShape"
+ | "layer.frequencySeparation"
))
&& photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty())
}
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 7f47e6ea6..8b24728ed 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -980,6 +980,7 @@ const PLACE_AFTER: &[(&str, &str)] = &[
("filter.render.relight", "filter.render.lightingEffects"),
("view.resetView", "view.flipHorizontal"),
("filter.other.colorToAlpha", "filter.other.offset"),
+ ("layer.frequencySeparation", "filter.other.colorToAlpha"),
];
pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
@@ -1747,6 +1748,22 @@ mod tests {
assert!(!others.iter().any(|p| p.as_str() == "Other"), "no duplicate Other submenu: {others:?}");
}
+ /// Frequency Separation (#1714) is no Photoshop menu item either (it is a recorded action
+ /// there): it follows Color to Alpha in Filter › Other and asks for its radius.
+ #[test]
+ fn frequency_separation_sits_in_filter_other_after_color_to_alpha() {
+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
+ let items = menu_items(&app);
+ let at = |id: &str| items.iter().position(|i| i.id == id).unwrap_or_else(|| panic!("{id} missing"));
+ let (c2a, fs) = (at("filter.other.colorToAlpha"), at("layer.frequencySeparation"));
+ assert_eq!(fs, c2a + 1);
+ assert_eq!(items[fs].path, items[at("filter.other.highPass")].path);
+ assert_eq!(items[fs].label, "Frequency Separation…");
+ assert!(items[fs].enabled, "the Background is a pixel layer with pixels");
+ assert!(crate::filter_dialog::has_dialog("layer.frequencySeparation"));
+ }
+
#[test]
fn menu_bar_labels_have_horizontal_padding_and_open_menus() {
use egui_kittest::{Harness, kittest::Queryable};
```
### #4042: feat(canvas): rotate view with Shift + middle-mouse drag
`crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/rotate_view.rs`
```diff
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 676d06807..f8e430346 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -3270,7 +3270,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// (Space) and a middle-button drag still pan, and a secondary window's Hand never stops, as the
// box is only drawn and driven on the primary canvas.
let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle;
- let hand_pan = tool == Tool::Hand && !transform_owns_pointer;
+ let middle_rotate = middle && ui.input(|i| i.modifiers.shift);
+ let hand_pan = tool == Tool::Hand && !transform_owns_pointer && !middle_rotate;
// Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on.
let flick_id = egui::Id::new(("pc-flick-pan", idx, primary, ctx.viewport_id()));
let flick_on = app.session.prefs().tools.enable_flick_panning;
@@ -3285,7 +3286,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
ctx.data_mut(|m| m.get_temp_mut_or_default::<crate::flick_pan::Flick>(flick_id).stop());
}
}
- if hand_pan && response.dragged() && !stroke_dismissal {
+ if middle_rotate && response.dragged() && !stroke_dismissal {
+ if let Some(pointer) = response.interact_pointer_pos()
+ && crate::rotate_view::middle_drag(&mut view, &xf, pointer, response.drag_delta().x)
+ {
+ ctx.request_repaint();
+ }
+ ctx.set_cursor_icon(crate::rotate_view::cursor(true));
+ } else if hand_pan && response.dragged() && !stroke_dismissal {
let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp);
view.center[0] -= d.x;
view.center[1] -= d.y;
@@ -3310,7 +3318,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let d = xf.to_doc(p);
crate::layer_picker::sample_at(app, d[0], d[1]);
}
- } else if primary && !stroke_dismissal {
+ } else if primary && !stroke_dismissal && !middle_rotate {
let mods = ui.input(|i| i.modifiers);
// An ⌥-click the window manager took never arrives; say so (alt_grab.rs).
crate::alt_grab::watch(app, &ctx, egui::Id::new(("pc-alt-grab", idx)), rect, tool, !under_dialog);
diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs
index 5e173da5d..adb14dde2 100644
--- a/crates/ui-egui/src/rotate_view.rs
+++ b/crates/ui-egui/src/rotate_view.rs
@@ -155,6 +155,31 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool) -> Option<f32> {
apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0)
}
+/// Shift + middle-drag rotates the view instead of panning. Keep the document point
+/// under the pointer in place, including when the view is flipped or stretched.
+pub fn middle_drag(view: &mut View, xf: &ViewXform, pointer: Pos2, delta_x: f32) -> bool {
+ if !pointer.x.is_finite() || !pointer.y.is_finite() || !delta_x.is_finite() || delta_x == 0.0 {
+ return false;
+ }
+ let current = ViewXform { center: view.center, rotation: view.rotation, ..*xf };
+ let before = current.to_doc(pointer);
+ let rotation = wrap_deg(view.rotation + delta_x * 0.35);
+ if rotation == view.rotation {
+ return false;
+ }
+ let after = ViewXform { rotation, ..current }.to_doc(pointer);
+
```
### #4035: fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778)
`crates/compose/src/lib.rs`, `crates/engine/src/pixels.rs`, `crates/io/src/lib.rs`, `crates/io/tests/it/color.rs`
```diff
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 37c6167e9..8b3184688 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -232,10 +232,23 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
/// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model
/// and depth), optionally flattened over an opaque `background`; rendered and written in bands,
/// so no full-size float composite is held. Tiles equal to the default pixel are pruned.
+///
+/// A CMYK result is separated through the document's own CMYK profile, the one its layers were
+/// read with. A CMYK document whose composite is one plain CMYK pixel layer (Normal, 100 %, no
+/// mask or effects) keeps that layer's inks exactly, as in Photoshop: separating the composite
+/// again would regenerate black and clip inks outside the display gamut (#3778).
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
let mut s = Surface::new(fmt);
let n = fmt.channels();
let mut data = Vec::new();
+ if let Some((src, need_opaque)) = plain_cmyk_layer(doc, fmt, background) {
+ if copy_inks(src, doc.bounds(), &mut s, need_opaque) {
+ s.prune();
+ return s;
+ }
+ s = Surface::new(fmt);
+ }
+ let cmyk = cmyk_space(doc);
let _ = render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
let band = match background {
Some(bg) => band.over_background(bg),
@@ -243,9 +256,11 @@ pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, ba
};
data.clear();
data.resize(band.px.len() * n, 0.0);
- for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
- photocraft_raster::from_rgba_into(&fmt, *p, out);
- }
+ photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
+ for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
+ photocraft_raster::from_rgba_into(&fmt, *p, out);
+ }
+ });
s.write_region(band.rect, &data);
Ok(())
});
@@ -253,6 +268,64 @@ pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, ba
s
}
+/// The only visible layer of a CMYK document when the composite is exactly its pixels: a CMYK
+/// pixel layer at `fmt`'s depth, Normal at 100 % opacity and fill, without masks, effects,
+/// Blend If, channel exclusion or advanced blending. The flag says whether its pixels must also
+/// be opaque: when it is flattened over a `background`, or `fmt` has no alpha to carry its
+/// transparency.
+fn plain_cmyk_layer(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Option<(&Surface, bool)> {
+ use photocraft_color::ColorMode;
+ if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk {
+ return None;
+ }
+ let mut visible = doc.layers.iter().filter(|l| l.visible);
+ let l = visible.next()?;
+ if visible.next().is_some() {
+ return None;
+ }
+ let LayerContent::Raster(src) = &l.content else { return None };
+ let sf = src.format();
+ let plain = l.blend == BlendMode::Normal
+ && l.opacity >= 1.0
+ && l.fill_opacity >= 1.0
+ && l.mask.is_none()
+ && l.vector_mask.is_none()
+ && (!l.effects.enabled || l.effects.items.is_empty())
+ && l.excl
```
### #4023: i18n(es): add 548 missing rows, fix five filter and video terms
`crates/ui-egui/src/i18n/es.tsv`
### #3967: Add move and delete actions to Smart Filter rows
`crates/ui-egui/src/panels.rs`, `crates/ui-egui/src/smart_ui.rs`, `docs/control-protocol.md`
```diff
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 1d3259a71..7f5e4907b 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -2113,6 +2113,7 @@ fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool)
}
fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
+ crate::smart_ui::context_menu::close_stale(app, ui.ctx());
// A new active layer opens its parent groups and is scrolled into view (#152).
let reveal = crate::layer_reveal::track(app, ui.ctx());
let Some(st) = app.session.active() else {
@@ -2498,7 +2499,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
continue;
}
- let _ = app.run(&id, p);
+ let selection = crate::smart_ui::context_menu::selection_update(app, ui.ctx(), &id, &p);
+ if app.run(&id, p).is_ok()
+ && let Some(selection) = selection
+ {
+ selection.apply(ui.ctx());
+ }
}
}
diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs
index fd1ed6f01..847a805ec 100644
--- a/crates/ui-egui/src/smart_ui.rs
+++ b/crates/ui-egui/src/smart_ui.rs
@@ -2,6 +2,8 @@
//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters; click
//! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271).
+pub(crate) mod context_menu;
+
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartObject};
use serde_json::{Value, json};
@@ -135,6 +137,9 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth:
{
open_editor(app, ui.ctx(), l.id.0, sm, i);
}
+ if let Some(i) = index {
+ context_menu::show(app, &resp, l.id, i, actions);
+ }
}
}
@@ -303,3 +308,9 @@ mod tests {
assert!(drawn.iter().all(|(_, c)| *c != t.icon));
}
}
+
+#[cfg(test)]
+mod context_tests;
+
+#[cfg(test)]
+mod menu_guard_tests;
```
### #3748: control: suggest near matches for unknown command ids and methods (#3674)
`crates/automation/src/rpc.rs`, `crates/engine/src/lib.rs`, `crates/ui-egui/src/control.rs`
```diff
diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs
index 507010b9c..62cd46919 100644
--- a/crates/automation/src/rpc.rs
+++ b/crates/automation/src/rpc.rs
@@ -81,6 +81,9 @@ impl Headless {
match method {
"engine.execute" => {
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
+ if METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() {
+ return Err(bad(photocraft_engine::command_hints::protocol_method_as_command(id)));
+ }
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
}
@@ -172,7 +175,7 @@ impl Headless {
"doc.close" => self.close(index_of(&p)?),
"batch" => self.batch(&p),
"methods" => Ok(json!(METHODS)),
- other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
+ other => Err(bad(format!("{} (try `methods`)", photocraft_engine::command_hints::unknown_method(other, METHODS)))),
}
}
@@ -782,6 +785,23 @@ mod tests {
assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
}
+ /// #3674: a guessed name gets a pointer to the right one, in both directions.
+ #[test]
+ fn unknown_names_suggest_the_right_method_or_command() {
+ let mut h = Headless::new();
+ let err = |h: &mut Headless, m: &str, p: Value| h.handle(m, p).unwrap_err().to_string();
+ let e = err(&mut h, "engine.execute", json!({"command": "engine.commands"}));
+ assert!(e.contains("did you mean `command.list`") && e.contains("protocol method"), "{e}");
+ let e = err(&mut h, "engine.execute", json!({"command": "doc.inspect"}));
+ assert!(e.contains("`doc.inspect` is a protocol method"), "{e}");
+ let e = err(&mut h, "engine.execute", json!({"command": "edit.undoo"}));
+ assert!(e.contains("did you mean `edit.undo`"), "{e}");
+ let e = err(&mut h, "doc.inspct", Value::Null);
+ assert!(e.contains("did you mean `doc.inspect`") && e.contains("(try `methods`)"), "{e}");
+ let e = err(&mut h, "edit.undo", Value::Null);
+ assert!(e.contains("engine.execute {\"command\": \"edit.undo\"}"), "{e}");
+ }
+
#[test]
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
let mut h = Headless::new();
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index ff3cde6e4..81d2c2827 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -28,6 +28,7 @@ pub mod channel_cmds;
mod clip_group;
pub mod color_cmds;
pub mod color_to_alpha_cmds;
+pub mod command_hints;
pub mod commands;
pub mod comps_cmds;
pub mod cutout_cmds;
@@ -140,7 +141,8 @@ pub use photocraft_paint::BrushSettings;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
- #[error("unknown command `{0}`")]
+ /// Displays with the nearest registered ids, if any ([`command_hints`], #3674).
+ #[error("unknown command `{id}`{hint}", id = .0, hint = command_hints::unknown_command_hint(.0))]
UnknownCommand(String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
@@ -645,7 +647,7 @@ impl Session {
/// [`Self::disabled_reason`] for a call with `params`.
```
### #3519: feat(export): optionally trim transparent margins from layer files
`crates/engine/src/file_cmds.rs`, `crates/engine/src/image_cmds.rs`, `crates/ui-egui/src/view_cmds.rs`
```diff
diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs
index 7689f3967..f00c06408 100644
--- a/crates/engine/src/file_cmds.rs
+++ b/crates/engine/src/file_cmds.rs
@@ -1227,6 +1227,7 @@ pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[
let cmd = "file.export.layersToFiles";
let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true);
+ let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false);
let save = SaveOpts::from_params(s, p, cmd)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
@@ -1243,6 +1244,32 @@ pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[
only.visible = true;
only.clipped = false;
one.layers = vec![only];
+ if trim {
+ // Measure the isolated layer's rendered alpha, including masks and effects.
+ // Track the extrema without constructing a rectangle for every pixel.
+ let rendered = photocraft_compose::flatten(&one);
+ let width = one.size.width as usize;
+ let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
+ if width > 0 {
+ for (pixel, rgba) in rendered.px.iter().enumerate() {
+ if rgba[3] > 0.0 {
+ let x = pixel % width;
+ let y = pixel / width;
+ x0 = x0.min(x);
+ y0 = y0.min(y);
+ x1 = x1.max(x + 1);
+ y1 = y1.max(y + 1);
+ }
+ }
+ }
+ // Empty layers retain a valid full-sized canvas.
+ if x0 != usize::MAX {
+ let bounds = Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32);
+ if bounds != one.bounds() {
+ crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?;
+ }
+ }
+ }
let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name));
let (bytes, _warnings) = encode(&one, &name, save)?;
write(&name, &bytes)?;
@@ -1586,7 +1613,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layers to Files…",
&["File", "Export"],
None,
- r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##,
+ r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##,
native_doc,
layers_to_files
),
diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs
index 15a71376b..5bd355f74 100644
--- a/crates/engine/src/image_cmds.rs
+++ b/crates/engine/src/image_cmds.rs
@@ -303,7 +303,7 @@ pub(crate) fn translate_doc(doc: &mut Document, cmd: &str, dx: i32,
```
### #3464: fix(vector): target selected saved paths and own Pen gestures
`crates/engine/src/vector_cmds.rs`, `crates/ui-egui/src/canvas.rs`, `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/path_selection_tests.rs`, `crates/ui-egui/src/tools/anchor_tools/tests.rs`, `crates/ui-egui/src/tools/direct_select.rs`, `crates/ui-egui/src/tools/direct_select/tests.rs`, `crates/ui-egui/src/vector_paint_tests.rs`, `crates/ui-egui/src/vector_ui.rs`, `crates/vector/src/flatten.rs`
```diff
diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs
index 14579e595..f2630f930 100644
--- a/crates/engine/src/vector_cmds.rs
+++ b/crates/engine/src/vector_cmds.rs
@@ -841,26 +841,32 @@ fn path_set(s: &mut Session, p: &Value) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?;
s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| {
- let merge = |old: Option<&Path>, new: Path| match (old, append) {
- (Some(o), Some(op)) => {
- let mut o = o.clone();
- o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| {
- sp.op = op;
- sp
- }));
- o
- }
- _ => new,
+ let merge = |old: Option<&Path>, new: Path| -> Result<Path> {
+ let merged = match (old, append) {
+ (Some(o), Some(op)) => {
+ let mut o = o.clone();
+ o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| {
+ sp.op = op;
+ sp
+ }));
+ o
+ }
+ _ => new,
+ };
+ // Appending small Pen components must not bypass the complete path's bounds and
+ // tessellation budget. Validate before changing the document or its history.
+ vector::flatten::validate_path(&merged).map_err(|e| bad(CMD, e))?;
+ Ok(merged)
};
if is_work(name.as_deref()) {
- doc.work_path = Some(merge(doc.work_path.as_ref(), path));
+ doc.work_path = Some(merge(doc.work_path.as_ref(), path)?);
} else {
let n = name.clone().unwrap_or_default();
match doc.paths.iter_mut().find(|q| q.name == n) {
Some(q) => {
- q.path = merge(Some(&q.path), path);
+ q.path = merge(Some(&q.path), path)?;
}
- None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }),
+ None => doc.paths.push(NamedPath { name: n, path: merge(None, path)?, psd_raw: None }),
}
}
Ok(())
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index db0efe31f..46b78b873 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -166,6 +166,62 @@ pub struct Drag {
/// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`).
pub sel_move: Option<bool>,
pub lasso: Option<crate::lasso_ui::Lasso>,
+ /// Freeform Pen and Path Selection gestures belong to their source document and target.
+ vector_owner: Option<VectorGestureOwner>,
+ pen_owner: Option<crate::vector_ui::PenOwner>,
+}
+
+/// The document, revision and target a vector drag started on.
+#[derive(Clone, Debug)]
+struct VectorGestureOwner {
+ doc: photocraft_doc::DocId,
+ document: std::sync::Weak<Document>,
+ revision: u64,
+ layer: Option<photocraft_doc::LayerId>,
+ selected_path: Option<String>,
+ vector_mask: bool,
+ mode: String,
+ tool: Tool,
+}
+
+impl VectorGestureOwner {
+ fn capture(app: &PhotocraftApp) -> Option<Self> {
+ let st = app.session.active()?;
+
```
### #3461: Replay recorded layer visibility on the intended named layer
`crates/engine/src/actions_cmds.rs`, `crates/engine/src/jobs.rs`, `crates/engine/src/lib.rs`, `crates/ui-egui/src/actions.rs`, `docs/control-protocol.md`
```diff
diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index 348011f75..4ae0a79e0 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -77,6 +77,47 @@ pub struct ActionState {
pub rev: u64,
}
+/// Visibility gestures name a layer for replay, since document-local ids change on another image.
+pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value {
+ if s.actions.recording.is_none() || id != "layer.setProps" || p.get("visible").is_none() {
+ return p;
+ }
+ let Some(st) = s.active() else { return p };
+ let Some(layer) = p.get("layer").and_then(Value::as_u64).map(photocraft_doc::LayerId).or(st.active_layer).and_then(|id| st.doc.layer(id)) else { return p };
+ let ordinal = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).position(|(_, _, l)| l.id == layer.id).unwrap_or(0);
+ if let Some(object) = p.as_object_mut() {
+ object.remove("layer");
+ object.remove("coalesce");
+ object.insert("_actionLayer".into(), json!({"name": layer.name, "ordinal": ordinal}));
+ }
+ p
+}
+
+/// Resolve the recorded visibility target identically in engine and shell playback.
+pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
+ let mut p = p.clone();
+ if id == "layer.setProps"
+ && let Some(target) = p.get("_actionLayer")
+ {
+ let name = target.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing recorded layer name"))?;
+ let ordinal =
+ target.get("ordinal").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(id, "invalid recorded layer ordinal"))?;
+ let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
+ let layer = doc
+ .walk()
+ .into_iter()
+ .filter(|(_, _, l)| l.name == name)
+ .nth(ordinal)
+ .map(|(_, _, l)| l.id)
+ .ok_or_else(|| bad(id, format!("recorded layer `{name}` does not exist")))?;
+ if let Some(object) = p.as_object_mut() {
+ object.remove("_actionLayer");
+ object.insert("layer".into(), json!(layer.0));
+ }
+ }
+ Ok(p)
+}
+
impl ActionState {
pub(crate) fn touch(&mut self) {
self.rev = self.rev.saturating_add(1);
@@ -292,7 +333,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
break;
}
- match s.execute(id, params.clone()) {
+ match playback_params(s, id, params).and_then(|p| s.execute(id, p)) {
Ok(value) => {
if let Some(error) = nested_failure(id, &value) {
failed = Some(json!({"step": i, "id": id, "error": error}));
@@ -673,6 +714,27 @@ mod tests {
assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1], "rejected toggles change nothing");
}
+ #[test]
+ fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() {
+ let mut s = Session::new();
+ s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
+ s.execute("layer.new.layer", json!({"name": "Same"})).unwrap();
+ let hidden = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
+ s.execute("actions.record", json!({"name": "Hid
```
### #3455: Undo and redo action definition edits from the Actions panel
`crates/engine/src/actions_cmds.rs`, `crates/ui-egui/src/actions.rs`, `crates/ui-egui/src/menus.rs`, `docs/control-protocol.md`
```diff
diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index 348011f75..e8a3be636 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -75,6 +75,84 @@ pub struct ActionState {
/// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
#[serde(skip)]
pub rev: u64,
+ #[serde(skip)]
+ pub(crate) undo: Vec<ActionEdit>,
+ #[serde(skip)]
+ pub(crate) redo: Vec<ActionEdit>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct ActionEdit {
+ list: Vec<Action>,
+ recording: Option<(usize, usize)>,
+ shortcuts: std::collections::BTreeMap<String, String>,
+}
+
+fn snapshot(s: &Session) -> ActionEdit {
+ let mut list = s.actions.list.clone();
+ if let Some((_, idx)) = s.actions.recording {
+ let pending: Vec<_> = pending_steps(s, idx).cloned().collect();
+ if let Some(action) = list.get_mut(idx) {
+ action.steps.extend(pending);
+ }
+ }
+ ActionEdit {
+ list,
+ recording: s.actions.recording.map(|(_, idx)| (s.journal.len(), idx)),
+ shortcuts: s.prefs().shortcuts.iter().filter(|(key, _)| key.starts_with(PLAY_SHORTCUT_PREFIX)).map(|(k, v)| (k.clone(), v.clone())).collect(),
+ }
+}
+
+fn edit_action(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, include_pending: bool) -> Result<Value> {
+ let mut before = snapshot(s);
+ if !include_pending {
+ before.list = s.actions.list.clone();
+ }
+ let result = run(s, p)?;
+ if before.list != snapshot(s).list {
+ s.actions.undo.push(before);
+ if s.actions.undo.len() > 100 {
+ s.actions.undo.remove(0);
+ }
+ s.actions.redo.clear();
+ }
+ Ok(result)
+}
+
+pub fn can_undo(s: &Session) -> bool {
+ s.actions.playing == 0 && !s.actions.undo.is_empty()
+}
+pub fn can_redo(s: &Session) -> bool {
+ s.actions.playing == 0 && !s.actions.redo.is_empty()
+}
+
+fn undo_edit(s: &mut Session, redo: bool) -> Result<Value> {
+ not_playing(s, if redo { "actions.redo" } else { "actions.undo" })?;
+ let current = snapshot(s);
+ let prior = if redo { s.actions.redo.pop() } else { s.actions.undo.pop() };
+ let Some(prior) = prior else { return Ok(json!(false)) };
+ if redo {
+ s.actions.undo.push(current);
+ } else {
+ s.actions.redo.push(current);
+ }
+ let old_names: std::collections::BTreeSet<_> = s.actions.list.iter().map(|a| a.name.as_str()).collect();
+ let new_names: std::collections::BTreeSet<_> = prior.list.iter().map(|a| a.name.as_str()).collect();
+ let changed_bindings: Vec<_> = old_names.symmetric_difference(&new_names).map(|name| format!("{PLAY_SHORTCUT_PREFIX}{name}")).collect();
+ s.actions.list = prior.list;
+ s.actions.recording = s.actions.recording.and(prior.recording).map(|(_, idx)| (s.journal.len(), idx));
+ if !changed_bindings.is_empty() {
+ s.edit_prefs(|prefs| {
+ for key in changed_bindings {
+ prefs.shortcuts.remove(&key);
+ if let Some(value) = prior.shortcuts.get(&key) {
+ prefs.shortcuts.insert(key, value.clone());
+ }
+ }
+ });
+ }
+ s.actions.touch();
+ Ok(json!(true))
}
impl ActionState {
@@ -528,6 +606,26 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
+ CommandSpec {
+
```
### #3387: feat(codecs): add lossless JPEG XL export for 8/16-bit images
`apps/photocraft/src/services.rs`, `crates/codecs/Cargo.toml`, `crates/codecs/src/format.rs`, `crates/codecs/src/lib.rs`
```diff
diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index 20de0d6be..03218e47a 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -89,6 +89,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg"]),
+ ("JPEG XL", &["jxl"]),
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs
index fa2e1e6cb..e7d598475 100644
--- a/crates/codecs/src/format.rs
+++ b/crates/codecs/src/format.rs
@@ -33,6 +33,8 @@ pub enum Format {
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
+ /// JPEG XL: lossless export-only until the separate importer lands.
+ Jxl,
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only.
Cineon,
}
@@ -80,6 +82,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
),
+ (Format::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."),
(
Format::Cineon,
"Cineon frames are imported for film work; this crate has no Cineon writer, so save an \
@@ -104,7 +107,7 @@ const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::
impl Format {
/// Every format known to the crate (enabled or not).
- pub const ALL: [Format; 20] = [
+ pub const ALL: [Format; 21] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -124,6 +127,7 @@ impl Format {
Format::Heif,
Format::Fits,
Format::Dpx,
+ Format::Jxl,
Format::Cineon,
];
@@ -158,6 +162,7 @@ impl Format {
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
+ Format::Jxl => "JPEG XL",
Format::Cineon => "Cineon",
}
}
@@ -185,6 +190,7 @@ impl Format {
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
+ Format::Jxl => &["jxl"],
Format::Cineon => &["cin"],
}
}
@@ -210,6 +216,7 @@ impl Format {
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
+ Format::Jxl => "image/jxl",
Format::Cineon => "image/x-cin",
}
}
@@ -266,6 +273,8 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
+ // GrayA is widened to RGBA at the same depth; the 16-bit alpha header is corrected.
+ Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..base },
Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::G
```
### #3345: Add grouped gradient presets and selectable style icons
`crates/ui-egui/src/panels.rs`, `crates/ui-egui/src/panels/preset_panels.rs`, `crates/ui-egui/src/prefs_ui.rs`, `crates/ui-egui/src/tools/gradient_ui.rs`, `crates/ui-egui/src/tools/gradient_ui/preset_browser.rs`, `crates/ui-egui/src/tools/gradient_ui/preset_browser_tests.rs`, `crates/ui-egui/src/tools/gradient_ui/properties_panel.rs`, `docs/scorecard.md`
```diff
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 733f923c8..ee50b7fd8 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -1238,25 +1238,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) {
app.ui.tool_options.gradient_classic = classic;
}
- // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels):
- // both paint with the same engine-owned preset (#1651), and either swatch
- // click opens the Gradient Editor window, as in Photoshop.
+ // Classic's ribbon opens the editor; Live is a preview. Both have a
+ // separate dropdown for choosing a preset without leaving the tool.
if crate::gradient_ui::preset_swatch(app, ui) {
app.ui.panels.gradient_editor = true;
}
widgets::vline(ui, 22.0);
ui.spacing_mut().item_spacing.x = 2.0;
let before = app.ui.tool_options.clone();
- for (style, icon, tip) in [
- ("linear", "gradient", tl!("Linear Gradient")),
- ("radial", "circle", tl!("Radial Gradient")),
- ("angle", "rotate-cw", tl!("Angle Gradient")),
- ("reflected", "arrow-left-right", tl!("Reflected Gradient")),
- ("diamond", "diamond", tl!("Diamond Gradient")),
- ] {
- if icons::button(ui, icon, 24.0, app.ui.tool_options.gradient_style == style, tip).clicked() {
- app.ui.tool_options.gradient_style = style.into();
- }
+ if let Some(style) = crate::gradient_ui::style_buttons(ui, &app.ui.tool_options.gradient_style, app.session.prefs().interface.native_gradient_icons) {
+ app.ui.tool_options.gradient_style = style.into();
}
ui.spacing_mut().item_spacing.x = 8.0;
widgets::vline(ui, 22.0);
diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
index db908ae23..d97556f36 100644
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -1026,6 +1026,7 @@ fn humanize(key: &str) -> String {
"appearanceMode" => return "Appearance Mode".into(),
"darkTheme" => return "Dark Theme".into(),
"lightTheme" => return "Light Theme".into(),
+ "nativeGradientIcons" => return "Use PhotoCraft-native gradient icons".into(),
_ => {}
}
// These controls appear only for WebP, so reuse the existing translated labels.
@@ -3039,6 +3040,39 @@ mod tests {
assert!(h.state().custom_titlebar);
}
+ #[test]
+ fn native_gradient_icons_apply_cancel_and_restart() {
+ use egui_kittest::{Harness, kittest::Queryable};
+ let (mut app, store) = app_with_store();
+ app.run("prefs.set", json!({"path": "interface.language", "value": "en"})).unwrap();
+ let id = open_preferences(&mut app, "interface");
+ let mut h =
```
### #3142: Read applied Sony RAW metadata and crop ILCE-6000 images
`crates/raw/src/cameras.rs`, `docs/file-format-parity.md`, `docs/gaps.md`
```diff
diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs
index 1c605734f..827ac3946 100644
--- a/crates/raw/src/cameras.rs
+++ b/crates/raw/src/cameras.rs
@@ -95,6 +95,9 @@ const PROFILES: &[Profile] = &[Profile {
/// right and bottom), the same corner as the D4.
/// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the
/// 6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom).
+/// * **ILCE-6000** (3 files): the same 12 x 12 origin and 6000 x 4000 image;
+/// confirmed by the file's EXIF size and SR2SubIFD's area [12, 12, 6012, 4012],
+/// and checked against the matching camera JPEGs. No colour profile is inferred.
struct ImageArea {
make: &'static str,
model: &'static str,
@@ -107,6 +110,7 @@ struct ImageArea {
const IMAGE_AREAS: &[ImageArea] = &[
ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) },
ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) },
+ ImageArea { make: "SONY", model: "ILCE-6000", size: (6048, 4024), crop: (12, 12, 6000, 4000) },
];
/// Applies the measured profile of the camera that made `s`, when there is one,
@@ -114,7 +118,7 @@ const IMAGE_AREAS: &[ImageArea] = &[
pub(crate) fn apply(s: &mut Sensor) {
let (Some(make), Some(model)) = (s.make.as_deref(), s.model.as_deref()) else { return };
let (make, model) = (make.trim(), model.trim());
- if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height)) {
+ if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height) && s.crop == s.active) {
let (x, y, w, h) = a.crop;
s.crop = Rect::new(x, y, w, h);
}
@@ -196,6 +200,19 @@ mod tests {
apply(&mut s);
assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000));
+ let mut s = sensor("SONY", "ILCE-6000", 6048, 4024);
+ apply(&mut s);
+ assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000));
+ assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty());
+ // A file's explicit crop wins, including a different in-camera aspect ratio.
+ s.crop = Rect::new(12, 324, 6000, 3376);
+ apply(&mut s);
+ assert_eq!(s.crop, Rect::new(12, 324, 6000, 3376));
+
+ let mut s = sensor("SONY", "ILCE-6000", 3024, 2012);
+ apply(&mut s);
+ assert_eq!(s.crop, Rect::new(0, 0, 3024, 2012));
+
// Another size or model of the same maker is left alone.
let mut s = nef("NIKON D800E", 2496, 1646);
apply(&mut s);
```
### #3139: Draw right-to-left layer names in visual word order
`Cargo.lock`, `crates/ui-egui/Cargo.toml`, `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/layer_row_ui.rs`
```diff
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 131a670ee..22018457a 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -24,6 +24,7 @@ pub mod analysis_ui;
pub mod anchor_tools;
pub mod artboard_tool;
pub mod artboard_ui;
+pub mod bidi_label;
mod blend_keys;
pub(crate) mod blend_preview;
mod brand;
```
### #2300: Scroll the viewport while outlining with Polygonal Lasso
`crates/ui-egui/src/canvas.rs`
```diff
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 391ad464f..f27bec347 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2879,6 +2879,49 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f
app.session.color.hdr_preview(doc).map(|h| [h.exposure, h.gamma])
}
+/// Between Polygonal Lasso clicks, reveal more of the image near or beyond the viewport edge.
+/// Only the view moves: fixed vertices stay in document coordinates, including while button-up.
+fn polygon_edge_scroll(app: &PhotocraftApp, ctx: &egui::Context, idx: usize, rect: Rect, view: &mut View, ppp: f32) {
+ if app.ui.polygon.is_empty()
+ || app.ui.tool != Tool::PolygonLasso
+ || app.session.active_index() != Some(idx)
+ || !app.ui.dialogs.is_empty()
+ || app.camera_raw.is_some()
+ || app.discard.is_some()
+ || app.drag.is_some()
+ || ctx.text_edit_focused()
+ || egui::Popup::is_any_open(ctx)
+ || crate::hold_keys::for_frame(app, ctx, false).is_some()
+ {
+ return;
+ }
+ let Some((pointer, dt)) = ctx.input(|i| (i.focused && !i.pointer.middle_down()).then(|| i.pointer.hover_pos().map(|p| (p, i.stable_dt))).flatten()) else {
+ return;
+ };
+ let zoom = view.zoom / ppp;
+ if !pointer.x.is_finite() || !pointer.y.is_finite() || !dt.is_finite() || dt <= 0.0 || !zoom.is_finite() || zoom <= 0.0 {
+ return;
+ }
+ // Moving over a neighbouring document is not a request to pan either tiled view.
+ if arranged_cells(app, app.last_canvas_rect).is_some_and(|cells| cells.iter().any(|(other, cell)| *other != idx && cell.contains(pointer))) {
+ return;
+ }
+ let edge = 32.0_f32.min(rect.width() * 0.25).min(rect.height() * 0.25);
+ if edge <= 0.0 {
+ return;
+ }
+ let speed = |p: f32, lo: f32, hi: f32| {
+ let direction = ((edge - (hi - p)) / edge).clamp(0.0, 1.0) - ((edge - (p - lo)) / edge).clamp(0.0, 1.0);
+ if direction == 0.0 { 0.0 } else { direction.signum() * (80.0 + 520.0 * direction.abs()) }
+ };
+ let velocity = vec2(speed(pointer.x, rect.left(), rect.right()), speed(pointer.y, rect.top(), rect.bottom()));
+ let velocity = velocity * (600.0 / velocity.length().max(600.0));
+ let xf = ViewXform { rect, zoom, center: view.center, flip: app.ui.view.flip_horizontal, rotation: view.rotation, aspect: app.ui.view.display_aspect() };
+ let delta = xf.unmap_vec(velocity) * dt.min(0.05) / zoom;
+ view.center[0] += delta.x;
+ view.center[1] += delta.y;
+}
+
/// Draw one canvas view and handle its input. `primary` = main window (tools active).
pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View {
let ctx = ui.ctx().clone();
@@ -2922,8 +2965,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
if view.fill_pending && rect.width() > 50.0 {
fill_view(&mut view, &doc, rect.size(), ppp, aspect);
}
+ let previous_center = view.center;
+ if primary {
+ polygon_cancel_stale(app);
+ polygon_edge_scroll(app, &ctx, idx, rect, &mut view, ppp);
+ }
// Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs).
- if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) {
+ if !app.sess
```
### #1991: feat(actions): import and export reusable Actions JSON files
`crates/automation/src/workspace.rs`, `crates/engine/src/actions_cmds.rs`, `crates/ui-egui/src/actions.rs`
```diff
diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs
index f73a70238..90e979ab0 100644
--- a/crates/automation/src/workspace.rs
+++ b/crates/automation/src/workspace.rs
@@ -368,7 +368,7 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
- "actions.save" | "swatches.export" => params.get("path").is_some(),
+ "actions.save" | "actions.export" | "swatches.export" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
@@ -778,6 +778,7 @@ mod tests {
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
+ ("actions.export", serde_json::json!({"path": "/outside/actions.json"})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
@@ -790,6 +791,8 @@ mod tests {
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
+ ("actions.import", serde_json::json!({"data": "{\"version\":1,\"actions\":[]}"})),
+ ("actions.export", serde_json::json!({})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index d881a6112..a074ac747 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -22,6 +22,8 @@ use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
+mod json_file;
+
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
@@ -580,6 +582,26 @@ fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
+ CommandSpec {
+ id: "actions.import",
+ label: "Load Actions…",
+ menu: &[],
+ shortcut: None,
+ params: r##"{"data":str} → {imported, skipped, actions:[names]}. Append version 1 Actions JSON, retarget nested calls, and rename conflicts; unchanged reimports do nothing."##,
+ enabled: always,
+ run: json_file::import,
+ journal: false,
+ },
+ CommandSpec {
+ id: "actions.export",
+ label: "Save Actions…",
+ menu: &[],
+ shortcut: None,
+ params: r##"{"path":str?} → {count, bytes, data?:JSON string, path?}. Export version 1 Actions JSON, preserving disabled steps, dialog toggles and sets."##,
+ enabled: always,
+ run: json_file::export,
+ journal: false,
+ },
CommandSpec {
id: "actions.move",
label: "Move Action or Step",
diff --git a/crates/ui
```
## Checkpoint validation
- All 41 integrated selected heads verified as ancestors.
- cargo fmt --all -- --check: PASS on the partial integration tree.
- Full workspace, corpus, GPU, wasm, CI and manual tests: NOT RUN; integration remains incomplete.
- Default Cargo cache was not writable; a workspace-local cache check is in progress. No credentials, global settings, dependency pins, tests or thresholds were changed.