230 KiB
Integration checkpoint and decisions
Partial integration: 41 of 108 selected heads are ancestors; 67 require resolution. No integrated build, corpus, full sweep or manual interaction qualification is claimed. See test plan, selection evidence and machine-readable status.
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
- 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.
- Adjustment #9512: extend current
blend_backwith Fill, retainblend_rgb_fillfor special modes and retain #9509'slet 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. - 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. - 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.
- 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 feat(actions): import and export reusable Actions JSON files — PENDING CONFLICT
- #3455 Undo and redo action definition edits from the Actions panel — PENDING CONFLICT
- #3461 Replay recorded layer visibility on the intended named layer — PENDING CONFLICT
- #3748 control: suggest near matches for unknown command ids and methods (#3674) — PENDING CONFLICT
- #4993 Prevent control-channel engine commands from editing behind modal dialogs — PENDING CONFLICT
- #5090 Actions: add editable PhotoCraft Essentials starter presets — PENDING CONFLICT
- #5176 Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale — PENDING CONFLICT
- #6492 Fail control startup promptly when its port is occupied — PENDING CONFLICT
- #6510 Automation: resolve configured root aliases for absolute paths (#6426) — INTEGRATED
- #7742 Actions: a fractional setting an integer field cannot hold is refused, not rounded — PENDING CONFLICT
- #8905 fix(plugins): bound a run's total plug-in memory, not just each instance — PENDING CONFLICT
Compositing and filters (20)
- #3874 fix(layer-style): support Blend If Alt-split handle feathering and throttle live preview — INTEGRATED
- #3967 Add move and delete actions to Smart Filter rows — PENDING CONFLICT
- #4305 Filter > Other > Frequency Separation (#1714) — PENDING CONFLICT
- #4711 Treat Dissolve as a per-pixel choice — PENDING CONFLICT
- #4992 Preserve Dissolve coverage when compositing clipped layers — PENDING CONFLICT
- #7153 Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace — INTEGRATED
- #7903 Filter Gallery › Cutout 4/4: trace the image into flat paper shapes — INTEGRATED
- #8039 Compose: Extend Softer outer glows past Size (#7899) — PENDING CONFLICT
- #8191 Filter Gallery › Cutout 1/4: colour and palette stage — INTEGRATED
- #8192 Filter Gallery › Cutout 2/4: regions stage — INTEGRATED
- #8193 Filter Gallery › Cutout 3/4: edges and curves stage — INTEGRATED
- #8903 Box-filter minified patterns in the CPU and GPU samplers (#8831) — INTEGRATED
- #8931 feat(algo): describe the Gaussian blur as data for other devices — INTEGRATED
- #9472 fix(io): import a Displace smart filter that names its map file without embedding it (#9162) — INTEGRATED
- #9509 fix: an edit at full coverage keeps its result over huge 32-bit values — INTEGRATED
- #9512 fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes — PENDING CONFLICT
- #9584 fix(compose, gpu): Blend If hard points match Photoshop at 16 bits and for Gray — INTEGRATED
- #9589 fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity — PENDING CONFLICT
- #9595 fix(compose, gpu): a Pass Through group with Blend If renders isolated — INTEGRATED
- #9651 perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) — INTEGRATED
File fidelity and colour (15)
- #3142 Read applied Sony RAW metadata and crop ILCE-6000 images — PENDING CONFLICT
- #3387 feat(codecs): add lossless JPEG XL export for 8/16-bit images — PENDING CONFLICT
- #3519 feat(export): optionally trim transparent margins from layer files — PENDING CONFLICT
- #4035 fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778) — PENDING CONFLICT
- #5870 fix(format): reject duplicate native layer identities — INTEGRATED
- #6387 Video Frames to Layers: every page of a multi-page TIFF (part of #6379) — INTEGRATED
- #8897 fix(io): read linked smart objects for PSD export as regular files only — PENDING CONFLICT
- #9339 fix(export): embed requested sRGB profile for untagged sources (#9325) — PENDING CONFLICT
- #9342 fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325) — PENDING CONFLICT
- #9401 fix(format): a save on macOS keeps the file's creation time (#9388) — PENDING CONFLICT
- #9416 Affinity reader: contiguous curve records and a cheaper object tag index — INTEGRATED
- #9418 Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073) — INTEGRATED
- #9457 raw: faster tone stage (Shadows, Highlights, Clarity, Texture, Dehaze), same bytes — INTEGRATED
- #9590 fix(io): PSD layer-effect contours keep at most 16 points — PENDING CONFLICT
- #9596 fix(io): drop a kept Txt2 once no type layers are left — INTEGRATED
Painting (9)
- #4763 refactor(liquify): reuse native brush resizing and anchored HUD feedback — PENDING CONFLICT
- #4985 feat(ui): add Shift+right-click brush mode picker — INTEGRATED
- #5082 fix(mixer-brush): Alt-click samples the foreground colour like the Brush (#4873) — PENDING CONFLICT
- #5165 Fix: brush section selection — INTEGRATED
- #5939 fix(paint): scale blur sigma with brush size (#5892) — INTEGRATED
- #6139 Fix painting workflows, File Info and layer selection — PENDING CONFLICT
- #6236 Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098) — PENDING CONFLICT
- #7848 fix(retouch): preserve Smudge pickup with live-pixel stroke commits — PENDING CONFLICT
- #9623 Pencil: keep a 1 px freehand line connected where it turns — INTEGRATED
Platform and reliability (8)
- #3554 fix(packaging): launch bundled CLI directly from AppImage — INTEGRATED
- #4742 fix(macos): bundle photocraft-cli and resolve it from droplet scripts — INTEGRATED
- #6383 Windows releases ship a symbols zip: the release build's PDBs for profilers (#688) — INTEGRATED
- #7743 packaging: the portable zip holds the files, not a folder with the files in it — INTEGRATED
- #9015 fix(xtask): reject leading zeroes in numeric prerelease identifiers — INTEGRATED
- #9019 fix(xtask): reject unknown performance benchmark selections — PENDING CONFLICT
- #9110 test: GPU tests honour WGPU_BACKEND, so they run on Windows with an Intel GPU (#4) — INTEGRATED
- #9436 Startup: a failed launch says why in a dialog instead of closing silently (#7643) — INTEGRATED
Selection, paths and transforms (13)
- #2300 Scroll the viewport while outlining with Polygonal Lasso — PENDING CONFLICT
- #3464 fix(vector): target selected saved paths and own Pen gestures — PENDING CONFLICT
- #5453 Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1 — PENDING CONFLICT
- #5493 Import Photoshop custom shape sets into the Shapes panel — PENDING CONFLICT
- #5515 Selecting subject now automatically applies to selected layer, and us… — PENDING CONFLICT
- #5650 Guides: ⌥ turns a dragged guide; dropping one on either ruler deletes it (#5034) — PENDING CONFLICT
- #6026 Marquee: Fixed Size selects exactly the entered width and height (fixes #6015) — PENDING CONFLICT
- #6621 Crop: right-click on the pending crop opens the crop menu (#6561) — PENDING CONFLICT
- #7648 Pen: Path Operations menu, as in Photoshop (#1397) — INTEGRATED
- #8074 Transform: remember selected interpolation (#8067) — PENDING CONFLICT
- #8896 Select and Mask: Photoshop's full-window workspace (#7069, #8148) — INTEGRATED
- #8986 Path tools: edit the saved path selected in the Paths panel (#8478) — PENDING CONFLICT
- #9493 fix(vector): a mask feather continues the mask past the canvas edge — INTEGRATED
Type and typography (6)
- #3139 Draw right-to-left layer names in visual word order — PENDING CONFLICT
- #4783 Fix/4758 type extreme finite geometry 20261013s1215 — PENDING CONFLICT
- #8347 Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment) — PENDING CONFLICT
- #9478 fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470) — PENDING CONFLICT
- #9523 Fix Type on a Path wrapping and caret positioning across closed-path seams — PENDING CONFLICT
- #9594 fix(engine): copied type keeps its size across resolutions after an edit — INTEGRATED
UI and workflow (26)
- #3345 Add grouped gradient presets and selectable style icons — PENDING CONFLICT
- #3520 fix(ui): restore square color chips in Studio Dark theme — INTEGRATED
- #4023 i18n(es): add 548 missing rows, fix five filter and video terms — PENDING CONFLICT
- #4042 feat(canvas): rotate view with Shift + middle-mouse drag — PENDING CONFLICT
- #4547 fix(icons): PhotoCraft icon corner radius matches other Craft apps (#4478) — INTEGRATED
- #4883 feat(ui): show selection and active-layer bounds in the status bar — INTEGRATED
- #4890 Add scrollbar visibility preferences (#3262) — PENDING CONFLICT
- #4938 Layer Style: one dialog size on every page, and it fits short screens — PENDING CONFLICT
- #5010 Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938] — PENDING CONFLICT
- #5270 Layer Style: Gradient Overlay takes any number of colour stops (#5177) — PENDING CONFLICT
- #5744 fix(ui): keep detached documents in one interactive window — PENDING CONFLICT
- #6449 Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop — PENDING CONFLICT
- #7255 fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223) — PENDING CONFLICT
- #8892 ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte — PENDING CONFLICT
- #8985 Color panel and Color Picker: clip the round cursor to the colour field — PENDING CONFLICT
- #9004 fix(ui): enlarge Move tool alignment icons for legibility — INTEGRATED
- #9172 Gradient Fill layer: default to Foreground to Transparent (#8708) — INTEGRATED
- #9282 feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer — PENDING CONFLICT
- #9354 fix(ui): Full Screen Mode needs a document (#9349) — PENDING CONFLICT
- #9481 fix(ui): new layer effects start from the Make Default values — PENDING CONFLICT
- #9504 fix(ui): Keyboard Shortcuts records ⌘Z instead of undoing the capture — INTEGRATED
- #9517 fix(ui): Layer Style scrolls on a short window so OK stays on screen — PENDING CONFLICT
- #9597 fix(ui): the layer list doesn't move with no layer selected — PENDING CONFLICT
- #9598 fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon — PENDING CONFLICT
- #9606 fix(ui): the Color panel remembers its options across restarts — PENDING CONFLICT
- #9703 Fix adjustment layer name clicks targeting masks — PENDING CONFLICT
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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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, ¶ms)
@@ -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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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, ¶ms).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, ¶ms).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.