From 5cd134f3539c8e737fef0b9ab199d070ea82448e Mon Sep 17 00:00:00 2001 From: AlastorIRL <100581152+AlastorIRL@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:00:13 -0300 Subject: [PATCH 001/304] fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback --- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/timeline_ui.rs | 79 ++++++++++++++++++++++++++- crates/ui-egui/src/view_cmds.rs | 32 +++++++++++ crates/ui-egui/src/view_cmds/tests.rs | 25 +++++++++ 8 files changed, 140 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6836a381dc..1a944c9a20 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2687,3 +2687,4 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau + Stop Stopp diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f9cfe73f89..7f8d9bc032 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2728,3 +2728,4 @@ curves Smooth Suavizar Light Red Rojo claro Medium Blue Azul medio Light Gray Gris claro + Stop Detener diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 62495a8d44..cd7ff45cf1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3217,3 +3217,4 @@ curves Smooth 매끄럽게 Light Red 연한 빨강 Medium Blue 중간 파랑 Light Gray 연한 회색 + Stop 정지 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 187f63e18d..e01b9a3d48 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2652,3 +2652,4 @@ curves Smooth 平滑 Light Red 浅红色 Medium Blue 中蓝色 Light Gray 浅灰色 + Stop 停止 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3e0f8902cd..8c2a27efab 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2637,3 +2637,4 @@ curves Smooth 平滑 Light Red 淺紅色 Medium Blue 中藍色 Light Gray 淺灰色 + Stop 停止 diff --git a/crates/ui-egui/src/timeline_ui.rs b/crates/ui-egui/src/timeline_ui.rs index 22d8408abe..282f88e5de 100644 --- a/crates/ui-egui/src/timeline_ui.rs +++ b/crates/ui-egui/src/timeline_ui.rs @@ -49,6 +49,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut act: Option<(&str, Value)> = None; let mut close = false; let mut toggle_play = false; + let mut stop_play = false; let playing = app.ui.timeline.playing; let t = Tokens::get(ctx); @@ -84,7 +85,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::icons::button(ui, play_icon, 22.0, playing, if playing { tl!("Pause") } else { tl!("Play") }).clicked() { toggle_play = true; } + if crate::icons::button(ui, "square", 22.0, false, tl!("Stop")).clicked() { + if !playing { + act = Some(("timeline.setFrame", json!({ "frame": 0 }))); + } + stop_play = true; + } if crate::icons::button(ui, "chevron-left", 22.0, false, tl!("Previous frame")).clicked() { + stop_play = true; act = Some(("timeline.previousFrame", json!({}))); } ui.label( @@ -96,12 +104,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { .monospace(), ); if crate::icons::button(ui, "chevron-right", 22.0, false, tl!("Next frame")).clicked() { + stop_play = true; act = Some(("timeline.nextFrame", json!({}))); } ui.separator(); ui.label(RichText::new(format!("{:.2}s", tl.time())).color(t.text_dim).monospace()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::icons::button(ui, "trash", 20.0, false, tl!("Delete Timeline")).clicked() { + stop_play = true; act = Some(("timeline.delete", json!({}))); } ui.label(RichText::new(format!("{:.0} fps", tl.fps)).color(t.text_dim)); @@ -113,6 +123,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut frame = tl.current as f64; let resp = ui.add(egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64).integer().show_value(false).text("playhead")); if resp.changed() { + stop_play = true; act = Some(("timeline.setFrame", json!({ "frame": frame as u64 }))); } } @@ -124,7 +135,9 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { app.ui.timeline.open = false; app.ui.timeline.playing = false; } - if toggle_play { + if stop_play { + app.ui.timeline.playing = false; + } else if toggle_play { app.ui.timeline.playing = !app.ui.timeline.playing; app.ui.timeline.last_time = ctx.input(|i| i.time); } @@ -147,3 +160,67 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn stop_step_and_scrub_pause_playback_and_second_stop_rewinds() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("timeline.create", json!({"duration": 10, "fps": 30.0})).unwrap(); + app.ui.timeline.open = true; + app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0)); + + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.001).build_ui_state(|ui, app| windows(app, ui.ctx()), app); + h.run_steps(2); + + // Start playback via Play button. + h.get_by_label("Play").click(); + h.run_steps(2); + assert!(h.state().ui.timeline.playing); + + // Move to frame 5 while playing; first Stop pauses at the current frame without rewinding. + h.state_mut().run("timeline.setFrame", json!({"frame": 5})).unwrap(); + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "first Stop pauses playback"); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 5); + + // Second Stop while already paused rewinds to frame 0. + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0); + + // Next frame and Previous frame pause playback. + h.state_mut().ui.timeline.playing = true; + h.run_steps(1); + h.get_by_label("Next frame").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "Next frame pauses playback"); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 1); + + h.state_mut().ui.timeline.playing = true; + h.run_steps(1); + h.get_by_label("Previous frame").click(); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "Previous frame pauses playback"); + assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0); + + // Scrubbing the playhead slider pauses playback and updates the current frame. + h.state_mut().ui.timeline.playing = true; + h.run_steps(1); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let target = egui::pos2(slider.right() - 8.0, slider.center().y); + h.event(egui::Event::PointerMoved(target)); + h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().ui.timeline.playing, "scrubbing pauses playback"); + assert!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current > 0); + } +} diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index f0404633cf..cba9978761 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -461,6 +461,11 @@ fn wraps(id: &str) -> bool { | "image.calculations" | "select.saveSelection" | "select.loadSelection" + | "layer.videoLayers.newVideoLayerFromFile" + | "layer.videoLayers.replaceFootage" + | "layer.videoLayers.interpretFootage" + | "file.import.videoFramesToLayers" + | "file.export.renderVideo" ) } @@ -1033,6 +1038,33 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { + let doc = match app.active_doc_id() { + Ok(doc) => doc, + Err(e) => return Some(Err(e)), + }; + let cmd = id.to_string(); + Some(app.pick_file_bytes(move |app, path, _bytes| { + app.with_document(doc, |app| { + let r = app.run(&cmd, json!({"path": path})); + if r.is_ok() && app.session.active().is_some_and(|d| d.doc.timeline.is_some()) { + app.ui.timeline.open = true; + } + app.sync_views(); + r + }) + })) + } + "layer.videoLayers.interpretFootage" => { + let st = app.session.active()?; + let id_layer = st.active_layer?; + let fps = st.doc.layer(id_layer).and_then(|l| l.video.as_ref()).map_or(30.0, |v| v.fps); + dialog(app, json!({"fps": fps}), json!({})) + } + "file.export.renderVideo" => { + let _ = doc?; + dialog(app, json!({"dir": dir, "format": "gif"}), json!({"format": ["gif", "png", "jpg", "webp", "bmp", "tiff"]})) + } "file.fileInfo" => { let info = app.session.execute("file.fileInfo", json!({})).ok()?; let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::>().join("; ")).unwrap_or_default(); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 34c93ccc25..b073ed8db3 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -369,3 +369,28 @@ fn load_selection_without_alpha_channels_offers_the_composite() { assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB"); crate::dialogs::confirm(&mut app, dialog).unwrap(); } + +#[test] +fn video_menu_commands_open_pickers_and_dialogs() { + let (mut app, ctx) = app_with(1); + app.services.file_dialog = Some(Box::new(|_, _, _| {})); + + for id in ["layer.videoLayers.newVideoLayerFromFile", "file.import.videoFramesToLayers", "layer.videoLayers.replaceFootage"] { + assert!(is_live(id), "{id}"); + assert_eq!(menu(&mut app, &ctx, id, json!({})).unwrap(), json!({"fileDialog": "open"}), "{id}"); + app.file_dialog = None; + } + + let interpret = menu(&mut app, &ctx, "layer.videoLayers.interpretFootage", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &app.ui.dialog_mut(interpret).unwrap().fields; + assert_eq!(fields["__command"], "layer.videoLayers.interpretFootage"); + assert_eq!(fields["fps"], 30.0); + app.ui.close_dialog(interpret); + + let render = menu(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &app.ui.dialog_mut(render).unwrap().fields; + assert_eq!(fields["__command"], "file.export.renderVideo"); + assert_eq!(fields["format"], "gif"); + assert_eq!(fields["__choices"]["format"], json!(["gif", "png", "jpg", "webp", "bmp", "tiff"])); + app.ui.close_dialog(render); +} From 2a10524d53806b268d8217103edd23144bb7b7bd Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 09:00:20 +0200 Subject: [PATCH 002/304] PSD: save Indexed Color, Bitmap and Duotone documents in their own mode Indexed Color (mode 2): the flattened document as palette indices, the Color Table as Color Mode Data (256 reds, greens, blues), its length in resource 1046 and the transparent entry in 1047; the importer now reads both back. Bitmap (mode 0): 1 bit per pixel, 1 = black. Neither mode has layers in Photoshop, so both are written flat, like Multichannel; the layerless file building is shared with multichannel_map. A Duotone keeps the ink data it was imported with (undocumented, kept verbatim in Duotone::psd_raw) over its grey plate. Duotones whose inks were set in PhotoCraft have no such data and stay Grayscale with the warning, as before. Fixes #4814 --- crates/io/src/lib.rs | 1 + crates/io/src/multichannel_map.rs | 34 +++++++--- crates/io/src/palette_map.rs | 107 ++++++++++++++++++++++++++++++ crates/io/src/psd_export.rs | 20 +++++- crates/io/src/psd_import.rs | 8 ++- crates/io/tests/psd_structure.rs | 41 ++++++++++++ 6 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 crates/io/src/palette_map.rs diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b80da66c3c..309107a4c5 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -49,6 +49,7 @@ mod kra; pub mod linked; mod multichannel_map; mod ora; +mod palette_map; pub use ora::losses as ora_losses; pub mod pattern_map; mod pdn; diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index db31e743ef..365d551504 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -119,7 +119,27 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false))); } - resources.extend(crate::psd_export::path_resources(doc, &mut warnings)); + // Alpha identifiers: one u32 per channel, only while they still match the channels. + let keep = |id: u16, data: &[u8]| id != ALPHA_IDENTIFIERS || data.len() == channels.len().saturating_mul(4); + let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); + let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings); + (file, warnings) +} + +/// A PSD without layers around `image_data`: `resources` (the mode's own) plus the paths, XMP, +/// EXIF and every other imported resource `keep` accepts (except the ones that only describe +/// layers and any already in `resources`), and the document-level tagged blocks. Shared by the +/// modes that have no layers in Photoshop: Multichannel, Indexed Color and Bitmap. +pub(crate) fn layerless_file( + doc: &Document, + header: Header, + image_data: ImageData, + color_mode_data: Vec, + mut resources: Vec, + keep: impl Fn(u16, &[u8]) -> bool, + warnings: &mut Vec, +) -> PsdFile { + resources.extend(crate::psd_export::path_resources(doc, warnings)); // The XMP and EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it. let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0); if let Some(x) = &doc.metadata.xmp { @@ -132,11 +152,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< // ones that only describe layers and any already written above. let written: Vec = resources.iter().map(|r| r.id).collect(); for (id, name, data) in &doc.metadata.psd_resources { - if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) { - continue; - } - // Alpha identifiers: one u32 per channel, only while they still match the channels. - if *id == ALPHA_IDENTIFIERS && data.len() != channels.len().saturating_mul(4) { + if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) || !keep(*id, data) { continue; } let mut r = ImageResource::new(*id, data.to_vec()); @@ -154,8 +170,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< .filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32")) .map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data)) .collect(); - let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); - let file = PsdFile { + PsdFile { header, color_mode_data, resources, @@ -165,6 +180,5 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< global_blocks, layer_mask_trailing: Vec::new(), image_data, - }; - (file, warnings) + } } diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs new file mode 100644 index 0000000000..a279af0f4a --- /dev/null +++ b/crates/io/src/palette_map.rs @@ -0,0 +1,107 @@ +//! Indexed Color and Bitmap documents -> PSD colour modes 2 and 0. +//! +//! Neither mode has layers in Photoshop, so the file holds only the merged image (the document +//! flattened): for Indexed Color one plane of palette indices, with the Color Table as Color Mode +//! Data (768 bytes: 256 reds, 256 greens, 256 blues), its length in resource 1046 and the +//! transparent entry in resource 1047; for Bitmap one plane of 1 bit per pixel, 1 = black. + +use photocraft_color::ColorMode; +use photocraft_doc::{ColorTable, Document}; +use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids}; +use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile}; + +/// Indexed Color Table Count: the number of colours in the table (u16). +pub(crate) const COLOR_TABLE_COUNT: u16 = 1046; +/// Transparency Index: the colour table entry shown as transparent (u16). +pub(crate) const TRANSPARENCY_INDEX: u16 = 1047; +/// Channel resources (names, display info, alpha identifiers): these modes have no extra channels. +const CHANNEL_RESOURCES: [u16; 4] = [1006, 1045, 1053, crate::channel_map::DISPLAY_INFO]; + +/// The PSD for an Indexed Color document with a colour table of at most 256 entries, or a Bitmap +/// document; None for any other document (it is written like the others). +pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFile, Vec)> { + let table = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256); + let (mode, plane, color_mode_data) = match doc.mode { + ColorMode::Indexed => { + let table = table?; + (PsdMode::Indexed, indices(doc, table), palette(table)) + } + ColorMode::Bitmap => (PsdMode::Bitmap, bits(doc), Vec::new()), + _ => return None, + }; + let mut warnings = Vec::new(); + if doc.layers.len() > 1 { + warnings.push(format!("{:?} documents have no layers in Photoshop: written flattened", doc.mode)); + } + let big = doc.size.width > 30_000 || doc.size.height > 30_000; + let size_exceeds_limit = !force_psb && !big && crate::psd_export::psd_size_exceeds_limit(doc); + let version = crate::psd_export::psd_version(doc, force_psb, size_exceeds_limit); + let depth = if mode == PsdMode::Bitmap { 1 } else { 8 }; + let header = Header::new(version, doc.size.width, doc.size.height, 1, depth, mode); + let image_data = ImageData::encode(Compression::Rle, &plane, &header) + .or_else(|_| ImageData::encode(Compression::Raw, &plane, &header)) + .unwrap_or(ImageData { compression: Compression::Raw, data: plane }); + let mut resources = vec![ + ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()), + ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()), + ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()), + ImageResource::new(1032, crate::psd_export::guides_resource(doc)), + ]; + if let (PsdMode::Indexed, Some(t)) = (mode, table) { + resources.push(ImageResource::new(COLOR_TABLE_COUNT, (t.colors.len() as u16).to_be_bytes().to_vec())); + if let Some(i) = t.transparent { + resources.push(ImageResource::new(TRANSPARENCY_INDEX, u16::from(i).to_be_bytes().to_vec())); + } + } + // An imported transparency index that no longer applies is not copied either. + let keep = |id: u16, _: &[u8]| !CHANNEL_RESOURCES.contains(&id) && id != TRANSPARENCY_INDEX && id != COLOR_TABLE_COUNT; + let file = crate::multichannel_map::layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings); + Some((file, warnings)) +} + +/// The Color Table as Color Mode Data: 256 reds, 256 greens, 256 blues (unused entries black). +fn palette(t: &ColorTable) -> Vec { + let mut m = vec![0u8; 768]; + for (i, c) in t.colors.iter().take(256).enumerate() { + m[i] = c[0]; + m[256 + i] = c[1]; + m[512 + i] = c[2]; + } + m +} + +/// The flattened document as palette indices (the nearest entry; the transparent one where the +/// document is less than half covered). +fn indices(doc: &Document, t: &ColorTable) -> Vec { + let mut idx = Vec::with_capacity(doc.size.area() as usize); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + idx.extend(band.px.iter().map(|p| match t.transparent { + Some(i) if p[3] < 0.5 => i, + _ => t.nearest([p[0], p[1], p[2]]) as u8, + })); + Ok(()) + }); + idx +} + +/// The flattened document over white as 1-bit rows (padded to a byte), 1 = black: pixels darker +/// than middle grey. +fn bits(doc: &Document) -> Vec { + let w = doc.size.width as usize; + let row = w.div_ceil(8); + let mut out = Vec::with_capacity(row * doc.size.height as usize); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + for line in band.px.chunks(w.max(1)) { + let mut bytes = vec![0u8; row]; + for (x, p) in line.iter().enumerate() { + let grey = (p[0] + p[1] + p[2]) / 3.0 * p[3] + (1.0 - p[3]); + if grey < 0.5 { + bytes[x / 8] |= 0x80 >> (x % 8); + } + } + out.extend(bytes); + } + Ok(()) + }); + out +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4eada2422d..9b5878af7d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1428,7 +1428,17 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } + if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb) { + return file; + } let fmt = doc.pixel_format(); + // A Duotone keeps the inks it was imported with (their Color Mode Data is undocumented and + // kept verbatim) over its grey plate; Photoshop has no 32-bit Duotone. + let duotone_inks = doc + .duotone + .as_ref() + .and_then(|d| d.psd_raw.as_ref()) + .filter(|_| doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample != SampleType::F32); let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; @@ -1458,7 +1468,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c } else if size_exceeds_limit && !opts.force_psb { ex.warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into()); } - if fmt.mode != doc.mode { + if fmt.mode != doc.mode && duotone_inks.is_none() { ex.warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode)); } // Assign layer ids up front (stable across round trips): keep unique @@ -1568,7 +1578,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; - let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode)); + let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) }; + let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), mode); let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression }; let image_data = ImageData::encode(mcomp, &planes, &header) .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) @@ -1706,7 +1717,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c } // 32-bit files carry Photoshop's HDR toning records as Color Mode Data; Photoshop will not // open a 32-bit file without them (#291). - let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); + let color_mode_data = match duotone_inks { + Some(raw) => raw.to_vec(), + None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth), + }; let file = PsdFile { header, color_mode_data, diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 1e77cdaa6a..1517300131 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -828,8 +828,12 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) if h.color_mode == PsdMode::Indexed && file.color_mode_data.len() >= 768 { // Planar palette: 256 reds, 256 greens, 256 blues (the Color Table). let m = &file.color_mode_data; - let colors = (0..256).map(|i| [m[i], m[256 + i], m[512 + i]]).collect(); - doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None }); + // Resource 1046 says how many entries are used, 1047 which one is transparent. + let u16_of = |id: u16| file.resource(id).and_then(|r| r.data.get(..2)).map(|b| u16::from_be_bytes([b[0], b[1]])); + let count = u16_of(crate::palette_map::COLOR_TABLE_COUNT).map_or(256, usize::from).clamp(1, 256); + let colors = (0..count).map(|i| [m[i], m[256 + i], m[512 + i]]).collect(); + let transparent = u16_of(crate::palette_map::TRANSPARENCY_INDEX).filter(|&i| usize::from(i) < count).map(|i| i as u8); + doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() { // The duotone ink block is undocumented: keep it raw; the image shows as its gray plate. doc.duotone = diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index e893adaf94..5e4f85a574 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -177,6 +177,47 @@ fn fallback_modes_import_flattened() { } } +#[test] +fn palette_and_duotone_modes_are_written_in_their_own_mode() { + // Indexed Color and Bitmap are written flat in their own mode (palette / 1 bit); a Duotone + // keeps the ink data it was imported with over its grey plate. Same image, same mode data. + for mode in [ColorMode::Indexed, ColorMode::Bitmap, ColorMode::Duotone] { + let depth = testgen::mode_depths(mode)[0]; + let f = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 9, 5); + let (d, _) = psd_to_document(&f); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + assert!(out.warnings.iter().all(|w| !w.contains("written as")), "{mode:?}: {:?}", out.warnings); + let back = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!((back.header.color_mode, back.header.depth, back.header.channels), (mode, f.header.depth, 1), "{mode:?}"); + assert_eq!(back.composite_rgba8().unwrap(), f.composite_rgba8().unwrap(), "{mode:?}"); + if mode != ColorMode::Bitmap { + assert_eq!(back.color_mode_data, f.color_mode_data, "{mode:?}"); + } + } +} + +#[test] +fn indexed_psd_keeps_the_table_length_and_transparent_entry() { + use photocraft_doc::ColorTable; + let mut d = photocraft_doc::Document::with_background( + "i", + photocraft_doc::Size::new(4, 2), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), + ); + d.mode = photocraft_color::ColorMode::Indexed; + d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0], [0, 0, 255]], transparent: Some(2) }); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!(f.header.color_mode, ColorMode::Indexed); + assert_eq!(f.resource(1046).map(|r| r.data.clone()), Some(vec![0, 3])); + assert_eq!(f.resource(1047).map(|r| r.data.clone()), Some(vec![0, 2])); + assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "every pixel is entry 1 (red)"); + let (back, _) = psd_to_document(&f); + assert_eq!(back.color_table, d.color_table); +} + #[test] fn multichannel_imports_ink_channels() { for depth in testgen::mode_depths(ColorMode::Multichannel) { From 5f59f0671aca1e0ba3ffa4345f4930c3a51b5c91 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 11:51:34 +0200 Subject: [PATCH 003/304] Layered TIFF: Indexed, Bitmap and Duotone layers are modelled in their pixel format The layered TIFF export builds its layer data through the PSD exporter, which now writes these modes in their own PSD mode (palette, 1 bit, duotone). TIFF has no such layout and interleaves the merged image in the pixel format, so the PSD model for it is made in that format (RGB or Grayscale), as before. Refs #4814 --- crates/io/src/tiff_layers.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 0aca0fe882..043f83068b 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -203,6 +203,16 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // keeps straight alpha) and uncompressed (its planes are interleaved below). let psd_opts = PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; + // TIFF has no palette, bitmap or duotone layout: the PSD model of an Indexed Color, Bitmap + // or Duotone document is made in its pixel format (RGB or Grayscale), which the PSD exporter + // otherwise writes in the document's own mode. + let plain; + let doc = if matches!(doc.mode, ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone) { + plain = Document { mode: doc.pixel_format().mode, ..doc.clone() }; + &plain + } else { + doc + }; let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; From ebd3c12270083e9bee36e91cb8ec1fdc18e74671 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 18:20:12 +0200 Subject: [PATCH 004/304] Large documents: a gigapixel New Document draws without freezing (#4017) The reduced CPU canvas composites one-colour tiles once per colour instead of every document pixel, and GPU canvas textures the device refuses fall back to the CPU canvas instead of disabling the GPU for the session. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 156 +++++++++++++++++++-- crates/compose/src/tests.rs | 102 ++++++++++++++ crates/compose/src/uniform.rs | 107 ++++++++++++++ crates/ui-egui/examples/perf_scenarios.rs | 29 ++++ crates/ui-egui/src/gpu_canvas.rs | 31 +++- crates/ui-egui/src/gpu_canvas_mip_tests.rs | 24 ++++ docs/scorecard.md | 3 +- perf/budgets.toml | 7 + 8 files changed, 439 insertions(+), 20 deletions(-) create mode 100644 crates/compose/src/uniform.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f1625b7a22..67a349b8a4 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -29,6 +29,7 @@ pub mod pattern; pub mod proxy; pub mod psblend; pub mod shape_split; +mod uniform; use photocraft_color::blend::BlendMode; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; @@ -377,6 +378,38 @@ pub fn render_reduced_damage(doc: &Document, w: u32, h: u32, damage: Rect) -> Bu /// [`render_reduced_rect`] (or, with `damage`, [`render_reduced_damage`]) of area `b` with an /// explicit band height (see [`render_bands`]). fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Option, band_rows: i32) -> Buffer { + render_reduced_with(doc, b, w, h, damage, band_rows, true, &mut ReducedStats::default()) +} + +/// What a reduced render composited (tests check that one-colour areas are not composited). +#[derive(Debug, Default)] +struct ReducedStats { + /// Whether one-colour areas were looked for ([`uniform::UniformAreas`]). + uniform: bool, + /// Document pixels composited one by one. + composited: u64, +} + +/// A tile of a reduced render: one colour (premultiplied), or its composited pixels. +enum Part { + Solid([f32; 4]), + Px(Buffer), +} + +/// A column of document tiles in a reduced render (`b`-relative source columns). +struct TileColumn { + /// The columns rendered: the tile's, within the source area. + x0: usize, + x1: usize, + /// The whole tile's columns within `b`, whose values decide whether it is one colour. + whole: (i64, i64), + /// Runs of (output column, how many of the rendered columns it averages). + runs: Vec<(usize, f32)>, +} + +/// [`render_reduced_in_bands`]; `uniform` off composites every pixel (the reference in tests). +#[allow(clippy::too_many_arguments)] +fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option, band_rows: i32, uniform: bool, stats: &mut ReducedStats) -> Buffer { let (fw, fh) = (b.width() as usize, b.height() as usize); let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize); let full = Rect::from_xywh(0, 0, w as u32, h as u32); @@ -420,22 +453,14 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti let (cols, rows) = (map(fw, w), map(fh, h)); let cols = &cols[sx0..sx1]; let mut acc = vec![[0.0f32; 4]; ow * oh]; - let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { - let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); + // Each output row sums its source rows in a band (in order, top to bottom), each row's pixels + // left to right: `row(y, out)` adds source row `y` (band-relative) to output row `out`. + let sum_band = |acc: &mut [[f32; 4]], by0: usize, by1: usize, row: &(dyn Fn(usize, &mut [[f32; 4]]) + Sync)| { let (t0, t1) = (rows[by0] as usize, rows[by1 - 1] as usize + 1); - let sw = sx1 - sx0; - // Each output row sums its source rows in this band (in order, top to bottom). let sum_row = |t: usize, out: &mut [[f32; 4]]| { let (a, z) = span(t, fh, h); for y in a.max(by0)..z.min(by1) { - let src = &band.px[(y - by0) * sw..(y - by0 + 1) * sw]; - for (p, &c) in src.iter().zip(cols) { - let o = &mut out[c as usize - ox0]; - for i in 0..3 { - o[i] += p[i] * p[3]; - } - o[3] += p[3]; - } + row(y - by0, out); } }; let part = &mut acc[(t0 - oy0) * ow..(t1 - oy0) * ow]; @@ -448,9 +473,110 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti for (i, out) in part.chunks_mut(ow).enumerate() { sum_row(t0 + i, out); } - Ok(()) - }); - debug_assert!(ok.is_ok()); + }; + let add = |o: &mut [f32; 4], p: &[f32; 4]| { + for i in 0..3 { + o[i] += p[i] * p[3]; + } + o[3] += p[3]; + }; + if let Some(uniform) = uniform.then(|| uniform::UniformAreas::new(doc)).flatten() { + stats.uniform = true; + // One-colour tiles (a new document's background, the empty parts of a sparse one) are + // composited once per colour and summed per run of source columns, not per pixel + // (#4017). Tiles lie on the document's tile grid whatever `damage` is, so a damage + // render sums exactly what the whole reduction does. + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(doc, &patterns); + let t = RENDER_TILE as usize; + // The document tile grid (the surfaces' own), in `b`-relative coordinates: the start of + // the tile holding `v` (negative when `b` starts inside a tile). + let grid = |v: usize, o: i32| (i64::from(o) + v as i64).div_euclid(t as i64) * t as i64 - i64::from(o); + let tile_cols: Vec = (grid(sx0, b.x0)..sx1 as i64) + .step_by(t) + .map(|gx| { + let (x0, x1) = ((gx.max(0) as usize).max(sx0), ((gx + t as i64) as usize).min(sx1)); + let mut runs: Vec<(usize, f32)> = Vec::new(); + for &c in &cols[x0 - sx0..x1 - sx0] { + match runs.last_mut() { + Some((last, n)) if *last == c as usize - ox0 => *n += 1.0, + _ => runs.push((c as usize - ox0, 1.0)), + } + } + TileColumn { x0, x1, whole: (gx.max(0), (gx + t as i64).min(fw as i64)), runs } + }) + .collect(); + let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize; + let mut y = grid(sy0, b.y0); + while y < sy1 as i64 { + let y1 = (y + band_h as i64).min(sy1 as i64); + let tiles: Vec<(Rect, Rect)> = (y..y1) + .step_by(t) + .flat_map(|gy| { + tile_cols.iter().map(move |&TileColumn { x0, x1, whole: (gx0, gx1), .. }| { + let at = |x0: i64, y0: i64, x1: i64, y1: i64| Rect::new(b.x0 + x0 as i32, b.y0 + y0 as i32, b.x0 + x1 as i32, b.y0 + y1 as i32); + let (gy0, gy1) = (gy.max(0), (gy + t as i64).min(fh as i64)); + (at(gx0, gy0, gx1, gy1), at(x0 as i64, gy0.max(sy0 as i64), x1 as i64, gy1.min(sy1 as i64))) + }) + }) + .collect(); + let one = |(whole, part): &(Rect, Rect)| -> Part { + let render = |r: Rect| render_tiled_with(doc, r, RENDER_TILE, &cx); + match uniform.color(*whole, |r| render(r).px.first().copied()) { + Some(p) => Part::Solid([p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]), + None => Part::Px(render(*part)), + } + }; + #[cfg(not(target_arch = "wasm32"))] + let parts: Vec = { + use rayon::prelude::*; + tiles.par_iter().map(one).collect() + }; + #[cfg(target_arch = "wasm32")] + let parts: Vec = tiles.iter().map(one).collect(); + stats.composited += parts.iter().map(|p| if let Part::Px(b) = p { b.px.len() as u64 } else { 0 }).sum::(); + let (by0, by1) = (y.max(sy0 as i64) as usize, y1 as usize); + let n = tile_cols.len(); + let band_y = y; + sum_band(&mut acc, by0, by1, &|row: usize, out: &mut [[f32; 4]]| { + let y = (by0 + row) as i64; + let j = (y - band_y) as usize / t; + let ty = (y - (band_y + (j * t) as i64).max(sy0 as i64)) as usize; + for (TileColumn { x0, x1, runs, .. }, part) in tile_cols.iter().zip(parts.iter().skip(j * n).take(n)) { + match part { + Part::Solid(pa) => { + for &(c, k) in runs { + let o = &mut out[c]; + for i in 0..4 { + o[i] += pa[i] * k; + } + } + } + Part::Px(buf) => { + let tw = x1 - x0; + for (p, &c) in buf.px.iter().skip(ty * tw).take(tw).zip(&cols[x0 - sx0..x1 - sx0]) { + add(&mut out[c as usize - ox0], p); + } + } + } + } + }); + y = y1; + } + } else { + let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { + stats.composited += band.px.len() as u64; + let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); + let sw = sx1 - sx0; + sum_band(&mut acc, by0, by1, &|y: usize, out: &mut [[f32; 4]]| { + for (p, &c) in band.px[y * sw..(y + 1) * sw].iter().zip(cols) { + add(&mut out[c as usize - ox0], p); + } + }); + Ok(()) + }); + debug_assert!(ok.is_ok()); + } for (i, p) in acc.iter_mut().enumerate() { let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h)); let n = ((y1 - y0) * (x1 - x0)).max(1) as f32; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 69504e4a93..a41ddfa62a 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2570,3 +2570,105 @@ fn effect_cache_follows_a_group_childs_blend_if() { assert!(before.px != cold.px, "Blend If should change the group's shadow"); assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed"); } + +/// A document whose one-colour areas are found: shared background tiles, a painted patch over +/// tile edges, a uniform mask tile, an adjustment, a group, a 16-bit layer and a hidden one. +fn sparse_doc(w: u32, h: u32) -> Document { + let mut d = doc_white(w, h); + let mut paint = Layer::raster("paint", PixelFormat::RGBA8); + let s = paint.surface_mut().unwrap(); + for y in 200..330 { + s.fill_rect(Rect::new(240, y, 520, y + 1), &[(y - 200) as f32 / 130.0, 0.4, 0.7, 0.8]); + } + s.fill_rect(Rect::new(0, 600, w as i32 / 2, 900), &[0.2, 0.9, 0.1, 0.5]); + let mut m = LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(0, 512, 256, 768), &[0.25]); + m.surface.fill_rect(Rect::new(300, 700, 310, 710), &[0.0]); + paint.mask = Some(m); + paint.blend = BlendMode::Multiply; + let mut deep = solid_layer("deep", Rect::new(100, 100, 700, 400), [0.3, 0.3, 0.9, 0.6]); + deep.content = LayerContent::Raster(deep.surface().unwrap().convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true))); + let mut hidden = solid_layer("hidden", Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0]); + hidden.visible = false; + d.layers.push(Layer::group("g", vec![paint, deep, hidden])); + d.layers.push(Layer::new("levels", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.0, gamma: 1.1 }))); + d +} + +#[test] +fn one_colour_areas_reduce_like_composited_ones() { + // #4017: one-colour tiles are composited once and summed per run of columns: the same + // averages as compositing every pixel (to float rounding), and a damage render gives exactly + // the whole reduction's pixels, so a reduced canvas updated in parts shows no seams. + let d = sparse_doc(2000, 2100); + for (b, w, h, band) in [ + (d.bounds(), 37, 41, 0), + (d.bounds(), 250, 2100, 256), + (d.bounds(), 1999, 3, 512), + (Rect::new(-300, -77, 2200, 2300), 61, 29, 0), + (Rect::new(13, 5, 2000, 2100), 1, 1, 256), + ] { + let reference = render_reduced_with(&d, b, w, h, None, band, false, &mut ReducedStats::default()); + let mut stats = ReducedStats::default(); + let all = render_reduced_with(&d, b, w, h, None, band, true, &mut stats); + assert!(stats.uniform); + assert!(stats.composited < u64::from(b.width()) * u64::from(b.height()) / 2, "{b:?}: {stats:?}"); + assert_eq!(all.rect, reference.rect); + for (p, q) in all.px.iter().zip(&reference.px) { + assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-4), "{b:?} {w}x{h} band {band}: {p:?} vs {q:?}"); + } + for dmg in [Rect::new(250, 210, 260, 220), Rect::new(0, 0, 2000, 2100), Rect::new(-50, 640, 3000, 641), Rect::new(1500, 1500, 1600, 1900)] { + let part = render_reduced_with(&d, b, w, h, Some(dmg), band, true, &mut ReducedStats::default()); + for y in part.rect.y0..part.rect.y1 { + for x in part.rect.x0..part.rect.x1 { + assert_eq!(part.get(x, y), all.get(x, y), "{b:?} {w}x{h} band {band} {dmg:?} at {x},{y}"); + } + } + } + } +} + +#[test] +fn layers_that_read_other_pixels_are_composited_pixel_by_pixel() { + let base = sparse_doc(300, 300); + let edits: [fn(&mut Layer); 4] = [ + |l| l.blend = BlendMode::Dissolve, + |l| l.mask.get_or_insert_with(LayerMask::reveal_all).feather = 3.0, + |l| l.effects.items.push(stroke(2.0, StrokePosition::Outside)), + |l| l.content = LayerContent::Fill(Fill::Solid(Color::BLACK)), + ]; + for edit in edits { + let mut d = base.clone(); + edit(&mut d.layers[0]); + let mut stats = ReducedStats::default(); + render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats); + assert!(!stats.uniform); + assert_eq!(stats.composited, 300 * 300); + } + let mut d = base.clone(); + d.layers.push(Layer::new("map", LayerContent::Adjustment(Adjustment::GradientMap { stops: vec![], reverse: false, dither: true }))); + let mut stats = ReducedStats::default(); + render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats); + assert!(!stats.uniform); +} + +#[test] +fn a_gigapixel_new_document_reduces_without_compositing_its_canvas() { + // #4017: the reduced canvas of a 10 x 5 m, 200 ppi document (3.1 GP) composited every pixel + // (25 s on 32 threads, all cores busy). Only the partial tiles at its right and bottom edges + // hold two values; the rest is one shared white tile. + let (w, h) = (78_740u32, 39_370u32); + for fill in [Some(Color::WHITE), Some(Color::rgba(0.2, 0.4, 0.6, 1.0)), None] { + let mut d = match fill { + Some(c) => Document::with_background("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8, c), + None => Document::new("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8), + }; + d.layers.push(Layer::raster("Layer 1", PixelFormat::RGBA8)); + let mut stats = ReducedStats::default(); + let r = render_reduced_with(&d, d.bounds(), 3937, 1969, None, 0, true, &mut stats); + let edge_tiles = u64::from(w.div_ceil(256) + h.div_ceil(256) - 1); + assert!(stats.composited <= edge_tiles * 256 * 256, "{fill:?}: {stats:?}"); + let want = fill.map_or([0.0; 4], |c| [c.c[0], c.c[1], c.c[2], 1.0]); + assert!(r.px.iter().all(|p| close4(*p, want)), "{fill:?}"); + } +} diff --git a/crates/compose/src/uniform.rs b/crates/compose/src/uniform.rs new file mode 100644 index 0000000000..e9423d0e69 --- /dev/null +++ b/crates/compose/src/uniform.rs @@ -0,0 +1,107 @@ +//! One-colour areas of a composite, known without compositing them pixel by pixel. +//! +//! A new document is a canvas of one shared white tile (or no tiles at all), and a sparse one is +//! mostly that. When every layer composites pixel by pixel from its own pixels (no effects, +//! feathered masks, dissolve, dithering or layer kinds laid out on the canvas), an area over which +//! every surface holds one value composites to one colour: one pixel of it is composited and +//! stands for the rest. The reduced canvas of a gigapixel document (#4017) then costs what its +//! painted tiles cost, not a composite of every document pixel. + +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; + +use photocraft_color::blend::BlendMode; +use photocraft_doc::{Adjustment, Document, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// The surfaces a document composites from, when its composite at each pixel depends only on +/// their values at that pixel. +pub(crate) struct UniformAreas<'a> { + surfaces: Vec<&'a Surface>, + /// One value per tile (by address), or `None` when its pixels differ: each tile is scanned once. + tile_values: Mutex>>>, + /// The composite colour of each combination of surface values met so far. + colors: Mutex, [f32; 4]>>, +} + +impl<'a> UniformAreas<'a> { + /// `None` when some layer's composite depends on more than its pixels at the same place. + pub(crate) fn new(doc: &'a Document) -> Option { + if doc.mode == photocraft_color::ColorMode::Multichannel { + return None; + } + let mut surfaces = Vec::new(); + for (_, _, l) in doc.walk() { + let effects = l.effects.enabled && !l.effects.items.is_empty(); + if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve { + return None; + } + if let Some(m) = &l.mask { + if m.feather != 0.0 { + return None; + } + surfaces.push(&m.surface); + } + match &l.content { + LayerContent::Raster(s) => surfaces.push(s), + LayerContent::Group(g) if g.artboard.is_none() => {} + LayerContent::Adjustment(a) if per_pixel(a) => {} + _ => return None, + } + } + Some(Self { surfaces, tile_values: Mutex::default(), colors: Mutex::default() }) + } + + /// The colour every pixel of `r` composites to, when every surface holds one value over it; + /// `composite` renders one pixel the first time a combination of values is met. + pub(crate) fn color(&self, r: Rect, composite: impl FnOnce(Rect) -> Option<[f32; 4]>) -> Option<[f32; 4]> { + if r.is_empty() { + return None; + } + let mut key = Vec::new(); + for s in &self.surfaces { + key.extend_from_slice(&self.value_over(s, r)?); + } + if let Some(c) = self.colors.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { + return Some(*c); + } + let c = composite(Rect::from_xywh(r.x0, r.y0, 1, 1))?; + self.colors.lock().unwrap_or_else(PoisonError::into_inner).insert(key, c); + Some(c) + } + + /// The one encoded value `s` holds over `r`, if it holds one. + fn value_over(&self, s: &Surface, r: Rect) -> Option> { + let mut found: Option> = None; + for c in r.tiles() { + let v = match s.tile(c) { + None => Some(Box::from(s.default_bytes())), + Some(t) => { + let id = std::sync::Arc::as_ptr(t) as usize; + let cached = self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).get(&id).cloned(); + match cached { + Some(v) => v, + None => { + let bpp = s.default_bytes().len().max(1); + let mut px = t.bytes().chunks_exact(bpp); + let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from); + self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone()); + v + } + } + } + }?; + if found.as_ref().is_some_and(|f| *f != v) { + return None; + } + found = Some(v); + } + found + } +} + +/// Whether adjustment `a` maps each pixel on its own (dithered ones vary with the position). +fn per_pixel(a: &Adjustment) -> bool { + !matches!(a, Adjustment::GradientMap { dither: true, .. } | Adjustment::ColorLookup { dither: true, .. }) +} diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 1b71258473..1ac4c8ea87 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -252,6 +252,8 @@ struct Sizes { big_photo: (u32, u32), /// A4 at 300 ppi. a4: (u32, u32), + /// A new 10 × 5 m document at 200 ppi (3.1 GP, #4017). + huge_new: (u32, u32), } const FULL: Sizes = Sizes { @@ -263,6 +265,7 @@ const FULL: Sizes = Sizes { ten_mp: (3872, 2592), big_photo: (6000, 4000), a4: (2480, 3508), + huge_new: (78_740, 39_370), }; const QUICK: Sizes = Sizes { @@ -274,6 +277,7 @@ const QUICK: Sizes = Sizes { ten_mp: (1200, 800), big_photo: (1600, 1066), a4: (1240, 1754), + huge_new: (20_000, 10_000), }; /// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke, @@ -866,6 +870,7 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) { } } liquify_preview_scenario(b); + huge_new_scenario(b, sz); let cmyk = "brush dab on A4 300 ppi CMYK + refresh"; if b.wanted(cmyk) { let (w, h) = sz.a4; @@ -929,6 +934,30 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) { } } +/// #4017: File > New at 10 × 5 m, 200 ppi (3.1 GP), and the first refresh of the canvas that +/// draws a document too large for GPU textures (the reduced CPU canvas, `canvas::ensure_texture`). +fn huge_new_scenario(b: &mut Bench, sz: &Sizes) { + let name = "new 3 GP document + first reduced canvas refresh"; + if !b.wanted(name) { + return; + } + let (w, h) = sz.huge_new; + let reps = b.reps.min(3); + b.time(name, &mut Session::new(), reps, false, |_, s, _| { + let t = Instant::now(); + exec(s, "file.new", json!({"width": w, "height": h, "resolution": 200, "background": "white"}))?; + let doc = &s.active().ok_or("no document")?.doc; + let factor = w.max(h).div_ceil(photocraft_ui_egui::canvas::MAX_TEXTURE).max(1); + let reduced = photocraft_compose::render_reduced(doc, w / factor, h / factor); + if reduced.px.first() != Some(&[1.0; 4]) { + return Err("the new document is not white".into()); + } + let v = ms(t); + exec(s, "file.close", json!({}))?; + Ok(v) + }); +} + /// A source-resolution Liquify brush update on the visible part of a 3500×2200 layer. /// Reports the old whole-document proxy cost alongside the full-resolution cropped update. fn liquify_preview_scenario(b: &mut Bench) { diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index bcf0e69516..0126f5aaf1 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1553,11 +1553,34 @@ fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu: impl DocTextures { /// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit - /// the canvas budget. - fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option { - if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget { + /// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB + /// of textures, #4017) lowers the budget below it, so that canvas and larger ones are drawn + /// on the CPU canvas instead of the out-of-memory error disabling the GPU for the session. + fn new(device: &wgpu::Device, res: &mut Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option { + let bytes = canvas_texture_bytes(size, tile, texel_bytes(format)); + if bytes > res.canvas_budget { return None; } + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let set = Self::create(device, res, size, tile, format); + // Native wgpu reports these synchronously; where they arrive later (WebGPU) none is seen. + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut error = None; + for pop in [memory.pop(), validation.pop()] { + if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) { + error.get_or_insert(e); + } + } + if let Some(e) = error { + log::warn!("{} MB of canvas textures failed ({e}); larger canvases are drawn on the CPU", bytes >> 20); + res.canvas_budget = res.canvas_budget.min(bytes.saturating_sub(1)); + return None; + } + Some(set) + } + + fn create(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self { let mut tiles = Vec::new(); for ty in (0..size[1]).step_by(tile as usize) { for tx in (0..size[0]).step_by(tile as usize) { @@ -1592,7 +1615,7 @@ impl DocTextures { tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); } } - Some(Self { size, format, tiles }) + Self { size, format, tiles } } /// Write `size` pixels at document `origin` from `texels` (in the texture format, row stride diff --git a/crates/ui-egui/src/gpu_canvas_mip_tests.rs b/crates/ui-egui/src/gpu_canvas_mip_tests.rs index a1c3e8ee14..b61b77ff1b 100644 --- a/crates/ui-egui/src/gpu_canvas_mip_tests.rs +++ b/crates/ui-egui/src/gpu_canvas_mip_tests.rs @@ -63,6 +63,30 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() { gpu.rs.renderer.write().callback_resources.get_mut::().unwrap().separate_mip_targets = separate; check_mips(&gpu); } + // On the same device (see the top of the file). + canvas_textures_the_gpu_refuses_leave_it_working(&gpu); +} + +/// #4017: a 3 GP canvas's 16 GB of textures failed with Out of Memory, and the uncaptured error +/// disabled the GPU for the session. A refused set is now caught: nothing is kept, the budget +/// drops below it (that canvas goes to the CPU canvas) and the device stays usable. A texture +/// wider than the device allows is refused the same way, synchronously, on any adapter. +fn canvas_textures_the_gpu_refuses_leave_it_working(gpu: &GpuCanvas) { + let max = gpu.rs.device.limits().max_texture_dimension_2d; + let (size, tile) = ([max + 1, 1], max + 1); + let bytes = canvas_texture_bytes(size, tile, texel_bytes(FORMAT)); + { + let mut renderer = gpu.rs.renderer.write(); + let res = renderer.callback_resources.get_mut::().unwrap(); + res.canvas_budget = u64::MAX; + assert!(DocTextures::new(&gpu.rs.device, res, size, tile, FORMAT).is_none()); + assert!(res.canvas_budget < bytes, "{} >= {bytes}", res.canvas_budget); + } + assert!(gpu.health.is_ok(), "{:?}", gpu.fault()); + // Smaller canvases still get GPU textures. + gpu.upload_buffer_full(3, &Buffer::filled(Rect::from_xywh(0, 0, 64, 64), [0.5, 0.5, 0.5, 1.0]), SampleType::U8); + assert!(gpu.rs.renderer.read().callback_resources.get::().unwrap().docs.contains_key(&3)); + assert!(gpu.health.is_ok(), "{:?}", gpu.fault()); } fn check_mips(gpu: &GpuCanvas) { diff --git a/docs/scorecard.md b/docs/scorecard.md index af13a3f265..1e038f1617 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 23 | 57 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 18 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | @@ -75,6 +75,7 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | P57 | Chisel bevel size change, 12 MP text paragraph, size 250 (GPU) | track only | – | – | – | – | – | not run | | | P58 | Chisel bevel size change, 12 MP ellipse, size 8 (CPU) | track only | – | – | – | – | – | not run | | | P59 | Chisel bevel size change, 12 MP text paragraph, size 8 (CPU) | track only | – | – | – | – | – | not run | | +| P61 | File > New 10 x 5 m at 200 ppi (3.1 GP) + first reduced canvas refresh | track only | – | – | – | – | – | not run | [#4017](https://github.com/storytold/photocraft/issues/4017) | **Not measurable yet** (the code can't do it yet; no number is recorded): diff --git a/perf/budgets.toml b/perf/budgets.toml index a95c1c83dc..d02f7d1ce1 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -518,3 +518,10 @@ id = "P59" title = "Chisel bevel size change, 12 MP text paragraph, size 8 (CPU)" bench = "bevel_bench" row = "bevel size change to 8, text (CPU)" + +[[scenario]] +id = "P61" +title = "File > New 10 x 5 m at 200 ppi (3.1 GP) + first reduced canvas refresh" +issue = 4017 +bench = "perf_scenarios" +row = "new 3 GP document + first reduced canvas refresh" From f805b12a17763ca64cc5c489ab2f5b64aec2c9ff Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 21:47:41 +0200 Subject: [PATCH 005/304] PSD: a flat Bitmap file saves in Bitmap mode and opens as a Background again #3560's test expected the flat Bitmap file to be saved with a layer section. With #4814 Bitmap is written in its own mode, which has no layers in Photoshop: the test now checks the Bitmap header, the missing layer section and that the saved file reopens with its Background. --- crates/io/src/psd_export.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4a2cd1a348..43d829da05 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1853,8 +1853,14 @@ mod tests { let doc = crate::import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; let bg = &doc.layers[0]; assert!(bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); + // Bitmap has no layers in Photoshop (#4814): the file is saved flat in Bitmap mode, without + // a layer section, and opens as a Background again. let out = document_to_psd(&doc); - assert!(out.layer_info.unwrap().layers[0].channels.iter().all(|c| c.id >= 0)); + assert_eq!(out.header.color_mode, PsdMode::Bitmap); + assert!(out.layer_info.as_ref().is_none_or(|li| li.layers.is_empty())); + let back = crate::import("flat.psd", &out.to_bytes().unwrap()).unwrap().document; + let bg = &back.layers[0]; + assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); } #[test] From e2ed5cdcb7e53586fdf5ad9e44abc0dee64cd285 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:41:22 +0800 Subject: [PATCH 006/304] Limit blend mode hover refresh to the visible canvas --- crates/ui-egui/src/blend_preview.rs | 31 +++- crates/ui-egui/src/canvas.rs | 14 +- .../src/canvas/blend_viewport_tests.rs | 142 ++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/canvas/blend_viewport_tests.rs diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index e9f010613a..b73ddd553b 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -29,6 +29,8 @@ pub(crate) struct BlendPreview { area: Option, /// The document shown for `mode`, with its key. shown: Option<(u64, Arc)>, + /// GPU preview key, affected region (including effects), and the area last refreshed. + covered: Option<(u64, Rect, Rect)>, } /// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the @@ -47,7 +49,7 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option Rect { + let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision) else { return region }; + if !p.shown.as_ref().is_some_and(|(shown, _)| *shown == key) { + p.covered = None; + return region; + } + let shown = region.intersect(&visible); + p.covered = Some((key, region, shown)); + shown +} + +/// A pan or zoom can expose pixels not yet rendered for the current preview key. +pub(crate) fn uncovered(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, visible: Rect) -> Option { + let p = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision)?; + let (covered_key, region, covered) = p.covered?; + if covered_key != key { + return None; + } + let want = region.intersect(&visible); + if want.is_empty() || covered.contains_rect(&want) { + return None; + } + p.covered = Some((key, region, want)); + Some(want) +} + fn is_preview_key(key: u64) -> bool { (BASE..BASE << 1).contains(&key) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 676d068070..f2cc88f074 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1545,6 +1545,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: let display_key = texture_key(display.as_deref()); let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key)); let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage)); + if let Some(region) = damage { + damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, visible)); + } // To an adjustment dialog's preview: only what the view shows now (the rest as it moves there). if damage.is_some() && let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::switch_region(app, id, revision, s.1 ^ display_key, raw_key)) @@ -1580,7 +1583,12 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); } - return true; + // Blend previews keep offscreen pixels untouched until the view reaches them. + // Use the normal refresh path so CPU fallback and pending redraws stay synchronized. + damage = crate::blend_preview::uncovered(app, id, revision, raw_key, visible); + if damage.is_none() { + return true; + } } // Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas // draws it (checked before the memory budget, which walks the history, every frame). @@ -8003,3 +8011,7 @@ mod rotation_preview_isolation_tests { #[cfg(test)] #[path = "canvas_display_memo_tests.rs"] mod display_memo_tests; + +#[cfg(test)] +#[path = "canvas/blend_viewport_tests.rs"] +mod blend_viewport_regression; diff --git a/crates/ui-egui/src/canvas/blend_viewport_tests.rs b/crates/ui-egui/src/canvas/blend_viewport_tests.rs new file mode 100644 index 0000000000..fe06c0ba72 --- /dev/null +++ b/crates/ui-egui/src/canvas/blend_viewport_tests.rs @@ -0,0 +1,142 @@ +use super::*; +use photocraft_color::BlendMode; +use serde_json::json; + +fn render_state() -> Option { + match std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) { + Ok(rs) => Some(rs), + Err(error) => { + if std::env::var_os("PHOTOCRAFT_REQUIRE_GPU_TESTS").is_some() { + std::panic::resume_unwind(error); + } + eprintln!("skipping blend viewport regression: no GPU adapter"); + None + } + } +} + +#[test] +fn blend_hover_refresh_is_limited_to_visible_canvas() { + check_blend_viewport(false); +} + +#[test] +fn blend_hover_cpu_fallback_refresh_tracks_visible_canvas() { + check_blend_viewport(true); +} + +#[test] +fn blend_hover_during_background_redraw_restores_committed_pixels() { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.background_jobs = true; + app.run("file.new", json!({"width":2600,"height":1700,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap(); + let ctx = egui::Context::default(); + let visible = DRect::new(256, 256, 512, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert!(!app.redraws.is_empty(), "fixture must start a deferred full redraw"); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + let panned = DRect::new(2048, 1024, 2304, 1280); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + settle_redraw(&mut app); + assert_visible_pixels(&mut app, panned); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), None); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + settle_redraw(&mut app); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); +} + +fn settle_redraw(app: &mut PhotocraftApp) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let gpu = app.gpu.clone().unwrap(); + let id = app.session.active().unwrap().doc.id; + while !app.redraws.is_empty() { + crate::background_redraw::poll(app, &gpu, id); + assert!(std::time::Instant::now() < deadline, "background redraw did not finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +fn assert_visible_pixels(app: &mut PhotocraftApp, visible: DRect) { + let (doc, _) = display_doc(app, 0); + let gpu = app.gpu.clone().unwrap(); + let reference = doc.id.0 ^ (1u64 << 60); + let (display, _) = canvas_display(app, &doc, None); + gpu.upload_composite(reference, &doc, display.as_deref()); + let (_, size, shown) = gpu.read_texels(doc.id.0).unwrap(); + let (_, _, want) = gpu.read_texels(reference).unwrap(); + for y in visible.y0..visible.y1 { + for x in visible.x0..visible.x1 { + let index = y as usize * size[0] as usize + x as usize; + for channel in 0..4 { + assert!((shown[index][channel] - want[index][channel]).abs() <= 2.0 / 255.0, "preview differs at ({x},{y}) channel{channel}"); + } + } + } +} + +fn check_blend_viewport(cpu_fallback: bool) { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + if cpu_fallback { + app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap(); + } + let ctx = egui::Context::default(); + let visible = DRect::new(256, 256, 512, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert!(app.redraws.is_empty(), "small fixture must finish synchronously"); + if cpu_fallback { + assert!(!app.perf.last_refresh.starts_with("gpu"), "fixture must exercise CPU composition fallback"); + } + let id = app.session.active().unwrap().doc.id; + let gpu = app.gpu.clone().unwrap(); + let original = gpu.read_texels(id.0).unwrap(); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + println!("BLEND_VIEWPORT kind={} pixels={} expected={} adapter={:?}", app.perf.last_refresh, app.perf.last_refresh_px, 256 * 256, app.perf.gpu_info); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "hover must not recomposite offscreen pixels of a full-canvas layer"); + assert_visible_pixels(&mut app, visible); + app.perf.last_refresh_px = 0; + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 0, "unchanged view and mode must reuse the texture"); + let zoomed_out = DRect::new(128, 128, 640, 640); + assert!(ensure_gpu(&mut app, &ctx, 0, zoomed_out)); + assert_eq!(app.perf.last_refresh_px, 512 * 512, "zooming out catches up the larger visible area"); + let panned = DRect::new(640, 256, 896, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "newly exposed pixels must catch up without a mode change"); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Screen)); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + assert_eq!(app.perf.last_refresh_px, 256 * 256); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "returning view must show the current mode"); + assert_visible_pixels(&mut app, visible); + assert_visible_pixels(&mut app, panned); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), None); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(gpu.read_texels(id.0).unwrap(), original, "cancel must restore the whole texture, including previously previewed offscreen pixels"); + crate::blend_preview::hover(&mut app, photocraft_doc::LayerId(layer), Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + app.run("layer.setProps", json!({"layer":layer,"blend":"Multiply"})).unwrap(); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); + app.run("file.new", json!({"width":1024,"height":768,"background":"white"})).unwrap(); + assert!(ensure_gpu(&mut app, &ctx, 1, visible)); + app.session.set_active(0); + app.sync_views(); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_visible_pixels(&mut app, bounds); +} From 78d06781a03d0bfd12735c45b80a897f64b05a28 Mon Sep 17 00:00:00 2001 From: Juan Antonio Redondo Date: Wed, 14 Oct 2026 12:12:54 +0200 Subject: [PATCH 007/304] perf(retouch): row-parallel Blur/Sharpen dabs, 2.6-3.3x faster strokes (#5426) The Blur and Sharpen tools ran each dab's separable Gaussian (local_blur) and the mix back onto the working copy on one thread. A large brush is a lot of work per dab: a 2500 px dab is about 10^9 multiply-adds, so strokes on big images stalled the app for seconds. Both passes of local_blur and the per-dab mix now run their rows in parallel with rayon, as blur.rs already does. Every row does exactly the same arithmetic in the same order, so the output is bit-identical: the layer pixels after the strokes below hash the same as on main. paint.blur, 200-point stroke on a 6000x4000 noise layer, release, 10-core M-series: brush commit (main -> this) live stroke (main -> this) 300 px 323 ms -> 124 ms 497 ms -> 314 ms 1000 px 1.11 s -> 312 ms 1.70 s -> 996 ms 2500 px 2.77 s -> 843 ms 4.26 s -> 2.44 s The live stroke keeps a larger serial share (region copies and mix_back per push); that is left for a follow-up. Refs #5426 Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/retouch.rs | 65 ++++++++++++++++--------------- crates/engine/src/retouch_cmds.rs | 14 +++++-- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index f7c1c18557..e7d33453d4 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -7,6 +7,7 @@ //! "Protect Tones" works on luminance (scaling RGB so hue and saturation are kept, with gamut //! compression instead of per-channel clipping). +use rayon::prelude::*; use serde::{Deserialize, Serialize}; /// Tonal range targeted by Dodge/Burn. @@ -124,56 +125,56 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect(); let ks: f32 = k.iter().sum(); let k: Vec = k.into_iter().map(|v| v / ks).collect(); - // Horizontal pass over the window rows extended vertically by r. + // Horizontal pass over the window rows extended vertically by r. Rows are independent, so + // both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426). let th = oh + 2 * r as usize; let stride = ow * ch; let mut tmp = vec![0.0f32; stride * th]; - let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch]; - for ty in 0..th { - let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize; - // Clamp and premultiply each source pixel once, rather than once for every kernel tap. - for (rx, px) in row.chunks_exact_mut(ch).enumerate() { - let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize; - let i = (y * w + x) * ch; - px.copy_from_slice(&data[i..i + ch]); - if let Some(a) = alpha { - let av = px[a]; - for (c, v) in px.iter_mut().enumerate() { - if c != a { - *v *= av; + tmp.par_chunks_mut(stride).enumerate().for_each_init( + || vec![0.0f32; (ow + 2 * r as usize) * ch], + |row, (ty, dst)| { + let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize; + // Clamp and premultiply each source pixel once, rather than once for every kernel tap. + for (rx, px) in row.chunks_exact_mut(ch).enumerate() { + let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize; + let i = (y * w + x) * ch; + px.copy_from_slice(&data[i..i + ch]); + if let Some(a) = alpha { + let av = px[a]; + for (c, v) in px.iter_mut().enumerate() { + if c != a { + *v *= av; + } } } } - } - let dst = &mut tmp[ty * stride..(ty + 1) * stride]; - // Contiguous accumulation can be vectorized for any channel layout. Each output still - // sums the same products in the same kernel order as the scalar convolution. - for (i, kv) in k.iter().enumerate() { - for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) { - *d += s * kv; + // Contiguous accumulation can be vectorized for any channel layout. Each output still + // sums the same products in the same kernel order as the scalar convolution. + for (i, kv) in k.iter().enumerate() { + for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) { + *d += s * kv; + } } - } - } + }, + ); let mut out = vec![0.0f32; ow * oh * ch]; - for oy in 0..oh { - let dst = &mut out[oy * stride..(oy + 1) * stride]; + out.par_chunks_mut(stride).enumerate().for_each(|(oy, dst)| { for (i, kv) in k.iter().enumerate() { for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) { *d += s * kv; } } - for ox in 0..ow { - let o = (oy * ow + ox) * ch; - if let Some(a) = alpha { - let av = out[o + a]; - for c in 0..ch { + if let Some(a) = alpha { + for (ox, px) in dst.chunks_exact_mut(ch).enumerate() { + let av = px[a]; + for (c, v) in px.iter_mut().enumerate() { if c != a { - out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] }; + *v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] }; } } } } - } + }); out } diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9ea7fd5975..f41dff7e8c 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -18,6 +18,7 @@ use photocraft_geom::Rect; use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke, stroke_coverage}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use rayon::prelude::*; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -977,19 +978,26 @@ impl DabTool { let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize); let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) }; let rw = r.width() as usize; - for y in r.y0..r.y1 { + // Row-parallel, like the blur itself (#5426). + let (wx0, wy0) = (work.rect.x0, work.rect.y0); + work.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| { + let y = wy0 + row as i32; + if y < r.y0 || y >= r.y1 { + return; + } for x in r.x0..r.x1 { let k = per_dab(fp.at(x, y) * strength, sp); if k <= 0.0 { continue; } let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n; - let px = work.px_mut(x, y); + let i = (x - wx0) as usize * n; + let px = &mut line[i..i + n]; for c in 0..n { px[c] += (out[o + c] - px[c]) * k; } } - } + }); }) } Self::Smudge { strength, finger, max_size } => { From ff92cb3ffde57df60d06bb67a0192ff8f11ed7bd Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:57:13 +0800 Subject: [PATCH 008/304] Preserve editable clipboard contents when native reads fail --- apps/photocraft/src/services.rs | 83 +++++++++++++++--- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/lib.rs | 150 +++++++++++++++++++++++++++----- crates/ui-egui/src/menus.rs | 2 +- 4 files changed, 204 insertions(+), 33 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index e6c882d198..8b4c7c045e 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -475,23 +475,35 @@ pub fn native(automation: Option) -> clipboard_get_image: Some({ let clip = clip.clone(); Box::new(move || { - let mut slot = clip.try_borrow_mut().ok()?; + let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; let cb = match slot.as_mut() { Some(c) => c, - None => slot.insert(arboard::Clipboard::new().ok()?), + None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?), }; // Copied files first: Finder also puts the file's icon on the clipboard as an // image, which would otherwise paste instead of the file. - if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) { - return Some(img); + match cb.get().file_list() { + Ok(paths) => { + if let Some(img) = image_from_files(&paths) { + return Ok(Some(img)); + } + } + Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()), + Err(_) => {} } - if let Ok(img) = cb.get_image() { - return Some((img.width as u32, img.height as u32, img.bytes.into_owned())); + match cb.get_image() { + Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))), + Err(error) => { + #[cfg(target_os = "linux")] + if let Some(image) = wayland_clipboard_image() { + return Ok(Some(image)); + } + match error { + arboard::Error::ContentNotAvailable => Ok(None), + _ => Err(error.to_string()), + } + } } - #[cfg(target_os = "linux")] - return wayland_clipboard_image(); - #[cfg(not(target_os = "linux"))] - None }) }), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), @@ -597,6 +609,57 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// Regression using the real clipboard on an isolated Windows CI runner. + #[cfg(target_os = "windows")] + #[test] + #[ignore = "requires exclusive use of the Windows clipboard"] + fn busy_native_clipboard_preserves_copied_shape() { + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] { + let mut native = native(None); + let services = Services { + clipboard_set_image: native.clipboard_set_image.take(), + clipboard_get_image: native.clipboard_get_image.take(), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded"); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + + // Another thread holds the OS clipboard open beyond arboard's bounded retry window. + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + // A non-null window distinguishes this owner from arboard's null-window opens. + let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap(); + ready_tx.send(()).unwrap(); + release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + drop(guard); + }); + ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + let contention = arboard::Clipboard::new().unwrap().get_image().err(); + let first = app.run(id, json!({})); + release_tx.send(()).unwrap(); + holder.join().unwrap(); + assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}"); + eprintln!("paste while clipboard occupied: {first:?}"); + assert!(first.is_err(), "report contention rather than a successful no-op"); + assert!( + app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()), + "a temporary OS read failure must not discard the copied editable shape" + ); + app.run(id, json!({})).unwrap(); + let state = app.session.active().unwrap(); + assert!( + matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)), + "retry keeps the shape editable" + ); + } + } + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn open_filters_cover_uppercase_and_mixed_case_extensions() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 209691b875..843e02dd76 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1647,7 +1647,7 @@ mod tests { let image = clipboard.clone(); let mut app = PhotocraftApp::new( photocraft_engine::Session::new(), - crate::Services { clipboard_get_image: Some(Box::new(move || image.lock().ok()?.clone())), ..Default::default() }, + crate::Services { clipboard_get_image: Some(Box::new(move || Ok(image.lock().unwrap().clone()))), ..Default::default() }, ); let ctx = egui::Context::default(); let opened = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"newDocument"})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38118955c3..d75df23aa0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -341,8 +341,9 @@ pub type OpenUrlFn = Box Result<(), String>>; pub type EncodePngFn = Box Result, String>>; /// Put an RGBA8 image (width, height, pixels) on the OS clipboard. pub type ClipboardSetFn = Box Result<(), String>>; -/// Read an RGBA8 image from the OS clipboard. -pub type ClipboardGetFn = Box Option<(u32, u32, Vec)>>; +/// Read an RGBA8 image from the OS clipboard. `Ok(None)` means no image is available; +/// `Err` means the contents could not be read and must not invalidate the internal copy. +pub type ClipboardGetFn = Box Result)>, String>>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). @@ -1007,15 +1008,11 @@ impl PhotocraftApp { if let Some(r) = crate::crop_ui::intercept(self, id) { return r; } - let suppress_events = self.automation_input && self.session.prefs().script_events.enabled; - if suppress_events { - self.session.edit_prefs(|prefs| prefs.script_events.enabled = false); - } let t0 = gpu_canvas::now_ms(); // The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU). if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "file.newFromClipboard") { if !clip_read { - self.import_os_clipboard(); + self.import_os_clipboard()?; } if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { // Enabled on the strength of the OS clipboard, which held no image: a quiet no-op. @@ -1025,6 +1022,10 @@ impl PhotocraftApp { return Ok(serde_json::json!({"pasted": false})); } } + let suppress_events = self.automation_input && self.session.prefs().script_events.enabled; + if suppress_events { + self.session.edit_prefs(|prefs| prefs.script_events.enabled = false); + } // Long commands become background jobs when enabled (`jobs_ui`); the rest run inline. let params = self.with_mask_target(id, params); let params = vector_ui::with_active_path(self, id, params); @@ -2446,7 +2447,9 @@ impl PhotocraftApp { } } } - self.import_os_clipboard(); + if self.import_os_clipboard().is_err() { + return f; + } if let Some(c) = self.session.clipboard.as_ref().filter(|c| !c.bounds.is_empty()) { crate::new_doc_ui::set_clipboard(&mut f, c.bounds.width(), c.bounds.height()); } @@ -2455,22 +2458,32 @@ impl PhotocraftApp { /// If the OS clipboard holds an image that isn't the one we put there, make it the session /// clipboard (so ⌘V pastes screenshots and images copied in other apps, like Photoshop). - /// Returns true when a new external image was imported. Once the OS clipboard no longer holds + /// Returns `Ok(true)` when a new external image was imported. Once the OS clipboard no longer holds /// the image we mirrored or imported (text or a file was copied since), that image is stale: /// it is dropped rather than pasted. - pub(crate) fn import_os_clipboard(&mut self) -> bool { - let Some(get) = self.services.clipboard_get_image.as_mut() else { return false }; - let image = get().filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); + pub(crate) fn import_os_clipboard(&mut self) -> Result { + let Some(get) = self.services.clipboard_get_image.as_mut() else { return Ok(false) }; + // A read failure says nothing about the clipboard contents. Keep our editable copy and + // its signature, and abort this paste rather than treating the failure as an empty image. + let image = match get() { + Ok(image) => image, + Err(error) => { + self.ui.status = error.clone(); + self.ui.status_error = true; + return Err(error); + } + }; + let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { if self.os_clip_sig.take().is_some() { self.session.clipboard = None; self.clip_external = false; } - return false; + return Ok(false); }; let sig = clip_signature(w, h, &bytes); if self.os_clip_sig == Some(sig) && self.session.clipboard.is_some() { - return false; + return Ok(false); } let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes); @@ -2478,7 +2491,7 @@ impl PhotocraftApp { self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None }); self.os_clip_sig = Some(sig); self.clip_external = true; - true + Ok(true) } } @@ -2611,7 +2624,7 @@ mod clipboard_tests { *a.lock().unwrap() = Some((w, h, px.to_vec())); Ok(()) })), - clipboard_get_image: Some(Box::new(move || b.lock().unwrap().clone())), + clipboard_get_image: Some(Box::new(move || Ok(b.lock().unwrap().clone()))), ..Default::default() }; let mut app = PhotocraftApp::new(Session::new(), services); @@ -2622,10 +2635,10 @@ mod clipboard_tests { let (w, h, px) = os.lock().unwrap().clone().unwrap(); assert_eq!((w, h, px.len()), (8, 4, 8 * 4 * 4)); // Our own image comes back unchanged (no re-import, keeps the original position). - assert!(!app.import_os_clipboard()); + assert!(!app.import_os_clipboard().unwrap()); // Text copied elsewhere replaces our image: nothing to paste, not the stale image. let ours = os.lock().unwrap().take(); - assert!(!app.import_os_clipboard()); + assert!(!app.import_os_clipboard().unwrap()); assert!(app.session.clipboard.is_none()); *os.lock().unwrap() = ours; app.run("edit.copy", serde_json::json!({})).unwrap(); @@ -2659,12 +2672,107 @@ mod clipboard_tests { type OsClip = Arc)>>>; + #[test] + #[ignore = "renders clipboard error and retry evidence with a GPU adapter"] + fn clipboard_error_and_retry_visual_evidence() { + use std::sync::atomic::{AtomicBool, Ordering}; + let busy = Arc::new(AtomicBool::new(true)); + let unavailable = busy.clone(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let services = Services { + clipboard_get_image: Some(Box::new(move || { + if unavailable.load(Ordering::SeqCst) { + Err("The native clipboard is not accessible due to being held by another party.".into()) + } else { + Ok(None) + } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [80, 60, 200, 120], "fill": "#3070c0"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.run("file.new", serde_json::json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app + }); + let out = std::path::PathBuf::from(std::env::var_os("PHOTOCRAFT_CLIPBOARD_EVIDENCE").expect("evidence directory")); + std::fs::create_dir_all(&out).unwrap(); + h.run_steps(8); + let ctx = h.ctx.clone(); + assert!(crate::menus::invoke(h.state_mut(), &ctx, "edit.paste", serde_json::json!({})).is_err()); + h.run_steps(2); + assert!(h.state().ui.status_error); + h.render().unwrap().save(out.join("clipboard-busy.png")).unwrap(); + busy.store(false, Ordering::SeqCst); + crate::menus::invoke(h.state_mut(), &ctx, "edit.paste", serde_json::json!({})).unwrap(); + h.run_steps(3); + assert!(!h.state().ui.status_error); + assert!(h.state().ui.status.is_empty(), "retry clears the clipboard error: {}", h.state().ui.status); + // Direct harness mutations need a repaint after the command has cleared the error. + h.ctx.request_repaint(); + h.run_steps(2); + h.render().unwrap().save(out.join("clipboard-retry.png")).unwrap(); + } + + #[test] + fn failed_clipboard_reads_preserve_editable_contents_until_retry() { + use std::sync::atomic::{AtomicBool, Ordering}; + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] { + let os = OsClip::default(); + let busy = Arc::new(AtomicBool::new(false)); + let (write, read, unavailable) = (os.clone(), os.clone(), busy.clone()); + let services = Services { + clipboard_set_image: Some(Box::new(move |w, h, bytes| { + *write.lock().unwrap() = Some((w, h, bytes.to_vec())); + Ok(()) + })), + clipboard_get_image: Some(Box::new(move || { + if unavailable.load(Ordering::SeqCst) { Err("clipboard is busy".into()) } else { Ok(read.lock().unwrap().clone()) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + let signature = app.os_clip_sig; + assert!(signature.is_some()); + let before = app.session.active().unwrap().doc.walk().len(); + busy.store(true, Ordering::SeqCst); + let result = crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({})); + assert_eq!(result.unwrap_err(), "clipboard is busy", "{id}"); + assert!(app.ui.status_error); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active().unwrap().doc.walk().len(), before); + assert_eq!(app.os_clip_sig, signature); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + // The New dialog also reads the clipboard; a failed preset probe must preserve it. + app.new_document_fields(); + assert_eq!(app.os_clip_sig, signature); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + // An automation error must not leave script events disabled by the dispatch guard. + app.automation_input = true; + app.session.edit_prefs(|prefs| prefs.script_events.enabled = true); + assert!(app.run(id, serde_json::json!({})).is_err()); + assert!(app.session.prefs().script_events.enabled); + app.automation_input = false; + busy.store(false, Ordering::SeqCst); + app.run("edit.pasteSpecial.pasteInPlace", serde_json::json!({})).unwrap(); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_))); + assert!(!app.ui.status_error); + } + } + /// An app whose OS clipboard is `os`, counting every read in `reads`. fn app_with_os_clipboard(os: &OsClip, reads: &Arc) -> PhotocraftApp { let (b, n) = (os.clone(), reads.clone()); let get = move || { n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - b.lock().unwrap().clone() + Ok(b.lock().unwrap().clone()) }; let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() }); app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap(); @@ -2700,7 +2808,7 @@ mod clipboard_tests { let (b, n) = (os.clone(), Arc::clone(&reads)); let get = move || { n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - b.lock().unwrap().clone() + Ok(b.lock().unwrap().clone()) }; let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() }); let ctx = egui::Context::default(); @@ -2750,7 +2858,7 @@ mod clipboard_tests { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let get = move || { n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - b.lock().unwrap().clone() + Ok(b.lock().unwrap().clone()) }; let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() }); app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 403994ac10..2d4d312420 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -544,7 +544,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "edit.paste" if params.as_object().is_none_or(|o| o.is_empty()) => { // Photoshop: paste in place when the copied area is visible, else centred in the view; // images from other apps are always centred. - app.import_os_clipboard(); + app.import_os_clipboard()?; app.clip_read_for_paste = true; let external = app.clip_external; let visible = !external From 6f46fcfc4ae5d5dad7c28342a3519abfb79bf8f9 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:17:59 +0700 Subject: [PATCH 009/304] fix(svg): budget rasterised fallback layers across imports --- crates/io/src/svg.rs | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04fc4bee3a..cc858a0694 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,9 @@ use crate::{ImportResult, IoError}; pub const MAX_SIDE: u32 = 16_384; /// A rasterised fallback layer or a smart-object render never exceeds this many pixels. const MAX_RASTER_PIXELS: u64 = 24_000_000; +/// Aggregate decoded raster memory allowed when opening one drawing. Per-element limits +/// alone do not stop thousands of masked groups from exhausting memory. +const MAX_PIXEL_BYTES: u64 = 1 << 30; /// Past this many drawable elements the whole drawing opens as one pixel layer. const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). @@ -212,6 +215,10 @@ pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: us } fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result { + import_parsed_with_budget(name, parsed, max_group_depth, MAX_PIXEL_BYTES) +} + +fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, pixel_budget: u64) -> Result { let Parsed { tree, fontless_text } = parsed; let mut warnings = Vec::new(); if fontless_text { @@ -222,7 +229,7 @@ fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); @@ -311,6 +318,8 @@ struct Conv { noted: Vec<&'static str>, /// Unnamed shapes, groups, images and texts so far, for "Shape 3"-style names. counts: [usize; 4], + /// Remaining budget for rasterised fallback layers, measured in f32 sample bytes. + pixel_budget: u64, } impl Conv { @@ -321,6 +330,20 @@ impl Conv { } } + fn charge_pixels(&mut self, w: usize, h: usize) -> bool { + let bytes = (w as u64).saturating_mul(h as u64).saturating_mul(u64::from(self.fmt.channels()) * 4); + match self.pixel_budget.checked_sub(bytes) { + Some(remaining) => { + self.pixel_budget = remaining; + true + } + None => { + self.note("pixel-budget", "SVG: rasterised elements exceed the import's pixel memory budget; remaining elements were dropped".into()); + false + } + } + } + /// The element's `id`, or a numbered name by kind (0 shape, 1 group, 2 image, 3 text). fn name(&mut self, id: &str, kind: usize) -> String { if !id.is_empty() { @@ -548,7 +571,7 @@ impl Conv { let y0 = ((bb.y() * k).floor() as i32).max(self.canvas.y0); let x1 = ((bb.right() * k).ceil() as i32).min(self.canvas.x1); let y1 = ((bb.bottom() * k).ceil() as i32).min(self.canvas.y1); - if x1 <= x0 || y1 <= y0 { + if x1 <= x0 || y1 <= y0 || !self.charge_pixels((x1 - x0) as usize, (y1 - y0) as usize) { return None; } let Some(mut pixmap) = pixmap((x1 - x0) as u32, (y1 - y0) as u32) else { @@ -768,6 +791,22 @@ mod tests { assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); } + + #[test] + fn rasterized_groups_stop_at_the_aggregate_pixel_budget() { + let mut svg = String::from(""); + svg.push_str(""); + for i in 0..8 { + svg.push_str(&format!("")); + } + svg.push_str(""); + // Enough for one 256×256 RGBA-f32 surface, but not eight of them. + let budget = 256u64 * 256 * 4 * 4; + let result = import_parsed_with_budget("budget", parse(svg.as_bytes()).unwrap(), MAX_GROUP_DEPTH, budget).unwrap(); + assert_eq!(result.document.layers.len(), 1, "later raster layers are omitted"); + assert!(result.warnings.iter().any(|warning| warning.contains("pixel memory")), "{:?}", result.warnings); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; From 92647dd6ffcac9f00857b07afff28e652dead97f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:18:25 +0700 Subject: [PATCH 010/304] fix(svg): cast channel count for pixel budget arithmetic --- crates/io/src/svg.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index cc858a0694..11d80ed92b 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -331,7 +331,7 @@ impl Conv { } fn charge_pixels(&mut self, w: usize, h: usize) -> bool { - let bytes = (w as u64).saturating_mul(h as u64).saturating_mul(u64::from(self.fmt.channels()) * 4); + let bytes = (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.channels() as u64 * 4); match self.pixel_budget.checked_sub(bytes) { Some(remaining) => { self.pixel_budget = remaining; From e29ec76d4cd8913e39fc6a1613b27ede889f782e Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 18:57:40 +0200 Subject: [PATCH 011/304] Smart Filters: deleting the last filter removes its filter mask (#5619) Deleting or dragging away the only smart filter now clears the filter mask, as Clear Smart Filters already did, so the next filter takes the new selection. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/smart_cmds.rs | 6 ++++++ crates/engine/src/smart_cmds/tests.rs | 31 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 295b845df0..ba4c9e8bd5 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1069,6 +1069,9 @@ fn delete_filter(s: &mut Session, p: &Value) -> Result { edit_filters(s, p, "Delete Smart Filter", |sm| { let i = filter_index("layer.smartFilter.delete", p, sm)?; sm.smart_filters.remove(i); + if sm.smart_filters.is_empty() { + sm.filter_mask = None; + } Ok(()) }) } @@ -1138,6 +1141,9 @@ fn transfer_filters(s: &mut Session, p: &Value) -> Result { match index { Some(i) => { src.smart_filters.remove(i); + if src.smart_filters.is_empty() { + src.filter_mask = None; + } } None => { src.smart_filters.clear(); diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 1caaff6ce2..51d8d007f7 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -330,6 +330,37 @@ fn selection_becomes_the_filter_mask() { assert!(!s.is_enabled("layer.smartFilter.deleteFilterMask")); } +/// #5619: deleting (or dragging away) the last smart filter removes the filter mask with it, so +/// the next filter takes the new selection instead of the old one. +#[test] +fn removing_the_last_filter_removes_its_filter_mask() { + let mut s = session(8); + paint(&mut s); + let original = flat(&s); + let a = convert(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + assert!(active_smart(&s).filter_mask.is_some()); + s.execute("layer.smartFilter.delete", json!({})).unwrap(); + assert!(active_smart(&s).filter_mask.is_none(), "the last filter takes its mask along"); + s.execute("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let px = flat(&s); + let at = |v: &Vec<[f32; 4]>, x: i32, y: i32| v[(y * W + x) as usize]; + assert_eq!(at(&px, 6, 20), at(&original, 6, 20), "the old selection is not filtered"); + assert_ne!(at(&px, 30, 20), at(&original, 30, 20), "the new selection is filtered"); + + // Dragging the only filter to another smart object leaves no mask behind either. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s); + let b = convert(&mut s); + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 0})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Smart(sm) = &doc.layer(LayerId(a)).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty() && sm.filter_mask.is_none()); +} + /// Scales `id` to 10 % around the origin and back, returning the composite. fn shrink_and_grow(s: &mut Session, id: u64) -> Vec<[f32; 4]> { s.execute("edit.transform", json!({"layer": id, "matrix": [0.1, 0, 0, 0.1, 0, 0]})).unwrap(); From a0e5e6eb14d417854b155bc2aa571408f43c44d9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 19:49:52 +0200 Subject: [PATCH 012/304] Count tool: deleting an earlier count group keeps the active group (#5653) count.deleteGroup only clamped the active index to the new length, so removing a group before the active one silently made the next group active and later count.add calls landed there. Shift the index down when the removed group precedes it; deleting the active last group still selects the new last group. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/analysis_cmds.rs | 4 +++- crates/engine/src/analysis_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 6ad25951db..7cfd07e48e 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -856,7 +856,9 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { s.edit("Delete Count Group", |d, _| { d.measurement.count_groups.remove(g); let n = d.measurement.count_groups.len(); - d.measurement.active_count_group = d.measurement.active_count_group.min(n.saturating_sub(1)); + // Removing a group before the active one shifts it down a slot. + let a = d.measurement.active_count_group; + d.measurement.active_count_group = (if g < a { a - 1 } else { a }).min(n.saturating_sub(1)); Ok(()) })?; Ok(count_json(doc(s)?.as_ref())) diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index 124f9abf1e..6c1c6b6242 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -291,6 +291,27 @@ fn count_groups_markers_and_undo() { assert!(s.execute("count.add", json!({"x": 1})).is_err()); } +#[test] +fn deleting_an_earlier_count_group_keeps_the_active_group() { + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + for _ in 0..3 { + s.execute("count.newGroup", json!({})).unwrap(); + } + s.execute("count.setGroup", json!({"group": 2})).unwrap(); + let r = s.execute("count.deleteGroup", json!({"group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // A later group leaves the active index alone. + let r = s.execute("count.deleteGroup", json!({"group": 2})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // Deleting the active last group selects the new last group. + let r = s.execute("count.deleteGroup", json!({})).unwrap(); + assert_eq!(r["activeGroup"], 0); + assert_eq!(r["groups"][0]["name"], "Count Group 2"); +} + #[test] fn log_delete_and_csv_export() { let mut s = session(10, 10, 8); From f31fb26fe83f833d013103b382bfb85766d7ca18 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 19:51:15 +0200 Subject: [PATCH 013/304] Video: Interpret Footage and New Video Layer from File reject an invalid frame rate (#5657) Both commands stored the `fps` param unchecked: 0 or a negative rate was kept, and 1e39 became an infinite rate the next .pcraft save refused. They now use timeline_cmds::fps_param (finite, above 0, at most MAX_FPS), as timeline.create and timeline.setProps do; a missing fps keeps the old fallback. Regression test covers 0, -1, 1e39 and "x" for both commands. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/timeline_cmds.rs | 2 +- crates/engine/src/video_cmds.rs | 6 +++--- crates/engine/src/video_cmds/tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 197c8e9ccc..3121c5dab4 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -59,7 +59,7 @@ fn duration_param(cmd: &str, p: &Value) -> Result> { } /// The optional `fps` param, rejected unless it's a finite number in `(0, MAX_FPS]` (#1009). -fn fps_param(cmd: &str, p: &Value) -> Result> { +pub(crate) fn fps_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("fps").filter(|v| !v.is_null()) else { return Ok(None) }; v.as_f64() .filter(|f| f.is_finite() && *f > 0.0 && *f <= f64::from(MAX_FPS)) diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..301f6d12ba 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -297,7 +297,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); let Footage { frames, fps: footage_fps } = load_frames(&path, fmt)?; // An explicit `fps` wins, then the rate the footage implies, then 30. - let fps = p.get("fps").and_then(Value::as_f64).map(|f| f as f32).or(footage_fps).unwrap_or(30.0); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.newVideoLayerFromFile", p)?.or(footage_fps).unwrap_or(30.0); let n = frames.len(); let id = s.edit("New Video Layer from File", |doc, active| { store(doc); @@ -337,10 +337,10 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { } fn interpret_footage(s: &mut Session, p: &Value) -> Result { - let fps = p.get("fps").and_then(Value::as_f64); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.interpretFootage", p)?; edit_video(s, "Interpret Footage", move |v, _| { if let Some(f) = fps { - v.fps = f as f32; + v.fps = f; } Ok(()) }) diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 22d639d49b..8c8673af9c 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -330,6 +330,32 @@ fn a_still_gif_is_one_frame_at_the_default_rate() { assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 10.0); } +/// Interpret Footage and New Video Layer from File reject an fps that isn't a finite number in +/// `(0, MAX_FPS]` and keep the layer as it was (#5657). 1e39 used to become an infinite rate the +/// next `.pcraft` save refused. +#[test] +fn an_invalid_frame_rate_is_an_error_and_changes_nothing() { + let mut s = session(); + let id = LayerId(s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let fps = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps; + assert_eq!(fps(&s), 30.0); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.interpretFootage", json!({"fps": bad})).is_err(), "fps {bad}"); + assert_eq!(fps(&s), 30.0, "fps {bad}"); + } + s.execute("layer.videoLayers.interpretFootage", json!({"fps": 24})).unwrap(); + assert_eq!(fps(&s), 24.0); + + let dir = tmpdir("gifin-badfps"); + let path = format!("{dir}/anim.gif"); + write_gif(&path, 8, 8, &[([255, 0, 0], 10), ([0, 255, 0], 10)]); + let layers = s.active().unwrap().doc.layer_count(); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path, "fps": bad})).is_err(), "fps {bad}"); + assert_eq!(s.active().unwrap().doc.layer_count(), layers, "fps {bad}"); + } +} + #[test] fn video_frames_to_layers_takes_every_gif_frame() { let dir = tmpdir("gifin-ftl"); From afd735ba96fbf40e41b5b51a2343201af4a8a9f0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 19:51:45 +0200 Subject: [PATCH 014/304] Image > Adjustments: locked smart objects refuse new smart filters (#5655) adjust_as_smart_filter recorded image.adjustments.* (and Shadows/Highlights) as a smart filter without checking locks, so a Lock All or Lock Pixels smart object still took the filter. It now refuses with check_pixels_unlocked, the same rule filter.* smart filters use. Regression test covers both locks. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/adjust_cmds.rs | 2 ++ crates/engine/src/adjust_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..b242727345 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -270,6 +270,8 @@ pub(crate) fn adjust_as_smart_filter(s: &mut Session, kind: &str, label: &str, p if !smart { return Ok(false); } + // A pixel or full lock refuses the smart filter, as for `filter.*` on a smart object. + crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; // The recorded params are the command's own, minus the engine's private `__kind` key. let mut params = p.clone(); if let Value::Object(m) = &mut params { diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f614732de..249b88e4bf 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -479,6 +479,26 @@ fn adjustment_smart_filters_take_a_selection_as_their_mask() { assert_eq!(at(&now, 40, 16), at(&original, 40, 16), "outside the mask the composite is unchanged"); } +/// #5655: as for `filter.*` smart filters, a pixel or full lock refuses an adjustment smart +/// filter, leaving the layer and the history as they were. +#[test] +fn locked_smart_objects_refuse_adjustment_smart_filters() { + let adjustments = + [("image.adjustments.invert", json!({})), ("image.adjustments.shadowsHighlights", json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0}))]; + for lock in [json!({"all": true}), json!({"pixels": true})] { + for (cmd, p) in &adjustments { + let mut s = smart_session(); + s.execute("layer.lockLayers", lock.clone()).unwrap(); + let (steps, flat) = (s.active().unwrap().history.past_len(), flatten(&s)); + let r = s.execute(cmd, p.clone()); + assert!(r.is_err(), "{cmd} added a smart filter to a smart object locked with {lock}: {r:?}"); + assert!(smart_filters_of(&s).is_empty(), "{cmd} {lock}: nothing recorded"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd} {lock}: no history step"); + assert_eq!(flatten(&s), flat, "{cmd} {lock}: pixels untouched"); + } + } +} + /// A wrong-typed value errors before anything is recorded (never a panic), and the adjustments /// that can't re-render from a source (Replace Color, Match Color) stay off a smart object. #[test] From b768970d4dcf8d86c4008223f766b903368af62b Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 15:43:48 -0400 Subject: [PATCH 015/304] Brush smoothing: the lagging brush keeps the pressure of where it is (#5713) Smoother::push moved the brush part of the way to the pointer but gave every point the pointer's newest pose (`..p`). On a fast stroke the brush trails far behind, so when the pen lifted, the trailing brush took the lift's near-zero pressure while still short of the end, and the end catch-up drew a straight hairline from there to the pointer: a thick stroke stepping down to a thin tail. The brush's pressure, tilt, rotation (the short way round) and wheel now move with its position, for exponential smoothing and the pulled string. The end catch-up then ramps from the brush's pressure to the pen's last, a taper instead of a step. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/brush_tests.rs | 32 ++++++++++++++++++++++++++++++++ crates/paint/src/dynamics.rs | 25 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index d0d7de4110..7a5e4dec43 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -874,6 +874,38 @@ fn smoothing_without_catch_up_stops_while_the_pointer_pauses() { assert_eq!(out.len(), n, "held still: the brush doesn't move"); } +#[test] +fn a_smoothed_brush_keeps_the_pressure_of_where_it_is_when_the_pen_lifts() { + // #5713: a fast stroke at full pressure, lifted in the last sample. The brush trails far + // behind the pointer; it used to take the lift's pressure there, and the end catch-up drew + // a hairline from the trailing brush to the pointer. + let pts: Vec = + (0..=20).map(|i| StrokePoint { time: f64::from(i) * 4.0, ..StrokePoint::new(f64::from(i) * 10.0, 0.0, if i < 20 { 1.0 } else { 0.05 }) }).collect(); + let out = smooth_points(&Smoothing { amount: 0.8, ..Default::default() }, 1.0, &pts); + let (last, rest) = out.split_last().unwrap(); + assert_eq!((last.x, last.pressure), (200.0, 0.05), "the catch-up still ends at the pen's last sample"); + let lifted = rest.last().unwrap(); + assert!(lifted.x < 190.0, "the brush lags the pointer: {}", lifted.x); + for p in rest { + assert!(p.pressure > 0.5, "the trailing brush at x {} has pressure {}", p.x, p.pressure); + } +} + +#[test] +fn a_pulled_string_brush_takes_the_pose_along_the_string() { + let ps = Smoothing { amount: 0.2, pulled_string: true, catch_up_on_end: false, ..Default::default() }; + let a = StrokePoint { rotation: 350.0, ..StrokePoint::new(0.0, 0.0, 1.0) }; + let b = StrokePoint { rotation: 10.0, tilt_x: 40.0, ..StrokePoint::new(100.0, 0.0, 0.0) }; + let out = smooth_points(&ps, 1.0, &[a, b]); + // The 20 px string leaves the brush 80 % of the way to the pointer. + let p = out.last().unwrap(); + assert!((p.x - 80.0).abs() < 1e-4, "{}", p.x); + assert!((p.pressure - 0.2).abs() < 1e-4, "{}", p.pressure); + assert!((p.tilt_x - 32.0).abs() < 1e-3, "{}", p.tilt_x); + // Rotation the short way round: 350° + 80 % of 20°. + assert!((p.rotation - 6.0).abs() < 1e-3, "{}", p.rotation); +} + #[test] fn pose_overrides_stylus_values() { let b = BrushSettings { diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index ab846f1959..c0cad09460 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -46,6 +46,27 @@ fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint { // Smoothing // --------------------------------------------------------------------------- +/// The smoothed brush moved `f` of the way from `brush` towards the pointer `p`. Its pose +/// (pressure, tilt, rotation, wheel) moves with it, so the lagging brush paints with the pose the +/// pen had along the way, not the pointer's newest one. Taking the pointer's pressure made a fast +/// stroke's lift thin the brush while it still trailed far behind, and the end catch-up a hairline +/// (#5713). Time is the pointer's. +fn pull(brush: &StrokePoint, p: &StrokePoint, f: f64) -> StrokePoint { + let ff = f as f32; + // Barrel rotation takes the shorter way round. + let dr = (p.rotation - brush.rotation + 540.0).rem_euclid(360.0) - 180.0; + StrokePoint { + x: brush.x + (p.x - brush.x) * f, + y: brush.y + (p.y - brush.y) * f, + pressure: brush.pressure + (p.pressure - brush.pressure) * ff, + tilt_x: brush.tilt_x + (p.tilt_x - brush.tilt_x) * ff, + tilt_y: brush.tilt_y + (p.tilt_y - brush.tilt_y) * ff, + rotation: (brush.rotation + dr * ff).rem_euclid(360.0), + wheel: brush.wheel + (p.wheel - brush.wheel) * ff, + time: p.time, + } +} + /// Incremental stroke smoothing: exponential (with optional time-based catch-up) or pulled string. #[derive(Clone, Debug)] pub struct Smoother { @@ -87,7 +108,7 @@ impl Smoother { let d = (dx * dx + dy * dy).sqrt(); if d > len { let k = (d - len) / d; - let np = StrokePoint { x: pos.x + dx * k, y: pos.y + dy * k, ..p }; + let np = pull(&pos, &p, k); self.pos = Some(np); out.push(np); } @@ -113,7 +134,7 @@ impl Smoother { }; let mut cur = pos; for _ in 0..iters { - cur = StrokePoint { x: cur.x + (p.x - cur.x) * step, y: cur.y + (p.y - cur.y) * step, ..p }; + cur = pull(&cur, &p, step); out.push(cur); } self.pos = Some(cur); From c71f951b903cafcc2f3bfbf1801470033aeada2e Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 00:06:16 +0300 Subject: [PATCH 016/304] =?UTF-8?q?Fix=20View=20=E2=80=BA=20Snap=20off=20s?= =?UTF-8?q?till=20snapping=20via=20smart=20guides=20(storytold/photocraft#?= =?UTF-8?q?5782).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smart-guide targets were built even when the master snap switch was off; gate them on snap_on like other targets. Co-authored-by: Cursor --- crates/ui-egui/src/snap_ui.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 56fecd01eb..b0cb95e2fc 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -39,7 +39,7 @@ pub struct ActiveSnap { pub gesture: Gesture, pub start: [f64; 2], pub targets: SnapTargets, - /// Layer alignments only (smart guides), used when View › Snap is off. + /// Layer alignments only (smart guides), when View › Snap and Smart Guides are on. pub smart: SnapTargets, pub disabled: bool, } @@ -228,7 +228,8 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // A moving selection never snaps back to where it started. targets = targets.filtered(|k| k != SnapKind::Selection); } - let smart = if matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; + let smart = + if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); } @@ -571,6 +572,19 @@ mod tests { } } + #[test] + fn view_snap_off_blocks_smart_guide_snapping() { + let mut app = app_with_box(); + app.ui.tool = Tool::Move; + app.ui.view.show.smart_guides = true; + app.ui.extras.snap = false; + app.ui.tool_options.move_auto_select = false; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m); + assert_eq!(mover_bounds(&app).x0, 303, "View › Snap off must not snap via smart guides"); + } + #[test] fn ctrl_or_snap_off_disables_snapping() { let mut app = app_with_box(); From 5f83b53a323db31de9ddc8edfaee106508a52c4b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 23:13:41 +0200 Subject: [PATCH 017/304] Type: keep superscript and subscript (FontBaseline) when a text layer is re-rendered (#5645) CharStyle had no superscript/subscript attribute, so PSD import ignored FontBaseline and the first re-render (entering text editing) drew the run as plain text. Model it as CharStyle::baseline, read and write FontBaseline in EngineData, draw such runs at 58.3% of their size shifted by 33.3% of it (Photoshop defaults), and accept "baseline" in the character params. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/text.rs | 13 ++++++++++++ crates/engine/src/type_cmds.rs | 13 ++++++++++-- crates/engine/src/type_styles_cmds.rs | 3 ++- crates/text/src/layout.rs | 20 +++++++++++++++++- crates/text/src/psd.rs | 30 ++++++++++++++++++++++++++- crates/text/src/tests.rs | 24 +++++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 6f7836fa46..900e487f07 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -32,6 +32,16 @@ pub enum Caps { AllCaps, } +/// Superscript / subscript (Photoshop's `FontBaseline`). The run is drawn smaller and raised or +/// lowered; `size_pt` and `baseline_shift_pt` keep their own values. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FontBaseline { + #[default] + Normal, + Superscript, + Subscript, +} + /// An OpenType feature setting, e.g. `{ tag: "ss01", value: 1 }`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FontFeature { @@ -79,6 +89,8 @@ pub struct CharStyle { /// per character run as `Kerning`). Added to the automatic kerning of the pair. pub kern: f32, pub caps: Caps, + /// Superscript or subscript. + pub baseline: FontBaseline, /// Standard ligatures (`liga`/`clig`). pub ligatures: bool, /// Discretionary ligatures (`dlig`). @@ -117,6 +129,7 @@ impl Default for CharStyle { kerning: Kerning::Metrics, kern: 0.0, caps: Caps::Normal, + baseline: FontBaseline::Normal, ligatures: true, discretionary_ligatures: false, features: Vec::new(), diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..692cd0e02f 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -10,7 +10,8 @@ use std::sync::Arc; use photocraft_color::Color; use photocraft_doc::text::{ - AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, + AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, + TextRun, TextShape, }; use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, TextLayer}; use serde_json::{Value, json}; @@ -336,6 +337,14 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { }; hit(true); } + if let Some(v) = p.get("baseline").and_then(Value::as_str) { + s.baseline = match v { + "superscript" => FontBaseline::Superscript, + "subscript" => FontBaseline::Subscript, + _ => FontBaseline::Normal, + }; + hit(true); + } if let Some(Value::Object(m)) = p.get("features") { for (tag, v) in m { let value = v.as_u64().map(|x| x as u16).or_else(|| v.as_bool().map(u16::from)).unwrap_or(1); @@ -785,7 +794,7 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result Vec { vec![ diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 797dfe7f8a..93b4261a00 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -84,7 +84,7 @@ fn id_param(cmd: &str, p: &Value) -> Result> { /// A character style with every field changed from the default, to see which fields a set of /// Character-panel keys touches. fn perturbed_char() -> CharStyle { - use photocraft_doc::text::{Caps, FontFeature, FontVariation, Kerning}; + use photocraft_doc::text::{Caps, FontBaseline, FontFeature, FontVariation, Kerning}; CharStyle { font_family: "\u{1}".into(), font_style: "\u{1}".into(), @@ -105,6 +105,7 @@ fn perturbed_char() -> CharStyle { kerning: Kerning::Off, kern: -12345.0, caps: Caps::AllCaps, + baseline: FontBaseline::Subscript, ligatures: false, discretionary_ligatures: true, features: vec![FontFeature { tag: "\u{1}".into(), value: 9 }], diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e2bd223b3b..9ee29b9b19 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -22,7 +22,7 @@ use parley::{ PositionedLayoutItem, StyleProperty, }; use photocraft_doc::TextLayer; -use photocraft_doc::text::{Caps, CharStyle, Kerning, Orientation, TextAlign, TextDirection, TextShape}; +use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, TextAlign, TextDirection, TextShape}; use skrifa::raw::types::Tag; use crate::fonts::FontDb; @@ -31,6 +31,23 @@ use crate::fonts::FontDb; /// readable hierarchy for every font instead of silently rendering lowercase text unchanged. const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7; +/// Photoshop's default superscript / subscript size and position (Type preferences, stored in a +/// PSD's text resources as `SuperscriptSize` .583, `SuperscriptPosition` .333 and their +/// `Subscript` twins): the run is drawn at 58.3% of its size and moved by 33.3% of its size. +const SCRIPT_SIZE: f32 = 0.583; +const SCRIPT_POSITION: f32 = 0.333; + +/// The style a run is drawn with: superscript and subscript shrink it and shift its baseline. +fn drawn_style(s: &mut CharStyle) { + let dir = match s.baseline { + FontBaseline::Normal => return, + FontBaseline::Superscript => 1.0, + FontBaseline::Subscript => -1.0, + }; + s.baseline_shift_pt += dir * SCRIPT_POSITION * s.size_pt; + s.size_pt *= SCRIPT_SIZE; +} + /// Photoshop's default tab stops: every half inch, measured from the anchor of point text or the /// left edge of a paragraph box. PSD type carries no custom tab stops, so these are the only ones. pub const DEFAULT_TAB_STOP_PT: f32 = 36.0; @@ -478,6 +495,7 @@ impl Layouter { crate::served::request(&s.font_family); } fonts.select_named_face(&mut s); + drawn_style(&mut s); out.styles.push(s); } let run_starts: Vec = runs diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 0092fcf92e..d83c04de69 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -11,7 +11,9 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp}; +use photocraft_doc::text::{ + AntiAlias, Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp, +}; use photocraft_geom::{Affine, Point}; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor}; @@ -623,6 +625,11 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C Some(2) => Caps::AllCaps, _ => Caps::Normal, }; + s.baseline = match g("FontBaseline").and_then(E::as_i64) { + Some(1) => FontBaseline::Superscript, + Some(2) => FontBaseline::Subscript, + _ => FontBaseline::Normal, + }; if let Some(c) = g("FillColor") { // Positions matter (alpha, then the channels): a non-finite entry voids the colour. let v: Vec = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default(); @@ -858,6 +865,14 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E { Caps::AllCaps => 2, }), ), + ( + "FontBaseline".into(), + E::Int(match s.baseline { + FontBaseline::Normal => 0, + FontBaseline::Superscript => 1, + FontBaseline::Subscript => 2, + }), + ), ("Underline".into(), E::Bool(s.underline)), ("Strikethrough".into(), E::Bool(s.strikethrough)), ("Ligatures".into(), E::Bool(s.ligatures)), @@ -1362,6 +1377,19 @@ mod nonfinite_tests { assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); } + /// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back. + #[test] + fn font_baseline_round_trips() { + use photocraft_doc::text::FontBaseline; + let d = parse(b"<< /FontBaseline 2 >>").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript); + let d = parse(b"<< /FontBaseline 1 >>").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript); + let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() }; + let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0); + assert_eq!(back.baseline, FontBaseline::Subscript); + } + #[test] fn infinite_dpi_does_not_write_a_zero_size() { let layer = diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..f00a8e8862 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -434,6 +434,30 @@ fn styles_change_pixels() { assert!(hrect.width() as f32 > prect.width() as f32 * 1.7); } +/// #5645: superscript and subscript runs are drawn at 58.3% of their size, raised or lowered by +/// 33.3% of it, as Photoshop draws them; they used to be drawn as plain text. +#[test] +fn superscript_and_subscript_shrink_and_shift_the_run() { + use photocraft_doc::text::FontBaseline; + let mut e = TextEngine::new(); + let base = CharStyle { size_pt: 60.0, ..Default::default() }; + let rect = |e: &mut TextEngine, baseline: FontBaseline| { + let (_, r) = + e.render(&TextLayer { transform: Affine::translate(5.0, 100.0), ..styled("H", CharStyle { baseline, ..base.clone() }) }, 72.0, PixelFormat::RGBA8); + r.rect + }; + let plain = rect(&mut e, FontBaseline::Normal); + let sup = rect(&mut e, FontBaseline::Superscript); + let sub = rect(&mut e, FontBaseline::Subscript); + let ratio = |r: &photocraft_geom::Rect| r.height() as f32 / plain.height() as f32; + for r in [&sup, &sub] { + assert!((ratio(r) - 0.583).abs() < 0.06, "plain {plain:?}, scripted {r:?}"); + } + // The cap's foot sits on the shifted baseline: 20 px (33.3% of 60) above or below. + assert!((plain.y1 - sup.y1 - 20).abs() <= 2, "plain {plain:?}, superscript {sup:?}"); + assert!((sub.y1 - plain.y1 - 20).abs() <= 2, "plain {plain:?}, subscript {sub:?}"); +} + /// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one /// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the /// ink must grow only by the dilation (about perimeter x radius), not by edge saturation. From fbaf6795cfdc087b8343e7b1c351e7ddc8c8662d Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Wed, 14 Oct 2026 22:50:34 +0100 Subject: [PATCH 018/304] refactor(engine): drop the second damage pass after single-edit duplicates and pastes Session::edit has reported a new layer's bounds as damage since #3847, so Duplicate Layer, Duplicate Layers and Paste no longer need to keep the document from before the edit and work the damage out again with note_insert. Paste Into still does: it is three edits. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 -- crates/engine/src/edit_cmds.rs | 4 +--- crates/engine/src/layer_multi_cmds.rs | 18 +++++++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 68d00a7da3..924f6e5f8f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -648,7 +648,6 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let nid = s.edit("Duplicate Layer", |doc, active| { let mut dup = layer_copy(doc, id)?; if let Some(name) = &name { @@ -661,7 +660,6 @@ fn build() -> Vec { *active = Some(nid); Ok(nid) })?; - crate::layer_multi_cmds::note_insert(s, &before); Ok(json!({ "layer": nid.0 })) } ), diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..1340c70946 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -390,7 +390,6 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); let surf = if moved.format() == target { moved } else { moved.convert(target) }; - let before = d.doc.clone(); let id = s.edit("Paste", |doc, active| { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; @@ -399,8 +398,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { doc.selection = None; Ok(id) })?; - // Only where the pasted pixels land needs recompositing, not the whole document (#2901). - crate::layer_multi_cmds::note_insert(s, &before); + // `edit` reports only where the pasted pixels land as damage, not the whole document (#2901). Ok(json!({"layer": id.0, "offset": [dx, dy]})) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 9a79a72050..31733f12eb 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -884,9 +884,9 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { } } -/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for -/// commands made of more than one edit, so the canvas recomposites only where the new layers -/// draw instead of the whole document. +/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]), +/// so the canvas recomposites only where its new layers draw instead of the whole document. +/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695). pub(crate) fn note_insert(s: &mut Session, before: &Document) { let Some(st) = s.active_mut() else { return }; st.last_damage = step_damage(before, &st.doc); @@ -1378,7 +1378,6 @@ pub fn delete_selected(s: &mut Session) -> Result { /// this path, #2777). pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let old_active = s.active().and_then(|d| d.active_layer); let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); @@ -1400,7 +1399,6 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul *active = a; Ok((copies, a)) })?; - note_insert(s, &before); let out = copies.iter().map(|l| l.0).collect::>(); reselect(s, copies, active); Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) @@ -1807,8 +1805,8 @@ mod tests { x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) } - /// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer - /// draws; undoing a duplicate used to recomposite the whole document. + /// Duplicate (one layer or several), paste and delete, and their undo and redo, recomposite + /// only where the layers draw; undoing a duplicate used to recomposite the whole document. #[test] fn duplicate_paste_and_delete_report_where_the_layer_draws() { let mut s = session(8); @@ -1831,6 +1829,12 @@ mod tests { s.execute("edit.copy", json!({})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); step(&mut s, "edit.paste", json!({})); + // Duplicating several selected layers reports where the copies draw, too. + let b = rect_layer(&mut s, Rect::new(30, 30, 40, 40)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + step(&mut s, "layer.duplicate", json!({})); + assert_eq!(damage(&s), Some(Rect::new(10, 10, 40, 40)), "both copies, nothing else"); step(&mut s, "layer.delete", json!({"layer": a.0})); } From 2750809257fb315436442d5ee8089b04fb4228c0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 18:56:28 -0400 Subject: [PATCH 019/304] Batch: Format "Same" saves camera raws as PSD instead of failing (#5799) With Format "Same" every output took its input's extension, so a folder of ARW/CR2/NEF/DNG files tried to write raws, which PhotoCraft can't, and every file failed. Raws and other import-only inputs (PDN, Krita, Affinity) are now saved as PSD; other inputs keep their format. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 12 ++++++++++-- crates/engine/src/file_cmds/tests.rs | 21 +++++++++++++++++++++ crates/io/src/raw.rs | 2 +- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..e43723f0ab 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1042,13 +1042,21 @@ pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: /// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| { - let ext = - if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; (path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path)))) }); process_outputs(outputs, save, f) } +/// The extension a batch output takes with Format "Same": the input's own, except for formats +/// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on +/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. +fn same_format(path: &str) -> String { + const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub"]; + let ext = path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase(); + if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext } +} + fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { let mut files = Vec::new(); let mut errors = Vec::new(); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..ece0858c84 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -65,6 +65,27 @@ fn camera_raw_opens_as_16_bit_with_notes() { assert!(open_bytes_as(&mut s, "bad.dng", &bad, None, None).is_err()); } +/// #5799: Batch with Format "Same" over a folder of camera raws saved nothing (every output was +/// a raw PhotoCraft can't write). Raws are saved as PSD; other inputs keep their format. +#[test] +fn batch_same_format_saves_camera_raws_as_psd() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let dir = tmp("batch-same-raw"); + let (w, h) = (24, 16); + std::fs::write(join(&dir, "IMG_0001.dng"), DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + png(&dir, "b.png", 8, 8, "#ff0000"); + let out = join(&dir, "out"); + let mut s = Session::new(); + let r = s.execute("file.automate.batch", json!({"steps": [["image.adjustments.invert", {}]], "input": dir, "output": out, "format": "same"})).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let mut files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); + files.sort(); + assert_eq!(files.len(), 2, "{r}"); + assert!(files[0].ends_with("/IMG_0001.psd") && files[1].ends_with("/b.png"), "{files:?}"); + let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document; + assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32)); +} + #[test] fn revert_reloads_as_one_undoable_step() { let dir = tmp("revert"); diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 008173839b..aba70c21e7 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; -pub use photocraft_raw::{Tone, retone}; +pub use photocraft_raw::{EXTENSIONS, Tone, retone}; use crate::flat::image_to_document; use crate::{ImportResult, IoError}; From 3de03f48bfd22b1b31d5d075a7eb0b4345425acb Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 08:16:22 +0800 Subject: [PATCH 020/304] Keep unexported clipboard copies usable when native reads fail --- crates/ui-egui/src/lib.rs | 183 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d75df23aa0..fe970e318c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -687,8 +687,11 @@ pub struct PhotocraftApp { /// The filter a "Rasterize?" prompt answered for, run after the dialog closes, where the /// egui context is available again (rasterize_prompt). pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>, - /// Signature of the image we last put on the OS clipboard (to tell ours from other apps'). + /// Last mirrored or imported OS image; this can predate the current internal copy. os_clip_sig: Option, + /// The session copy, if any, need not track the OS clipboard. An older OS signature is kept + /// to recognize unchanged system contents when exporting a newer copy failed or was disabled. + clip_unmirrored: bool, /// The session clipboard currently holds an image imported from the OS clipboard: it has no /// original position, so Paste centres it (see `menus` "edit.paste"). pub(crate) clip_external: bool, @@ -856,6 +859,7 @@ impl PhotocraftApp { clone_preview: None, info_sample: None, os_clip_sig: None, + clip_unmirrored: true, clip_external: false, clip_read_for_paste: false, transform_preview: None, @@ -2418,6 +2422,7 @@ fn channel_tint(mode: photocraft_doc::ColorMode, k: usize, byte: u8, in_color: b impl PhotocraftApp { /// Mirror the session clipboard onto the OS clipboard (RGBA8). fn export_os_clipboard(&mut self) { + self.clip_unmirrored = true; if !self.session.prefs().general.export_clipboard { return; } @@ -2431,6 +2436,7 @@ impl PhotocraftApp { let bytes: Vec = px.into_iter().flatten().collect(); if set(b.width(), b.height(), &bytes).is_ok() { self.os_clip_sig = Some(clip_signature(b.width(), b.height(), &bytes)); + self.clip_unmirrored = false; } } @@ -2460,14 +2466,21 @@ impl PhotocraftApp { /// clipboard (so ⌘V pastes screenshots and images copied in other apps, like Photoshop). /// Returns `Ok(true)` when a new external image was imported. Once the OS clipboard no longer holds /// the image we mirrored or imported (text or a file was copied since), that image is stale: - /// it is dropped rather than pasted. + /// it is dropped rather than pasted. Unexported internal copies survive unavailable or empty + /// system clipboards. pub(crate) fn import_os_clipboard(&mut self) -> Result { let Some(get) = self.services.clipboard_get_image.as_mut() else { return Ok(false) }; // A read failure says nothing about the clipboard contents. Keep our editable copy and - // its signature, and abort this paste rather than treating the failure as an empty image. + // its signature rather than treating the failure as an empty image. let image = match get() { Ok(image) => image, Err(error) => { + // An unexported copy remains usable without an OS clipboard (for example, in + // a Wayland sandbox). Mirrored/imported copies still require a successful read + // before pasting, because their system contents may have changed. + if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() { + return Ok(false); + } self.ui.status = error.clone(); self.ui.status_error = true; return Err(error); @@ -2475,9 +2488,10 @@ impl PhotocraftApp { }; let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { - if self.os_clip_sig.take().is_some() { + if self.os_clip_sig.take().is_some() && !self.clip_unmirrored { self.session.clipboard = None; self.clip_external = false; + self.clip_unmirrored = true; } return Ok(false); }; @@ -2491,6 +2505,7 @@ impl PhotocraftApp { self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None }); self.os_clip_sig = Some(sig); self.clip_external = true; + self.clip_unmirrored = false; Ok(true) } } @@ -2678,14 +2693,20 @@ mod clipboard_tests { use std::sync::atomic::{AtomicBool, Ordering}; let busy = Arc::new(AtomicBool::new(true)); let unavailable = busy.clone(); + let os = OsClip::default(); + let (write, read) = (os.clone(), os); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).wgpu().build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let services = Services { + clipboard_set_image: Some(Box::new(move |w, h, bytes| { + *write.lock().unwrap() = Some((w, h, bytes.to_vec())); + Ok(()) + })), clipboard_get_image: Some(Box::new(move || { if unavailable.load(Ordering::SeqCst) { Err("The native clipboard is not accessible due to being held by another party.".into()) } else { - Ok(None) + Ok(read.lock().unwrap().clone()) } })), ..Default::default() @@ -2767,6 +2788,158 @@ mod clipboard_tests { } } + #[test] + fn internal_only_clipboard_remains_usable_when_native_access_fails() { + use std::sync::atomic::{AtomicBool, Ordering}; + for export in [true, false] { + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] { + let os = OsClip::default(); + let unavailable = Arc::new(AtomicBool::new(true)); + let (read, failed) = (os.clone(), unavailable.clone()); + let services = Services { + clipboard_set_image: Some(Box::new(|_, _, _| Err("clipboard unavailable".into()))), + clipboard_get_image: Some(Box::new(move || { + if failed.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(read.lock().unwrap().clone()) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = export); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + assert!(app.os_clip_sig.is_none() && !app.clip_external); + let bounds = app.session.clipboard.as_ref().unwrap().bounds; + assert_eq!(app.new_document_fields()["__preset"], "Clipboard"); + crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().layers.is_some()); + assert!(!app.ui.status_error); + if id == "file.newFromClipboard" { + assert_eq!(app.session.documents().len(), 3); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.size.width, doc.size.height), (bounds.width(), bounds.height())); + } else { + assert_eq!(app.session.documents().len(), 2); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_))); + } + app.run("edit.purge.clipboard", serde_json::json!({})).unwrap(); + assert!(app.session.clipboard.is_none()); + assert!(app.run("edit.paste", serde_json::json!({})).is_err(), "purged content must not be a fallback"); + // The fallback is only for failed reads: a later successful external image wins. + *os.lock().unwrap() = Some((3, 2, [0u8, 0, 255, 255].repeat(6))); + unavailable.store(false, Ordering::SeqCst); + app.run("edit.paste", serde_json::json!({})).unwrap(); + assert!(app.clip_external && app.os_clip_sig.is_some()); + let clip = app.session.clipboard.as_ref().unwrap(); + assert!(clip.layers.is_none()); + assert_eq!((clip.bounds.width(), clip.bounds.height()), (3, 2)); + } + } + } + + #[test] + fn newer_unexported_copies_do_not_track_an_older_os_signature() { + use std::sync::atomic::{AtomicBool, Ordering}; + for imported in [true, false] { + for disable_export in [true, false] { + for copy_id in ["edit.copy", "edit.cut", "edit.copyMerged"] { + let os = OsClip::default(); + let write_fails = Arc::new(AtomicBool::new(false)); + let read_fails = Arc::new(AtomicBool::new(false)); + let (write, read, failed_write, failed_read) = (os.clone(), os.clone(), write_fails.clone(), read_fails.clone()); + let services = Services { + clipboard_set_image: Some(Box::new(move |w, h, bytes| { + if failed_write.load(Ordering::SeqCst) { + return Err("clipboard unavailable".into()); + } + *write.lock().unwrap() = Some((w, h, bytes.to_vec())); + Ok(()) + })), + clipboard_get_image: Some(Box::new(move || { + if failed_read.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(read.lock().unwrap().clone()) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap(); + if imported { + *os.lock().unwrap() = Some((2, 2, [255u8, 0, 0, 255].repeat(4))); + assert!(app.import_os_clipboard().unwrap()); + } else { + app.run("edit.fill", serde_json::json!({"color": "#ff0000"})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + } + let old_signature = app.os_clip_sig; + assert!(old_signature.is_some() && !app.clip_unmirrored); + app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap(); + app.run("edit.fill", serde_json::json!({"color": "#0000ff"})).unwrap(); + write_fails.store(true, Ordering::SeqCst); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = !disable_export); + app.run(copy_id, serde_json::json!({})).unwrap(); + let copied = app.session.clipboard.clone().unwrap(); + assert!(app.clip_unmirrored && !app.clip_external); + assert_eq!(app.os_clip_sig, old_signature); + assert_eq!(copied.surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + // An old successful export/import must not block the newer internal copy. + read_fails.store(true, Ordering::SeqCst); + app.run("edit.pasteSpecial.pasteInPlace", serde_json::json!({})).unwrap(); + assert_eq!(app.os_clip_sig, old_signature); + assert!(app.clip_unmirrored && !app.clip_external); + read_fails.store(false, Ordering::SeqCst); + assert!(!app.import_os_clipboard().unwrap(), "unchanged old OS image must not overwrite the newer copy"); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), copied.surface.rgba(0, 0)); + *os.lock().unwrap() = None; + assert!(!app.import_os_clipboard().unwrap(), "empty OS clipboard must not discard an unexported copy"); + assert!(app.os_clip_sig.is_none() && app.clip_unmirrored); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), copied.surface.rgba(0, 0)); + // A different external image supersedes the internal copy and disables fallback. + *os.lock().unwrap() = Some((3, 2, [0u8, 255, 0, 255].repeat(6))); + assert!(app.import_os_clipboard().unwrap()); + assert!(app.clip_external && !app.clip_unmirrored); + let clip = app.session.clipboard.as_ref().unwrap(); + assert_eq!((clip.bounds.width(), clip.bounds.height()), (3, 2)); + read_fails.store(true, Ordering::SeqCst); + assert!(app.run("edit.paste", serde_json::json!({})).is_err()); + } + } + } + } + + #[test] + fn failed_clipboard_reads_do_not_paste_an_external_or_missing_copy() { + use std::sync::atomic::{AtomicBool, Ordering}; + for external in [true, false] { + let unavailable = Arc::new(AtomicBool::new(false)); + let failed = unavailable.clone(); + let services = Services { + clipboard_get_image: Some(Box::new(move || { + if failed.load(Ordering::SeqCst) { Err("clipboard unavailable".into()) } else { Ok(Some((3, 2, [255u8, 0, 0, 255].repeat(6)))) } + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", serde_json::json!({"width": 80, "height": 60})).unwrap(); + if external { + assert!(app.import_os_clipboard().unwrap()); + } + let signature = app.os_clip_sig; + let before = app.session.active().unwrap().doc.walk().len(); + unavailable.store(true, Ordering::SeqCst); + for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace", "file.newFromClipboard"] { + let result = crate::menus::invoke(&mut app, &egui::Context::default(), id, serde_json::json!({})); + assert_eq!(result.unwrap_err(), "clipboard unavailable", "{id}"); + assert!(app.ui.status_error); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().unwrap().doc.walk().len(), before); + assert_eq!(app.os_clip_sig, signature); + assert_eq!(app.session.clipboard.is_some(), external); + assert_eq!(app.clip_external, external); + } + } + } + /// An app whose OS clipboard is `os`, counting every read in `reads`. fn app_with_os_clipboard(os: &OsClip, reads: &Arc) -> PhotocraftApp { let (b, n) = (os.clone(), reads.clone()); From cc5971ff5b9b7bc764bce9b6a8920fba69d7e87e Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 10:07:54 +0800 Subject: [PATCH 021/304] ci: run clipboard regressions independently --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..d7a7d65b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,40 @@ jobs: if: runner.os == 'Linux' run: cargo xtask scorecard --check + # Keep clipboard regressions runnable when an unrelated workspace test fails. + clipboard: + name: clipboard regressions (${{ matrix.os }}) + if: github.event_name == 'pull_request' + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Install Linux system dependencies (egui/winit/rfd) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: test + save-if: false + - name: Clipboard state and regression tests + run: cargo test -p photocraft-ui-egui --locked --lib 'clipboard_tests::' + - name: Native Windows clipboard contention + if: runner.os == 'Windows' + run: cargo test -p photocraft --locked --bin photocraft services::tests::busy_native_clipboard_preserves_copied_shape -- --ignored --exact --test-threads=1 + corpus: # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache From f8cc7fca50f6a378a154e6cebb4b449c25268650 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 20:07:49 -0700 Subject: [PATCH 022/304] Brushes: importing an .abr file numbers repeated brush names in linear time brush.presets.importAbr named each imported preset with unique, which compared a candidate name with the names of the presets one by one and tried "Name", "Name 2", "Name 3" and so on until one was free. A version 1 or 2 brush with a computed tip and no name is named after its tip ("9 px Round"), so a file of n such brushes of one diameter cost about n^3/6 string comparisons. In a test build, importing 10,000 of them took 25 minutes (22 minutes of CPU time). The import now builds the set of preset names once and keeps the next number to try for each repeated name, as swatches.import does for swatch and group names. Names still compare case-insensitively, and every preset gets the same name as before. brush.presets.importImage names its one preset with the same code. repeated_brush_names_are_numbered_in_linear_time imports 10,000 such brushes on a thread of its own and fails when the import takes more than 20 seconds. repeated_brush_names_take_the_first_free_number checks the names when the file repeats a name in other letter cases, when a preset already holds one of the numbers, when the file has a name that the import already gave to an earlier brush, and when a name clashes with a built-in preset. --- crates/engine/src/preset_import_cmds.rs | 90 +++++++++++++++++++++---- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 8da6cee8a0..51ce9bf078 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -49,13 +49,32 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, S Ok((bytes, stem)) } -/// A name not used by any preset in `taken` (case-insensitive): `name`, `name 2`, `name 3`… -fn unique(name: &str, taken: &[String]) -> String { - let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); - if free(name) { - return name.to_string(); +/// The preset names in use, for naming new presets. +struct UniqueNames { + /// Every name in use, ASCII-lowercased. + taken: std::collections::HashSet, + /// The next number to try for each repeated name (lowercased). + next: std::collections::HashMap, +} + +impl UniqueNames { + fn new<'a>(names: impl IntoIterator) -> Self { + UniqueNames { taken: names.into_iter().map(str::to_ascii_lowercase).collect(), next: std::collections::HashMap::new() } + } + + /// A name not in use (case-insensitive): `name`, `name 2`, `name 3`… It is in use from then on. + fn unique(&mut self, name: &str) -> String { + let key = name.to_ascii_lowercase(); + if self.taken.insert(key.clone()) { + return name.to_string(); + } + // Resume numbering per name, so a file of identical names stays linear. + let from = self.next.get(&key).copied().unwrap_or(2); + let i = (from..).find(|i| !self.taken.contains(&format!("{key} {i}"))).unwrap_or(from); + self.taken.insert(format!("{key} {i}")); + self.next.insert(key, i + 1); + format!("{name} {i}") } - (2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string()) } fn import_abr(s: &mut Session, p: &Value) -> Result { @@ -93,11 +112,10 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io if p.get("replace").and_then(Value::as_bool).unwrap_or(true) { s.tools.presets.retain(|x| x.builtin || x.group != group); } - let mut taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); + let mut taken = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())); let mut names = Vec::with_capacity(imp.presets.len()); for mut preset in imp.presets { - preset.name = unique(&preset.name, &taken); - taken.push(preset.name.clone()); + preset.name = taken.unique(&preset.name); names.push(preset.name.clone()); s.tools.presets.push(preset); } @@ -180,8 +198,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let doc = crate::file_cmds::import(decode_name, &bytes)?; let (v, w, h) = image_paint(&doc, cmd)?; let (brush, tw, th) = crate::brush_cmds::sampled_brush(cmd, &v, w, h)?; - let taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); - let name = unique(&name, &taken); + let name = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())).unique(&name); s.tools.presets.push(photocraft_paint::BrushPreset { name: name.clone(), brush: brush.clone(), @@ -526,10 +543,55 @@ mod tests { assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing"); } + /// A version 1 brush with a computed tip and no name is named after its tip ("9 px Round"). The + /// import used to number each repeated name by searching the preset names once for every number + /// it tried, so importing 10,000 such brushes took minutes. The test fails when the import takes + /// more than 20 seconds. + #[test] + fn repeated_brush_names_are_numbered_in_linear_time() { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "9 PX ROUND 3"})).unwrap(); + let round = LegacyBrush { + name: String::new(), + spacing: 25, + anti_alias: true, + tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 }, + }; + let data = photocraft_paint::tile::b64_encode(&write_v12(1, &vec![round; 10_000], false).unwrap()); + let r = s.execute("brush.presets.importAbr", json!({"data": data})).unwrap(); + let names: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let _ = tx.send(names); + }); + let names = match rx.recv_timeout(std::time::Duration::from_secs(20)) { + Ok(names) => names, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("importing the brushes did not finish"), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the import thread panicked"), + }; + // Numbering starts at 2 and skips the number a preset already uses, in any letter case. + assert_eq!(names[..4], ["9 px Round", "9 px Round 2", "9 px Round 4", "9 px Round 5"]); + assert_eq!(names.last(), Some(&format!("9 px Round {}", names.len() + 1))); + assert_eq!(names.iter().collect::>().len(), names.len()); + } + + #[test] + fn repeated_brush_names_take_the_first_free_number() { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "speckle 3"})).unwrap(); + let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] }; + let brush = |name: &str| LegacyBrush { name: name.into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip.clone()) }; + let file = write_v12(2, &["Speckle", "SPECKLE", "speckle", "Speckle 2", "Speckle", "hard round"].map(brush), true).unwrap(); + let r = s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&file)})).unwrap(); + // Names that differ only in letter case count as the same name. The file's "Speckle 2" comes + // after "SPECKLE" took that name, and "hard round" clashes with the built-in Hard Round. + assert_eq!(r["imported"], json!(["Speckle", "SPECKLE 2", "speckle 4", "Speckle 2 2", "Speckle 5", "hard round 2"])); + } + #[test] fn unique_names() { - let taken = vec!["A".to_string(), "a 2".to_string()]; - assert_eq!(unique("a", &taken), "a 3"); - assert_eq!(unique("B", &taken), "B"); + let mut taken = UniqueNames::new(["A", "a 2"]); + assert_eq!(taken.unique("a"), "a 3"); + assert_eq!(taken.unique("B"), "B"); } } From f49564d281bf8892db58ed154767717223aff4f6 Mon Sep 17 00:00:00 2001 From: Bram Heerink <1698441+bramheerink@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:05:48 +0700 Subject: [PATCH 023/304] PSD: keep guide resources 1092/1097 only while they match the guides (#5966) Photoshop writes image resource 1092 (guides v2: 1032's guides plus a colour per guide) and 1097 (one GUID per guide) next to 1032. Export regenerated 1032 from the document but wrote 1092 and 1097 back verbatim, so after Clear Guides, New Guide, a move or a recolour the file carried two guide lists that disagree. Both are now written back only while 1092 still describes the document's guides (positions, directions and colours) and 1097 has as many entries; otherwise both are left out and the file carries 1032 alone, as files from before guides v2 do. Import takes the guides' own colours from 1092 (Photoshop's default Cyan stays with the preference colour), the read half of #5967. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/guides_map.rs | 157 +++++++++++++++++++++++++++++++++++ crates/io/src/lib.rs | 1 + crates/io/src/psd_export.rs | 4 + crates/io/src/psd_import.rs | 1 + crates/io/tests/guides_v2.rs | 128 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 6 files changed, 292 insertions(+) create mode 100644 crates/io/src/guides_map.rs create mode 100644 crates/io/tests/guides_v2.rs diff --git a/crates/io/src/guides_map.rs b/crates/io/src/guides_map.rs new file mode 100644 index 0000000000..6137011e2c --- /dev/null +++ b/crates/io/src/guides_map.rs @@ -0,0 +1,157 @@ +//! Guides ⇄ PSD beyond image resource 1032. +//! +//! Photoshop writes two undocumented resources next to 1032 (Grid and Guides), layout +//! inferred from the psd-tools, ag-psd and Photoshop-authored corpora (#5965): +//! +//! * 1092, Grid and Guides version 2: `u32` version 2, `u32` horizontal and vertical grid +//! cycle (576 = 18 px in 1/32 px, as in 1032), `u32` count, then per guide 9 bytes: `i32` +//! position in 1/32 px, `u8` direction (0 vertical, 1 horizontal), one `u8` that is always 0 +//! in the corpora, and the guide's colour as `u8` R, G, B. In every corpus file its guides +//! equal 1032's, in the same order. +//! * 1097: `u32` count (1092's), then per guide a length-prefixed ASCII GUID, a `u32` 0 and a +//! second length-prefixed GUID, always all zeros. +//! +//! Import takes the guides' own colours from 1092 (a guide in Photoshop's default colour keeps +//! following the preference colour). Export keeps both resources only while 1092 still +//! describes the document's guides and colours; otherwise it leaves both out, so the file +//! carries 1032 alone, as Photoshop files before version 2 did (#5966). + +use photocraft_doc::{Document, Guides}; + +/// Grid and Guides, version 2 (positions, directions and colours). +pub const GUIDES_V2: u16 = 1092; +/// One GUID per guide, in 1092's order. +pub const GUIDE_IDS: u16 = 1097; +/// Photoshop's default guide colour (Cyan, `#4affff`), which 1092 also stores for guides +/// that use the preference colour. +pub const DEFAULT_GUIDE_RGB: [u8; 3] = [0x4a, 0xff, 0xff]; + +/// One guide of a 1092 resource. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct GuideV2 { + /// Position in 1/32 px. + pub location: i32, + pub vertical: bool, + pub rgb: [u8; 3], +} + +fn be_u32(data: &[u8], at: usize) -> Option { + let b = data.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// The guides of a 1092 resource, or `None` when it is not version 2 or is cut short. +pub fn parse_v2(data: &[u8]) -> Option> { + if be_u32(data, 0)? != 2 { + return None; + } + let count = usize::try_from(be_u32(data, 12)?).ok()?; + // Each guide takes 9 bytes: a count the data can't hold is not a 1092 we understand. + if count > data.len().saturating_sub(16) / 9 { + return None; + } + (0..count) + .map(|i| { + let at = 16 + i * 9; + let g = data.get(at..at + 9)?; + Some(GuideV2 { location: i32::from_be_bytes([g[0], g[1], g[2], g[3]]), vertical: g[4] != 1, rgb: [g[6], g[7], g[8]] }) + }) + .collect() +} + +/// The number of entries a 1097 resource declares. +pub fn ids_count(data: &[u8]) -> Option { + be_u32(data, 0) +} + +/// The document's guides as 1092 would store them, sorted. +fn expected(guides: &Guides) -> Vec { + let mut v: Vec = [(&guides.vertical, true), (&guides.horizontal, false)] + .into_iter() + .flat_map(|(list, vertical)| { + list.iter().map(move |&p| GuideV2 { location: (p * 32.0).round() as i32, vertical, rgb: guides.color(vertical, p).unwrap_or(DEFAULT_GUIDE_RGB) }) + }) + .collect(); + v.sort(); + v +} + +/// Whether an imported 1092 still describes the document's guides: the same positions, +/// directions and colours, in any order. +pub fn describes(doc: &Document, data: &[u8]) -> bool { + let Some(mut stored) = parse_v2(data) else { return false }; + stored.sort(); + stored == expected(&doc.guides) +} + +/// Whether to write back an imported 1092 or 1097: only while 1092 describes the guides +/// (and, for 1097, holds as many entries as 1092). +pub fn keep_resource(doc: &Document, id: u16, data: &[u8]) -> bool { + match id { + GUIDES_V2 => describes(doc, data), + GUIDE_IDS => { + let v2 = doc.metadata.psd_resources.iter().find(|(rid, _, _)| *rid == GUIDES_V2); + v2.is_some_and(|(_, _, d)| describes(doc, d) && parse_v2(d).map(|g| g.len()).and_then(|n| u32::try_from(n).ok()) == ids_count(data)) + } + _ => true, + } +} + +/// Gives the document's guides the colours an imported 1092 stores. Only guides that 1032 +/// also has are coloured, and Photoshop's default colour is left to the preference. +pub fn apply_colors(doc: &mut Document) { + let Some(stored) = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == GUIDES_V2).and_then(|(_, _, d)| parse_v2(d)) else { + return; + }; + for g in stored { + if g.rgb == DEFAULT_GUIDE_RGB { + continue; + } + let pos = f64::from(g.location) / 32.0; + let list = if g.vertical { &doc.guides.vertical } else { &doc.guides.horizontal }; + if list.contains(&pos) { + doc.guides.set_color(g.vertical, pos, Some(g.rgb)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v2(guides: &[(i32, u8, [u8; 3])]) -> Vec { + let mut v = Vec::new(); + for n in [2u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir, rgb) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.extend_from_slice(&[*dir, 0]); + v.extend_from_slice(rgb); + } + v + } + + #[test] + fn parses_corpus_layout() { + // clipping-mask4.psd (psd-tools): one vertical and one horizontal guide at 16 px. + let d = v2(&[(512, 0, DEFAULT_GUIDE_RGB), (512, 1, DEFAULT_GUIDE_RGB)]); + let g = parse_v2(&d).unwrap(); + assert_eq!(g.len(), 2); + assert!(g[0].vertical && !g[1].vertical); + assert_eq!(g[1].location, 512); + } + + #[test] + fn rejects_other_versions_and_short_or_hostile_data() { + let mut d = v2(&[(512, 0, DEFAULT_GUIDE_RGB)]); + assert!(parse_v2(&d[..d.len() - 1]).is_none()); + d[3] = 1; + assert!(parse_v2(&d).is_none()); + let mut huge = v2(&[]); + huge[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(parse_v2(&huge).is_none()); + assert!(parse_v2(&[]).is_none()); + assert!(ids_count(&[0, 0]).is_none()); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 378122986e..646a824364 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -44,6 +44,7 @@ pub mod effects_map; pub mod exr_parts; mod flat; mod gradient_bake; +pub mod guides_map; pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod kra; pub mod linked; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8ba30d83c4..8d99a9d895 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1253,6 +1253,10 @@ pub(crate) fn keep_imported_resource(doc: &Document, id: u16, data: &[u8]) -> bo if !crate::annotations_map::keep_resource(doc, id) { return false; } + // Guides v2 and guide GUIDs: only while they still describe the document's guides. + if !crate::guides_map::keep_resource(doc, id, data) { + return false; + } // A preserved clipping-path resource is only valid while it names the current one. if id == crate::vector_map::CLIPPING_PATH { let n = usize::from(data.first().copied().unwrap_or(0)); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index c20c1b7fe7..ae4e15d9b4 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -830,6 +830,7 @@ pub(crate) fn psd_to_document_decoded( id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } } + crate::guides_map::apply_colors(&mut doc); for b in &file.global_blocks { doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone()))); } diff --git a/crates/io/tests/guides_v2.rs b/crates/io/tests/guides_v2.rs new file mode 100644 index 0000000000..73884f00f4 --- /dev/null +++ b/crates/io/tests/guides_v2.rs @@ -0,0 +1,128 @@ +//! Image resources 1092 (guides with colours) and 1097 (guide GUIDs) next to 1032 (#5966). + +use photocraft_io::guides_map::{DEFAULT_GUIDE_RGB, GUIDE_IDS, GUIDES_V2}; +use photocraft_io::*; +use photocraft_psd::testgen; +use photocraft_psd::{Compression, ImageResource, PsdFile, Version}; + +const RED: [u8; 3] = [0xff, 0x00, 0x00]; + +type Edit = fn(&mut photocraft_doc::Document); + +fn guides_v1(guides: &[(i32, u8)]) -> Vec { + let mut v = Vec::new(); + for n in [1u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.push(*dir); + } + v +} + +fn guides_v2(guides: &[(i32, u8, [u8; 3])]) -> Vec { + let mut v = Vec::new(); + for n in [2u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir, rgb) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.extend_from_slice(&[*dir, 0]); + v.extend_from_slice(rgb); + } + v +} + +fn guide_ids(n: u32) -> Vec { + let mut v = n.to_be_bytes().to_vec(); + for i in 0..n { + let id = format!("0000000{i}-1111-2222-3333-444444444444"); + let zero = "00000000-0000-0000-0000-000000000000"; + v.extend_from_slice(&36u32.to_be_bytes()); + v.extend_from_slice(id.as_bytes()); + v.extend_from_slice(&0u32.to_be_bytes()); + v.extend_from_slice(&36u32.to_be_bytes()); + v.extend_from_slice(zero.as_bytes()); + } + v +} + +/// A file as Photoshop writes it: a vertical guide at 16 px in the default colour and a red +/// horizontal one at 10 px, in 1032, 1092 and 1097. +fn photoshop_file() -> PsdFile { + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.resources.push(ImageResource::new(1032, guides_v1(&[(512, 0), (320, 1)]))); + f.resources.push(ImageResource::new(GUIDES_V2, guides_v2(&[(512, 0, DEFAULT_GUIDE_RGB), (320, 1, RED)]))); + f.resources.push(ImageResource::new(GUIDE_IDS, guide_ids(2))); + f +} + +fn resource(f: &PsdFile, id: u16) -> Option<&[u8]> { + f.resources.iter().find(|r| r.id == id).map(|r| r.data.as_slice()) +} + +#[test] +fn colours_come_from_guides_v2() { + let (d, _) = psd_to_document(&photoshop_file()); + assert_eq!(d.guides.vertical, [16.0]); + assert_eq!(d.guides.horizontal, [10.0]); + assert_eq!(d.guides.color(false, 10.0), Some(RED)); + // The default colour stays with the preference. + assert_eq!(d.guides.color(true, 16.0), None); +} + +#[test] +fn unchanged_guides_keep_both_resources_byte_exact() { + let f = photoshop_file(); + let (d, _) = psd_to_document(&f); + let out = document_to_psd(&d); + assert_eq!(resource(&out, GUIDES_V2), resource(&f, GUIDES_V2)); + assert_eq!(resource(&out, GUIDE_IDS), resource(&f, GUIDE_IDS)); +} + +#[test] +fn edited_guides_drop_both_resources() { + let (base, _) = psd_to_document(&photoshop_file()); + let edits: [(&str, Edit); 4] = [ + ("clear", |d| d.guides = Default::default()), + ("add", |d| d.guides.horizontal.push(40.0)), + ("move", |d| { + d.guides.vertical[0] = 20.0; + }), + ("recolour", |d| d.guides.set_color(true, 16.0, Some(RED))), + ]; + for (name, edit) in edits { + let mut d = base.clone(); + edit(&mut d); + let out = document_to_psd(&d); + assert!(resource(&out, GUIDES_V2).is_none(), "{name}: stale 1092 written"); + assert!(resource(&out, GUIDE_IDS).is_none(), "{name}: stale 1097 written"); + let (back, _) = psd_to_document(&out); + assert_eq!(back.guides.vertical, d.guides.vertical, "{name}"); + assert_eq!(back.guides.horizontal, d.guides.horizontal, "{name}"); + } +} + +#[test] +fn mismatched_ids_or_malformed_v2_are_not_kept() { + let mut f = photoshop_file(); + f.resources.retain(|r| r.id != GUIDE_IDS); + f.resources.push(ImageResource::new(GUIDE_IDS, guide_ids(3))); + let (d, _) = psd_to_document(&f); + let out = document_to_psd(&d); + assert!(resource(&out, GUIDES_V2).is_some()); + assert!(resource(&out, GUIDE_IDS).is_none()); + + let mut f = photoshop_file(); + for r in &mut f.resources { + if r.id == GUIDES_V2 { + r.data.truncate(20); + } + } + let (d, _) = psd_to_document(&f); + assert_eq!(d.guides.color(false, 10.0), None); + let out = document_to_psd(&d); + assert!(resource(&out, GUIDES_V2).is_none()); + assert!(resource(&out, GUIDE_IDS).is_none()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b0771c4d62..5c362e922f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 205c9c173721fadceeeb3695794387eceabe8950 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 09:39:25 +0400 Subject: [PATCH 024/304] History Brush: bound the stroke before its dense buffers (#4961) The History Brush renders its coverage and reads its source region densely over the stroke's bounds. Coverage is accumulated in tiles, so a thin stroke holds only the tiles it touches, but dense_coverage() materialises the whole bounding box and Region::read allocates that box again for every channel. Neither is admitted first, and both go through the infallible vec!, which aborts the process instead of returning an error. Pattern Stamp already refuses a stroke it cannot render: reject_huge_stroke bounds it from the points and the brush size before anything is rendered, reject_bounds checks the rendered box, and MAX_STROKE_PIXELS (8192 squared) leaves room for a stroke that runs corner to corner across a 24-36 MP canvas. The three were private to that module. Move them to the shared retouch_cmds module and apply them in the History Brush: reject_huge_stroke before the source surface is cloned or any coverage is rendered, reject_bounds before the dense source read. The budget is unchanged, so every stroke that worked before still works. Clone Stamp, Healing Brush, Spot Healing and Remove reach the same conversion and are left for a follow-up. Test: a stroke whose box dwarfs the canvas is refused with the size error and leaves the pixels as they were, while one that runs corner to corner across the canvas still paints, at 8, 16 and 32 bits. --- crates/engine/src/retouch_cmds.rs | 51 +++++++++++++++++++ .../engine/src/retouch_cmds/pattern_stamp.rs | 46 +---------------- crates/engine/src/retouch_cmds/tests.rs | 21 ++++++++ 3 files changed, 73 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9ea7fd5975..9726172062 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -35,6 +35,52 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a +/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error +/// instead of allocating. +const MAX_STROKE_PIXELS: u64 = 67_108_864; + +/// Refuse a stroke whose footprint would not fit the coverage budget, from the points and brush +/// size alone, before the renderer allocates anything for it. +fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { + let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for q in &stroke.points { + min_x = min_x.min(q.x); + min_y = min_y.min(q.y); + max_x = max_x.max(q.x); + max_y = max_y.max(q.y); + } + if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { + return Err(bad(cmd, "point coordinates must be finite")); + } + let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); + let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); + if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { + return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); + } + let area = (w as u64).saturating_mul(h as u64); + if area > MAX_STROKE_PIXELS { + return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); + } + Ok(()) +} + +fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { + if bounds.is_empty() { + return Ok(()); + } + let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); + let area = w.saturating_mul(h); + if area > MAX_STROKE_PIXELS { + return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); + } + Ok(()) +} + /// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush). fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { if crate::channel_cmds::edits_channel(s) { @@ -631,6 +677,10 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.historyBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; let mode = blend_param(p, CMD)?; + // The coverage and the source region below are both dense over the stroke's bounds, so a + // thin stroke across a large canvas would still ask for the whole box. Refuse it from the + // points and brush size, before the source surface is cloned or anything is rendered (#4961). + reject_huge_stroke(CMD, &stroke)?; let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; @@ -642,6 +692,7 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; let (bounds, cov) = stroke_coverage(&stroke); + reject_bounds(CMD, bounds)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 0dc9f102ac..3c90c6d2a4 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -13,14 +13,9 @@ use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_bounds, reject_huge_stroke}; use crate::{Result, Session}; -/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a -/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error -/// instead of allocating. -const MAX_STROKE_PIXELS: u64 = 67_108_864; - const STREAM_PHASE_X: u64 = 101; const STREAM_PHASE_Y: u64 = 102; @@ -123,45 +118,6 @@ fn stamp_phase(p: &Value, cmd: &str, aligned: bool, first: &StrokePoint) -> Resu } } -fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { - let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - for q in &stroke.points { - min_x = min_x.min(q.x); - min_y = min_y.min(q.y); - max_x = max_x.max(q.x); - max_y = max_y.max(q.y); - } - if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { - return Err(bad(cmd, "point coordinates must be finite")); - } - let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); - let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); - if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { - return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); - } - let area = (w as u64).saturating_mul(h as u64); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - -fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { - if bounds.is_empty() { - return Ok(()); - } - let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); - let area = w.saturating_mul(h); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - fn placement(phase: (f32, f32), scale: f32, angle: f32) -> Placement { Placement::new(Rect::EMPTY, false, phase, scale, angle) } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e20706857a..d72924c430 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -544,6 +544,27 @@ fn history_brush_restores_earlier_pixels() { } } +/// #4961: the History Brush's coverage and its source region are both dense over the stroke's +/// bounds, so a stroke whose box dwarfs the canvas is refused from the points and brush size +/// alone, before the source surface is cloned or any coverage is rendered. The document is left +/// as it was, and ordinary strokes still paint at every depth. +#[test] +fn history_brush_refuses_a_stroke_too_large_to_render() { + for depth in DEPTHS { + let mut s = session(40, 20, depth, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + + let err = s.execute("paint.historyBrush", json!({"points": [[0, 0], [60000, 60000]], "size": 6, "hardness": 100})).unwrap_err(); + assert!(err.to_string().contains("stroke too large"), "depth {depth}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "depth {depth}: a refused stroke leaves the pixels alone"); + + // The guard is sized for real work: a stroke corner to corner across the canvas still runs. + s.execute("paint.historyBrush", json!({"points": [[0, 0], [40, 20]], "size": 6, "hardness": 100})).unwrap(); + assert!(rgba(&s, 20, 10).iter().all(|v| *v > 0.99), "depth {depth}: the Open snapshot is white"); + } +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. From 11e4b407356a9411a057cb9e1ba11cb755efe80c Mon Sep 17 00:00:00 2001 From: Persi Date: Thu, 15 Oct 2026 07:50:38 +0200 Subject: [PATCH 025/304] PSD export: check the Background's opacity from its tiles --- crates/io/src/flat.rs | 2 +- crates/io/src/psd_export.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 54fe96090d..3190c8e08c 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -482,7 +482,7 @@ fn drop_alpha(data: &mut Vec, color: usize, alpha: usize) { } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). -fn opaque_surface(s: &Surface, r: Rect) -> bool { +pub(crate) fn opaque_surface(s: &Surface, r: Rect) -> bool { let fmt = s.format(); let (ch, bps) = (fmt.channels(), fmt.sample.bytes()); let a = ch - 1; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4fb952ba25..93982bfa57 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -102,9 +102,7 @@ fn psd_background(doc: &Document) -> Option { if canvas.is_empty() || s.content_bounds() != canvas { return None; } - let n = s.channels(); - let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); - opaque.then_some(l.id) + crate::flat::opaque_surface(s, canvas).then_some(l.id) } /// How deep embedded documents are exported inside each other before a smart object is written From 181bd3be396ce03a02c8cd77a5c308fe44c5561d Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Thu, 15 Oct 2026 07:14:15 +0100 Subject: [PATCH 026/304] fix(count): reject unknown target groups before adding markers --- crates/engine/src/analysis_cmds.rs | 7 +++- crates/engine/src/analysis_cmds/tests.rs | 44 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 85a6be4513..d4bd57b8c0 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -755,7 +755,12 @@ fn count_add(s: &mut Session, p: &Value) -> Result { if !at[0].is_finite() || !at[1].is_finite() { return Err(bad("count.add", "coordinates must be finite")); } - let gi = p.get("group").and_then(Value::as_u64).map(|v| v as usize); + let gi = p.get("group").and_then(Value::as_u64).map(|v| usize::try_from(v).map_err(|_| bad("count.add", "no such group"))).transpose()?; + // The first marker creates group zero, but an explicit target must never be redirected. + let group_count = doc(s)?.measurement.count_groups.len().max(1); + if gi.is_some_and(|i| i >= group_count) { + return Err(bad("count.add", "no such group")); + } let n = s.edit("Count", |d, _| { if d.measurement.count_groups.is_empty() { let g = new_group(d, None, None); diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index 124f9abf1e..4fdd9d64c3 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -449,3 +449,47 @@ fn count_set_group_rejects_sizes_that_are_not_whole_numbers() { assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing"); assert_eq!(s.active().unwrap().history.past_len(), past); } + +#[test] +fn count_add_rejects_unknown_groups_without_changing_the_document() { + for groups in [0, 1, 3] { + for group in [(groups as u64).max(1), u64::MAX] { + let mut s = session(10, 10, 8); + for _ in 0..groups { + s.execute("count.newGroup", json!({})).unwrap(); + } + let before = d(&s); + let history = s.active().unwrap().history.past_len(); + let journal = s.journal.len(); + let error = s.execute("count.add", json!({"x": 2, "y": 3, "group": group})).unwrap_err(); + assert!(error.to_string().contains("no such group"), "{error}"); + assert!(Arc::ptr_eq(&before, &d(&s)), "an invalid target must not create a group or add a marker"); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(s.journal.len(), journal); + } + } +} + +#[test] +fn count_add_keeps_first_group_creation_and_targets_the_requested_group() { + let mut s = session(10, 10, 8); + // Group zero can be requested before the first marker creates it. + let r = s.execute("count.add", json!({"x": 2, "y": 3, "group": 0})).unwrap(); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0]])); + s.execute("count.newGroup", json!({})).unwrap(); + let before = d(&s).measurement.clone(); + let history = s.active().unwrap().history.past_len(); + let r = s.execute("count.add", json!({"x": 4, "y": 5, "group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0], [4.0, 5.0]])); + assert_eq!(r["groups"][1]["count"], 0); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = d(&s).measurement.clone(); + assert!(s.undo()); + assert_eq!(d(&s).measurement, before); + assert!(s.redo()); + assert_eq!(d(&s).measurement, after); + // Omitting the group still uses the active group. + let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap(); + assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]])); +} From 5b39b2b3af04c84311fe715775acdfa6ef49eaab Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:18:19 +0200 Subject: [PATCH 027/304] =?UTF-8?q?Help=20=E2=80=BA=20Search:=20typed=20le?= =?UTF-8?q?tters=20go=20to=20the=20field,=20not=20to=20menu=20mnemonics=20?= =?UTF-8?q?(#5996)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In an open menu a plain letter runs the row with that mnemonic. Typing into Help › Search did too: "S" ran System Info and "A" opened the ArtCraft website, so only "De" of "Deselect" could be typed. Mnemonics now wait while a text field in the menu has the keyboard. --- crates/ui-egui/src/menu_keys.rs | 6 ++++-- crates/ui-egui/src/menus.rs | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 5762b2a945..7fe6c5e388 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -207,8 +207,10 @@ impl Keys { return; } if open { - // A letter runs the row with that mnemonic in the level the arrows are in. - let letter = events.iter().find_map(|e| match e { + // A letter runs the row with that mnemonic in the level the arrows are in, unless it is + // typed into a field in the menu: Help › Search took "S" for System Info (#5996). + let typing = ctx.text_edit_focused(); + let letter = events.iter().filter(|_| !typing).find_map(|e| match e { Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt => { key_char(*key).map(|c| (*key, c)) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f47e6ea64..4db0d61855 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1956,6 +1956,44 @@ mod tests { } } + /// #5996: a real key press comes as a key and its text. A letter typed into Help › Search is + /// text for the field, not a mnemonic: "S" ran System Info and "A" the ArtCraft website. + #[test] + fn help_search_letters_are_not_mnemonics() { + use egui::{Event, Key, Modifiers, PointerButton}; + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) + }); + h.run_steps(4); + let help = h.get_by_label("Help").rect().center(); + h.event(Event::PointerMoved(help)); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: help, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "Help opened"); + for c in "DeSelect all".chars() { + let key = Key::from_name(&c.to_ascii_uppercase().to_string()); + if let Some(key) = key { + h.event(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + } + h.event(Event::Text(c.to_string())); + h.run_steps(1); + if let Some(key) = key { + h.event(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + } + } + h.run_steps(3); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "typing keeps Help open"); + let query: String = h.ctx.data(|d| d.get_temp(egui::Id::new("help-menu-search"))).unwrap_or_default(); + assert_eq!(query, "DeSelect all", "every letter reached the field"); + assert!(h.state().ui.dialogs.is_empty(), "no menu command ran: {:?}", h.state().ui.dialogs); + } + #[test] fn dropdown_items_have_room() { use egui_kittest::{Harness, kittest::Queryable}; From 494308a038d6c4c31b4c05d444f67fa9ac06978c Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:28:55 +0200 Subject: [PATCH 028/304] Image Size: Indexed Color resamples with Nearest Neighbor only (#6019) An Indexed Color document holds only its colour table's colours, and a save quantises to them. Interpolating methods showed blended colours on screen that the saved file then lost. image.imageSize now resamples an Indexed document with Nearest Neighbor whatever method it is given, and the dialog offers only Automatic and Nearest Neighbor for one. --- crates/engine/src/image_cmds.rs | 27 ++++++++++++++++++++++ crates/ui-egui/src/sizing.rs | 40 ++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..5f37a2eaa8 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -171,6 +171,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); + // An Indexed Color document holds only its colour table's colours, and a save quantises to + // them: interpolation showed blends that the saved file didn't have (#6019). + let resample = match resample { + Method::Filter(_) | Method::Automatic if d.doc.mode == ColorMode::Indexed => Method::Filter(Resample::Nearest), + m => m, + }; let mut pw = p.get("width").and_then(Value::as_f64); let mut ph = p.get("height").and_then(Value::as_f64); // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. @@ -943,6 +949,27 @@ mod tests { /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands /// without a budget. It is refused before any allocation and the document is untouched; a /// change of resolution only, and an ordinary resize, still work. + #[test] + fn image_size_resamples_indexed_color_with_nearest_neighbor() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 0, "width": 1, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + for resample in ["bicubic", "automatic", "bilinear", "lanczos", "preserveDetails"] { + s.execute("image.imageSize", json!({"width": 8, "resample": resample})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layers[0].surface().unwrap(); + for x in 0..8 { + let px = surf.pixel(x, 0); + assert!(px[..3] == [1.0, 0.0, 0.0] || px[..3] == [0.0, 0.0, 1.0], "{resample}: no blended colour at x {x}: {px:?}"); + } + s.execute("history.undo", json!({})).unwrap(); + } + } + #[test] fn image_size_refuses_a_raster_past_the_budget() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 3c48e041f4..c3ec7a199b 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -70,7 +70,7 @@ pub fn reset(f: &mut Map) { pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let unit = pref_unit(app.session.prefs().units_and_rulers.rulers); - let resample = pref_resample(app.session.prefs().general.image_interpolation); + let mut resample = pref_resample(app.session.prefs().general.image_interpolation); let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let d = &app.session.active()?.doc; let (w, h, res) = (d.size.width as f64, d.size.height as f64, d.resolution_dpi as f64); @@ -88,6 +88,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { f.insert("width".into(), json!(w)); f.insert("height".into(), json!(h)); if command == "image.imageSize" { + // Indexed Color resamples with Nearest Neighbor only, as the command does (#6019). + let indexed = d.mode == photocraft_color::ColorMode::Indexed; + if indexed && resample != "automatic" { + resample = "nearest"; + } + f.insert("__indexed".into(), json!(indexed)); f.insert("__constrain".into(), json!(true)); f.insert("resolution".into(), json!(res)); f.insert("resample".into(), json!(resample)); @@ -321,6 +327,10 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { ("bilinear".to_string(), tl!("Bilinear")), ("lanczos".to_string(), tl!("Lanczos (sharp)")), ]; + // Indexed Color: every other method blends colours the colour table doesn't have, + // which the command doesn't use and a save quantises away (#6019). + let indexed = f.get("__indexed").and_then(Value::as_bool) == Some(true); + let opts: Vec<_> = opts.into_iter().filter(|(k, _)| !indexed || k == "automatic" || k == "nearest").collect(); if crate::widgets::dropdown(ui, "is-resample", &mut cur, &opts, 200.0) { f.insert("resample".into(), json!(cur)); } @@ -517,6 +527,34 @@ mod tests { /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). + #[test] + fn indexed_color_resamples_with_nearest_neighbor_only() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.execute("image.mode.indexedColor", json!({})).unwrap(); + for (pref, method) in [("bicubic", "nearest"), ("bicubicAutomatic", "automatic"), ("nearestNeighbor", "nearest")] { + app.session.execute("prefs.set", json!({"path": "general.imageInterpolation", "value": pref})).unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f.get("resample"), Some(&json!(method)), "{pref}"); + assert_eq!(f.get("__indexed"), Some(&json!(true))); + app.ui.close_dialog(id); + } + // The open list offers only Automatic and Nearest Neighbor. + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), "image.imageSize").unwrap(); + h.run_steps(3); + let combo = h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Nearest Neighbor (hard edges)")); + combo.expect("the Resample list shows Nearest Neighbor").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("Automatic").is_some(), "Automatic is offered"); + for gone in ["Bicubic", "Bilinear", "Lanczos", "Preserve Details"] { + assert!(h.query_by_label_contains(gone).is_none(), "{gone} is not offered for Indexed Color"); + } + } + #[test] fn resample_follows_the_interpolation_preference() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From d743c2532da53ac69a85da9fcf01a6663cb7481b Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Wed, 14 Oct 2026 17:07:13 +0800 Subject: [PATCH 029/304] perf(compose): bevel maps shade, chisel and mix rows on threads, results unchanged A chisel bevel size change rebuilds the bevel maps of the whole layer region. The distance fields already run on threads (#2119), but the passes after them still ran on one thread over every pixel: the chisel height from the two distances, the texture term, the highlight/shadow shading (once, or twice for emboss styles) and the emboss inside/outside mix. Each of these computes a pixel from its own inputs only (the height map, the shape, the distances), so they now go row by row through par_units / par_fill like the fields: the same arithmetic in the same order per pixel, so the maps are bit for bit the old ones whatever the thread count. Maps under 65536 pixels and wasm stay on the calling thread. Test: bevel_maps_do_not_depend_on_the_threads (4 shapes x 5 styles x 3 techniques, with soften and a texture, 1 vs 2, 5 and 16 threads, every map bit-equal). --- crates/compose/src/effects.rs | 136 ++++++++++++++++++++++++++-------- 1 file changed, 107 insertions(+), 29 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..ad6b763136 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1511,11 +1511,15 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern } else { let sharp = sharp_edges(shape); let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size)); - let mut h = Map::new(shape.w, shape.h, 0.0); let soft = g.chisel_soft > 0.0; - for i in 0..h.v.len() { - h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); - } + let w = shape.w; + let v = par_fill(w, shape.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * w + x; + *o = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); + } + }); + let mut h = Map { w, h: shape.h, v }; // Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel // Hard's across a straight edge); emboss styles keep the blur until measured. if soft && g.paint == BevelPaint::Both { @@ -1536,14 +1540,23 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0); let k = t.depth / unit; let (x0, y0) = (tex.rect.x0, tex.rect.y0); - for y in 0..h.h { - for x in 0..h.w { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); - let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; - h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l }; - } - } + let w = h.w; + par_units( + h.v.len(), + &mut h.v, + w, + &mut [] as &mut [()], + 0, + || (), + |_, y, row, _| { + for (x, o) in row.iter_mut().enumerate() { + let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); + let p = tile.sample(u, v); + let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; + *o += k * if t.invert { 1.0 - l } else { l }; + } + }, + ); } if b.soften >= 1.0 { tent(&mut h, b.soften); @@ -1625,23 +1638,33 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat }; let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) { let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0)); - for y in 0..shape.h as i64 { - for x in 0..shape.w as i64 { - let i = y as usize * shape.w + x as usize; - let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; - let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; - let n = [-gx, -gy, 1.0]; - let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); - let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; - let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; - let k = shade - se; - if k > 0.0 { - hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; - } else { - sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + // Rows on threads: every pixel reads only `hmap` and `shape`. + par_units( + hi.v.len(), + &mut hi.v, + shape.w, + &mut sh.v, + shape.w, + || (), + |_, y, hi_row, sh_row| { + let y = y as i64; + for x in 0..shape.w as i64 { + let i = y as usize * shape.w + x as usize; + let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; + let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; + let n = [-gx, -gy, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; + let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; + let k = shade - se; + if k > 0.0 { + hi_row[x as usize] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; + } else { + sh_row[x as usize] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + } } - } - } + }, + ); (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour)) }; let maps = match g.paint { @@ -1657,7 +1680,13 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat let (hi, sh) = shade_into(g.depth, 0); let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1); let mix = |a: Map, b: Map| -> Map { - let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect(); + let v = par_fill(a.w, a.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * a.w + x; + let (x, y, s) = (a.v[i], b.v[i], shape.v[i]); + *o = if s > INSIDE_EPS { x } else { 0.0 } + y * (1.0 - s.min(1.0)); + } + }); Map { w: a.w, h: a.h, v } }; vec![mix(hi, ho), mix(sh, so)] @@ -2932,6 +2961,55 @@ mod tests { } } + #[test] + fn bevel_maps_do_not_depend_on_the_threads() { + // Every style and technique, softened or not, and a texture; one thread (in order) is the reference. + let mut patchy = aa_disc(101, 101, 40.0); + for (i, v) in patchy.v.iter_mut().enumerate() { + if (i % 101 / 13 + i / 101 / 17) % 5 == 0 { + *v *= 0.6; + } + } + let shapes = [aa_disc(97, 83, 30.0), aa_round_rect(131, 77, 9.0, 11.0, 120.0, 66.0, 14.0), aa_bar(90, 130, 60.0, 150.0, 25.0), patchy]; + let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]); + sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]); + let pats = [Pattern::new("stripes", sf, 4, 4)]; + let light = GlobalLight::default(); + let styles = [BevelStyle::InnerBevel, BevelStyle::OuterBevel, BevelStyle::Emboss, BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss]; + for (k, shape) in shapes.iter().enumerate() { + let tex = TextureCtx { rect: Rect::new(3, 5, 3 + shape.w as i32, 5 + shape.h as i32), patterns: &pats, anchor: (0.0, 0.0) }; + for style in styles { + for technique in [BevelTechnique::Smooth, BevelTechnique::ChiselHard, BevelTechnique::ChiselSoft] { + for (size, soften, textured) in [(5.0, 0.0, false), (12.0, 2.0, false), (8.0, 0.0, true)] { + let mut b = bevel_of(style, technique); + (b.size, b.soften) = (size, soften); + if textured { + b.texture = Some(photocraft_doc::BevelTexture { + name: "stripes".into(), + id: String::new(), + scale: 1.0, + depth: 1.0, + invert: false, + link: true, + phase: (0.0, 0.0), + }); + } + let run = || bevel_maps(shape, &b, &light, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES)).0; + let one = with_threads(1, run); + for threads in [2, 5, 16] { + let many = with_threads(threads, run); + assert_eq!(one.len(), many.len()); + for (j, (a, m)) in one.iter().zip(&many).enumerate() { + assert_eq!(bits(&a.v), bits(&m.v), "shape {k}, {style:?} {technique:?} size {size} map {j} on {threads} threads"); + } + } + } + } + } + } + } + /// The four fields that use the distance transform, of `m`. fn distance_field_of(m: &Map, reach: f32) -> Vec> { [FieldKind::Inside, FieldKind::Outside, FieldKind::BevelInside, FieldKind::BevelOutside] From 5a93e93a4481aff65d495bf49ac05adcfe0ea4bb Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Wed, 14 Oct 2026 19:13:21 +0800 Subject: [PATCH 030/304] perf(gpu): a chisel bevel's two fields find their sharp edges once On the GPU canvas a bevel size change recomputes BevelInside and BevelOutside on the CPU, one distance_field call each, and each call ran sharp_edges (edge pixels and refined normals) on the same window again; the CPU compositor already shares them (bevel_height). sharp_edges was ~17% of the CPU time of those size changes. When both fields are recomputed over the same rectangle at the same reach (a size change, a new shape), sync_fx now computes them together: compose::effects::bevel_distance_fields finds the sharp edges once and returns both. Same window, same functions, so both fields are bit for bit the ones computed one at a time; the chamfer fields, wasm and a single bevel field keep the old path. Test: paired_bevel_fields_equal_the_fields_one_at_a_time (reach 3, 9, 40; whole region and two parts; bit-equal to field() of each kind). --- crates/compose/src/effects.rs | 8 +++++++ crates/gpu/src/fx.rs | 39 +++++++++++++++++++++++++++++++++++ crates/gpu/src/lib.rs | 31 ++++++++++++++++++---------- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index ad6b763136..d4f65dcb40 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -2319,6 +2319,14 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac } } +/// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its +/// [`distance_field`], with the sharp edges they both start from found once. +pub fn bevel_distance_fields(alpha: Vec, w: usize, h: usize, reach: f32) -> [Vec; 2] { + let s = Map { w, h, v: alpha }; + let sharp = sharp_edges(&s); + [bevel_dist_inside(&s, &sharp, reach), bevel_dist_outside(&s, &sharp, reach)] +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 01e147d51a..4b4ae5d575 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -558,6 +558,21 @@ fn field_banded(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: R parts.concat() } +/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`: one window, +/// whose sharp edges are found once instead of once per field. +pub(crate) fn bevel_fields(reach: i32, shape: &[f32], region: Rect, out: Rect) -> [Vec; 2] { + let [inside, outside] = [FieldKind::BevelInside, FieldKind::BevelOutside]; + if !threaded_inside(inside) { + return [inside, outside].map(|k| field(k, reach, shape, region, out)); + } + let win = out.inflate(field_radius(inside, reach) + 1).intersect(®ion); + if win.is_empty() { + return [Vec::new(), Vec::new()]; + } + let f = photocraft_compose::effects::bevel_distance_fields(crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); + if win == out { f } else { f.map(|f| crop(&f, win, out)) } +} + /// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. pub(crate) fn shape(doc: &photocraft_doc::Document, layer: &Layer, r: Rect) -> Vec { if r.is_empty() { @@ -842,6 +857,30 @@ mod tests { assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing"); } + /// Both bevel fields from one window (sharp edges found once) are bit for bit the fields + /// computed one at a time, over the whole region and over a part of it. + #[test] + fn paired_bevel_fields_equal_the_fields_one_at_a_time() { + let region = Rect::new(-20, -10, 300, 290); + let (w, h) = (region.width() as usize, region.height() as usize); + let mut shape = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 - 150.0, y as f32 - 140.0); + let r = (fx * fx + fy * fy).sqrt() + 8.0 * (fy * 0.07).sin() * (fx * 0.05).cos(); + shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 }; + } + } + let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::>(); + for reach in [3, 9, 40] { + for out in [region, Rect::new(40, 30, 170, 120), Rect::new(-20, 200, 300, 290)] { + let [inside, outside] = bevel_fields(reach, &shape, region, out); + assert_eq!(bits(&inside), bits(&field(FieldKind::BevelInside, reach, &shape, region, out)), "inside, reach {reach}, {out:?}"); + assert_eq!(bits(&outside), bits(&field(FieldKind::BevelOutside, reach, &shape, region, out)), "outside, reach {reach}, {out:?}"); + } + } + } + /// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact /// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU /// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8f8839b9bd..a90258b4b3 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1814,19 +1814,28 @@ impl Compositor { layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect(); let want = field_reaches(&progs); e.fields.retain(|k, _| want.contains_key(k)); - for (&kind, &reach) in &want { + // Each field to recompute: over which rectangle, exact up to which reach. + let jobs: Vec<(FieldKind, Rect, i32)> = want + .iter() + .filter_map(|(&kind, &reach)| match e.fields.get(&kind).map(|(r, _)| *r) { + Some(r) if r >= reach => (!damage.is_empty()).then(|| (kind, damage.inflate(fx::field_radius(kind, r)).intersect(®ion), r)), + _ => Some((kind, region, reach)), + }) + .collect(); + // A chiselled bevel's two fields over the same rectangle share their window's sharp edges. + let job = |k: FieldKind| jobs.iter().find(|j| j.0 == k).map(|j| (j.1, j.2)); + let mut bevel = match (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)) { + (Some((out, reach)), b) if b == Some((out, reach)) => fx::bevel_fields(reach, &e.shape_cpu, region, out).map(Some), + _ => [None, None], + }; + for (kind, out, reach) in jobs { let have = e.fields.get(&kind).map(|(r, _)| *r); - let out = match have { - Some(r) if r >= reach => { - if damage.is_empty() { - continue; - } - damage.inflate(fx::field_radius(kind, r)).intersect(®ion) - } - _ => region, + let pair = match kind { + FieldKind::BevelInside => bevel[0].take(), + FieldKind::BevelOutside => bevel[1].take(), + _ => None, }; - let reach = have.filter(|r| *r >= reach).unwrap_or(reach); - let v = fx::field(kind, reach, &e.shape_cpu, region, out); + let v = pair.unwrap_or_else(|| fx::field(kind, reach, &e.shape_cpu, region, out)); if have.is_none_or(|r| r < reach) { e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); } From 900968367138c6ffb00fb5ca0114bab684dc61b3 Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Wed, 14 Oct 2026 19:27:16 +0800 Subject: [PATCH 031/304] refactor(gpu): bevel fields are asked for, windowed and checked as one pair - the bevel program asks for both fields with B::bevel_fields, so they always come with the same reach; - field and bevel_fields share one windowing (fields_in_windows: one window, or bands on wasm), so wasm pairs them per band instead of taking a separate path; - compose's bevel_height and bevel_distance_fields share bevel_dists; - sync_fx recomputes the pair together and returns Unsupported when the two are not over the same rectangle at the same reach, instead of falling back to one at a time. Test: paired_bevel_fields_equal_the_fields_one_at_a_time also checks the banded path. --- crates/compose/src/effects.rs | 13 +++--- crates/gpu/src/fx.rs | 82 ++++++++++++++++++++++++----------- crates/gpu/src/lib.rs | 54 +++++++++++++---------- 3 files changed, 94 insertions(+), 55 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index d4f65dcb40..650651e963 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1509,8 +1509,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern tent(&mut h, g.width); h } else { - let sharp = sharp_edges(shape); - let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size)); + let [din, dout] = bevel_dists(shape, size); let soft = g.chisel_soft > 0.0; let w = shape.w; let v = par_fill(w, shape.h, 0.0, |y, row| { @@ -2322,9 +2321,13 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac /// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its /// [`distance_field`], with the sharp edges they both start from found once. pub fn bevel_distance_fields(alpha: Vec, w: usize, h: usize, reach: f32) -> [Vec; 2] { - let s = Map { w, h, v: alpha }; - let sharp = sharp_edges(&s); - [bevel_dist_inside(&s, &sharp, reach), bevel_dist_outside(&s, &sharp, reach)] + bevel_dists(&Map { w, h, v: alpha }, reach) +} + +/// The inside and outside bevel distances of `s`, from one set of sharp edges. +fn bevel_dists(s: &Map, reach: f32) -> [Vec; 2] { + let sharp = sharp_edges(s); + [bevel_dist_inside(s, &sharp, reach), bevel_dist_outside(s, &sharp, reach)] } #[cfg(test)] diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 4b4ae5d575..35691c2114 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -203,6 +203,11 @@ impl B { self.fields.push((kind, reach_for(width))); In::Field(kind) } + /// A chiselled bevel's inside and outside fields, always asked for together ([`bevel_fields`] + /// computes them as one). + fn bevel_fields(&mut self, width: f32) -> [In; 2] { + [FieldKind::BevelInside, FieldKind::BevelOutside].map(|k| self.field(k, width)) + } /// `Map::shifted` by whole pixels (`invert`: of `1 - a`); identity shifts of the plain map /// are skipped. fn shift(&mut self, src: In, dx: f32, dy: f32, outside: f32, invert: bool) -> In { @@ -374,8 +379,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, let mut h = if bv.technique == BevelTechnique::Smooth { b.conv(In::Shape, photocraft_compose::effects::tent_kernel(g.width)) } else { - let din = b.field(FieldKind::BevelInside, size); - let dout = b.field(FieldKind::BevelOutside, size); + let [din, dout] = b.bevel_fields(size); let soft = g.chisel_soft > 0.0; let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0)); // As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp. @@ -542,35 +546,51 @@ fn threaded_inside(kind: FieldKind) -> bool { /// fields run on all threads inside `distance_field`, from one window. That is also the window the CPU /// compositor computes them from, so a rebuilt field is exactly the one it gives. fn field_banded(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: Rect, halo: i32) -> Vec { - if threaded_inside(kind) { - let win = out.inflate(halo).intersect(®ion); - if win.is_empty() { - return Vec::new(); - } - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - return if win == out { f } else { crop(&f, win, out) }; - } - let parts = par_map(bands(out, BAND.max(halo)), |band| { - let win = band.inflate(halo).intersect(®ion); - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - crop(&f, win, band) + let [f] = fields_in_windows(threaded_inside(kind), shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] }); - parts.concat() + f } -/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`: one window, -/// whose sharp edges are found once instead of once per field. +/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`, with the +/// sharp edges of each window found once (the two kinds have the same radius and threading). pub(crate) fn bevel_fields(reach: i32, shape: &[f32], region: Rect, out: Rect) -> [Vec; 2] { - let [inside, outside] = [FieldKind::BevelInside, FieldKind::BevelOutside]; - if !threaded_inside(inside) { - return [inside, outside].map(|k| field(k, reach, shape, region, out)); + let kind = FieldKind::BevelInside; + fields_in_windows(threaded_inside(kind), shape, region, out, field_radius(kind, reach) + 1, |s, w, h| { + photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32) + }) +} + +/// The fields `compute` gives for a `w × h` window of the region-sized `shape`, over `out` (inside +/// `region`), each window grown by `halo`: `out` as one window when `whole` (the field spreads over +/// threads itself), else bands of it in parallel. +fn fields_in_windows( + whole: bool, + shape: &[f32], + region: Rect, + out: Rect, + halo: i32, + compute: impl Fn(Vec, usize, usize) -> [Vec; N] + Sync + Send, +) -> [Vec; N] { + let part = |r: Rect| -> [Vec; N] { + let win = r.inflate(halo).intersect(®ion); + if win.is_empty() { + return std::array::from_fn(|_| Vec::new()); + } + let f = compute(crop(shape, region, win), win.width() as usize, win.height() as usize); + if win == r { f } else { f.map(|f| crop(&f, win, r)) } + }; + if whole { + return part(out); } - let win = out.inflate(field_radius(inside, reach) + 1).intersect(®ion); - if win.is_empty() { - return [Vec::new(), Vec::new()]; - } - let f = photocraft_compose::effects::bevel_distance_fields(crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - if win == out { f } else { f.map(|f| crop(&f, win, out)) } + let parts = par_map(bands(out, BAND.max(halo)), part); + std::array::from_fn(|k| { + let mut f = Vec::with_capacity(parts.iter().map(|p| p[k].len()).sum()); + for p in &parts { + f.extend_from_slice(&p[k]); + } + f + }) } /// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. @@ -877,6 +897,16 @@ mod tests { let [inside, outside] = bevel_fields(reach, &shape, region, out); assert_eq!(bits(&inside), bits(&field(FieldKind::BevelInside, reach, &shape, region, out)), "inside, reach {reach}, {out:?}"); assert_eq!(bits(&outside), bits(&field(FieldKind::BevelOutside, reach, &shape, region, out)), "outside, reach {reach}, {out:?}"); + // The banded path (wasm) pairs them per band the same way. + let halo = field_radius(FieldKind::BevelInside, reach) + 1; + let banded = + fields_in_windows(false, &shape, region, out, halo, |s, w, h| photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32)); + for (k, kind) in [FieldKind::BevelInside, FieldKind::BevelOutside].into_iter().enumerate() { + let [one] = fields_in_windows(false, &shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] + }); + assert_eq!(bits(&banded[k]), bits(&one), "banded {kind:?}, reach {reach}, {out:?}"); + } } } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a90258b4b3..87e7963d71 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1073,7 +1073,7 @@ impl Compositor { let aprons: Vec = plan.fx.iter().zip(&paged).map(|(f, &pg)| if pg { Self::fx_apron(doc, f) } else { 0 }).collect(); for (f, &pg) in plan.fx.iter().zip(&paged) { if !pg { - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats)?; } } let fr = self.frame_res(device, queue, canvas, &plan); @@ -1104,7 +1104,7 @@ impl Compositor { continue; } let window = pf.inflate(aprons.get(i).copied().unwrap_or(0)).intersect(&f.region); - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats)?; } let chunks = grid_rects(part, self.chunk); stats.chunks += chunks.len(); @@ -1715,7 +1715,7 @@ impl Compositor { key: FxKey, region: Rect, stats: &mut Stats, - ) { + ) -> Result<(), Unsupported> { let layer = f.layer; let canvas = doc.bounds(); let is_group = matches!(layer.content, LayerContent::Group(_)); @@ -1786,13 +1786,13 @@ impl Compositor { } // Present: kept or (re)inserted above. let stamp = self.stamp; - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; e.last_used = frame; e.stamp = stamp; e.doc = doc.id; e.tiles = [content_src.map(fx::snapshot), mask_src.map(fx::snapshot)]; if region.is_empty() { - return; + return Ok(()); } let t_trace = web_time_now(); @@ -1822,28 +1822,33 @@ impl Compositor { _ => Some((kind, region, reach)), }) .collect(); - // A chiselled bevel's two fields over the same rectangle share their window's sharp edges. + // A chiselled bevel's two fields are asked for and kept together (`fx::bevel_fields`), so they + // are recomputed together, over one rectangle at one reach. let job = |k: FieldKind| jobs.iter().find(|j| j.0 == k).map(|j| (j.1, j.2)); - let mut bevel = match (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)) { - (Some((out, reach)), b) if b == Some((out, reach)) => fx::bevel_fields(reach, &e.shape_cpu, region, out).map(Some), - _ => [None, None], - }; + let (bevel_in, bevel_out) = (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)); + if bevel_in != bevel_out { + return Err(Unsupported(format!("bevel fields of `{}` out of step: inside {bevel_in:?}, outside {bevel_out:?}", layer.name))); + } for (kind, out, reach) in jobs { - let have = e.fields.get(&kind).map(|(r, _)| *r); - let pair = match kind { - FieldKind::BevelInside => bevel[0].take(), - FieldKind::BevelOutside => bevel[1].take(), - _ => None, + let fields = match kind { + FieldKind::BevelInside => { + let [inside, outside] = fx::bevel_fields(reach, &e.shape_cpu, region, out); + vec![(FieldKind::BevelInside, inside), (FieldKind::BevelOutside, outside)] + } + // Computed with BevelInside, over the same rectangle at the same reach (checked above). + FieldKind::BevelOutside => continue, + _ => vec![(kind, fx::field(kind, reach, &e.shape_cpu, region, out))], }; - let v = pair.unwrap_or_else(|| fx::field(kind, reach, &e.shape_cpu, region, out)); - if have.is_none_or(|r| r < reach) { - e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + for (kind, v) in fields { + if e.fields.get(&kind).is_none_or(|(r, _)| *r < reach) { + e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + } + if let Some((_, t)) = e.fields.get(&kind) { + t.write_r32(queue, region, out, &v); + } + staged += v.len() as u64 * 4; + stats.fx_pixels += out.width() as u64 * out.height() as u64; } - if let Some((_, t)) = e.fields.get(&kind) { - t.write_r32(queue, region, out, &v); - } - staged += v.len() as u64 * 4; - stats.fx_pixels += out.width() as u64 * out.height() as u64; } if let Some(t) = t_trace.filter(|_| !damage.is_empty()) { @@ -1858,7 +1863,7 @@ impl Compositor { if prog.maps == 0 { continue; } - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; let same = e.progs[i].key == prog.key && e.progs[i].maps.len() == prog.maps; let d = if same { damage } else { region }; if d.is_empty() { @@ -1873,6 +1878,7 @@ impl Compositor { stats.fx_programs += 1; self.run_program(device, queue, encoder, key, i, prog, d, stats); } + Ok(()) } /// Record the passes of `prog` (maps of item `item` of `layer`) recomputing damage `d`. From 510636b19cea3c0e3bcac860c92dee7808eec7d1 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:34:09 +0200 Subject: [PATCH 032/304] Type: Korean paragraph text wraps between words, not syllables (#5959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unicode's line-breaking rules allow a break between any two Hangul syllables, so Korean box text split words (설 / 정이나). Korean is set with breaks at the spaces between words, as Photoshop does: runs of Hangul now lay out with WordBreak::KeepAll. Chinese and Japanese keep breaking between characters. --- crates/text/src/layout.rs | 22 ++++++++++++++++++++++ crates/text/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e2bd223b3b..a09e323ab5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -629,6 +629,11 @@ impl Layouter { for (range, size) in &synthetic_small_caps { b.push(StyleProperty::FontSize(*size), range.clone()); } + // Korean breaks lines at spaces, between words, as Photoshop sets it; Unicode's + // default rules break between any two Hangul syllables, mid-word (#5959). + for range in hangul_runs(&ptext) { + b.push(StyleProperty::WordBreak(parley::WordBreak::KeepAll), range); + } for (&at, &spacing) in tabs.iter().zip(tab_spacing) { b.push(StyleProperty::LetterSpacing(spacing), at..at + 1); } @@ -1375,6 +1380,23 @@ fn first_ascent(line: &parley::Line<'_, RunBrush>) -> Option { best } +/// Hangul (syllables and jamo). +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7AF}' | '\u{D7B0}'..='\u{D7FF}') +} + +/// The byte ranges of `text` made of Hangul, each as long as it runs. +pub(crate) fn hangul_runs(text: &str) -> Vec> { + let mut runs: Vec> = Vec::new(); + for (i, c) in text.char_indices().filter(|&(_, c)| is_hangul(c)) { + match runs.last_mut() { + Some(r) if r.end == i => r.end = i + c.len_utf8(), + _ => runs.push(i..i + c.len_utf8()), + } + } + runs +} + #[cfg(test)] mod tests { use super::{SmallCapsMode, feature_list, small_caps_mode, variation_list}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..83d195b517 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1452,6 +1452,23 @@ fn japanese_box_text_preserves_wrap_boundaries() { assert_eq!(end, text.text.len()); } +/// #5959: Korean box text broke between Hangul syllables, mid-word ("설 / 정이나"). Lines break at +/// the spaces between words, as Photoshop sets Korean; a word longer than the box still breaks. +#[test] +fn korean_box_text_wraps_between_words() { + let mut engine = TextEngine::new(); + let mut text = point("여기서는 각종 설정이나 캐릭터 제작을 진행합니다.", 12.0); + text.shape = TextShape::Box { x: 0.0, y: 0.0, width: 60.0, height: 1000.0 }; + let layout = engine.layout(&text, 72.0); + assert!(layout.lines.len() > 2, "{}", layout.lines.len()); + let words: Vec<&str> = text.text.split(' ').collect(); + for line in &layout.lines { + let content = text.text.get(line.range.clone()).expect("UTF-8 line boundaries").trim(); + assert!(content.split(' ').all(|w| words.contains(&w)), "a line holds whole words only: {content:?}"); + } + assert_eq!(super::layout::hangul_runs("a 한국어 b"), vec![2..11]); +} + /// Which craft-fonts family drew the glyphs of `l` (matched by the embedded bytes). fn craft_family_of(l: &crate::TextLayout) -> Vec<&'static str> { let mut v = Vec::new(); From 116a60769712c7624e94b90b1553abc139807f16 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:41:16 +0200 Subject: [PATCH 033/304] =?UTF-8?q?Window=20=E2=80=BA=20Arrange:=20tiles?= =?UTF-8?q?=20keep=20their=20documents,=20and=20every=20tile=20has=20ruler?= =?UTF-8?q?s=20(#6017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With documents tiled side by side, a click into the other one moved it to the first tile, swapping the two, and only the active tile showed rulers. Each document now keeps its tile (with more documents than tiles, the shown run slides just enough to include the active one), and inactive tiles and floating windows draw their own document's rulers. Guides and the zero point are still dragged in the active view. --- crates/ui-egui/src/canvas.rs | 14 +++++-- crates/ui-egui/src/rulers.rs | 42 ++++++++++++++++---- crates/ui-egui/src/view_cmds/tests.rs | 57 +++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..6e9a65b072 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2020,9 +2020,11 @@ pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option = (0..n).map(|k| (idx + k) % n).zip(cells).collect(); - shown.sort_by_key(|(d, _)| *d); - Some(shown) + // Each document keeps its tile: a click into another one activates it where it is (#6017; it + // used to move to the first tile). With more documents than tiles, the shown run slides + // only as far as it must to include the active one. + let start = (idx + 1).saturating_sub(cells.len()); + Some((start..n).zip(cells).collect()) } fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -2904,7 +2906,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::monitor_status::check_window(app, &ctx); } let full = rect; - let rect = if primary { crate::rulers::content_rect(app, rect) } else { rect }; + // Every document view has its rulers, the tiles of Window › Arrange and floating windows + // too, as in Photoshop (#6017). + let rect = crate::rulers::content_rect(app, rect); let doc = app.session.documents()[idx].doc.clone(); let size = [doc.size.width, doc.size.height]; if view.doc_size != size { @@ -4087,6 +4091,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } if primary && app.ui.extras.rulers { crate::rulers::draw_rulers(app, ui, full, &xf); + } else if app.ui.extras.rulers { + crate::rulers::paint_rulers(app, ui, full, &xf, &doc); } crate::rotate_view::overlay(app, &ctx, &painter, &xf, &mut view, &response); if primary { diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 61ea25d96f..004b4cb321 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -329,10 +329,14 @@ fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64, zero: [f6 /// (`y_axis` false) or the left one, from Preferences › Units & Rulers and the zoom. Minor ticks /// sit at tenths of the step. fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) { + ruler_scale_of(app, app.session.active().map(|d| &*d.doc), xf, y_axis) +} + +/// [`ruler_scale`] for the view of `doc`. +fn ruler_scale_of(app: &PhotocraftApp, doc: Option<&photocraft_doc::Document>, xf: &ViewXform, y_axis: bool) -> (f64, f64) { let ur = &app.session.prefs().units_and_rulers; let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); - let (dpi, size) = - app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64])); + let (dpi, size) = doc.map_or((72.0, [1.0, 1.0]), |d| (d.resolution_dpi.max(1.0) as f64, [d.size.width as f64, d.size.height as f64])); let px_per_unit = unit.to_px(1.0, dpi, if y_axis { size[1] } else { size[0] }, ppi).max(1e-9); // The top ruler runs along document x, which pixel aspect correction stretches. let zoom = if y_axis { xf.zoom } else { xf.zoom_x() }; @@ -356,9 +360,16 @@ fn ruler_label(v: f64, step: f64, whole: bool) -> String { if step >= 1.0 || whole { format!("{}", v.round() as i64) } else { crate::widgets::fmt_num2(v) } } -/// Rulers along the top and left of `full` (the canvas rect before `content_rect`), with the -/// pointer position marked; dragging out of a ruler creates a guide. -pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &ViewXform) { +/// The rulers of `doc`'s view in `full`: background, ticks and labels from its zero point. +/// Returns the top, left and corner rects, the painter, the zero point and the minor tick sizes. +#[allow(clippy::type_complexity)] +fn paint( + app: &PhotocraftApp, + ui: &egui::Ui, + full: Rect, + xf: &ViewXform, + doc: &photocraft_doc::Document, +) -> (Rect, Rect, Rect, egui::Painter, [f64; 2], [f64; 2]) { let t = Tokens::get(ui.ctx()); let top = Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER)); let left = Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom())); @@ -376,11 +387,11 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let unit = app.session.prefs().units_and_rulers.rulers; let whole = unit == Unit::Pixels; // The zero point (#2955): rulers read 0 there and count the distance from it. - let origin = app.session.active().map_or([0.0, 0.0], |d| d.doc.ruler_origin); + let origin = doc.ruler_origin; let mut ticks = [1.0; 2]; for vertical in [false, true] { let p = p.with_clip_rect(if vertical { left } else { top }); - let (px_per_unit, step) = ruler_scale(app, xf, vertical); + let (px_per_unit, step) = ruler_scale_of(app, Some(doc), xf, vertical); ticks[usize::from(vertical)] = step * px_per_unit / 10.0; let (u0, u1) = if vertical { ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit, origin) @@ -413,6 +424,23 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & v += step; } } + (top, left, corner, p, origin, ticks) +} + +/// Rulers for a document view that isn't the active one (another tile of Window › Arrange, a +/// floating window): shown as the active view's are, but guides and the zero point are dragged +/// in the active view (#6017). +pub fn paint_rulers(app: &PhotocraftApp, ui: &egui::Ui, full: Rect, xf: &ViewXform, doc: &photocraft_doc::Document) { + paint(app, ui, full, xf, doc); +} + +/// Rulers along the top and left of `full` (the canvas rect before `content_rect`), with the +/// pointer position marked; dragging out of a ruler creates a guide. +pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &ViewXform) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let (top, left, corner, p, origin, ticks) = paint(app, ui, full, xf, &doc); + let t = Tokens::get(ui.ctx()); + let unit = app.session.prefs().units_and_rulers.rulers; // Pointer position markers. if let Some(h) = ui.ctx().pointer_hover_pos().filter(|h| full.contains(*h)) { let m = Stroke::new(1.0, t.text); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..ba1b61aa29 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -506,3 +506,60 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { assert_eq!(app.ui.view.screen_mode, "standard"); assert!(app.session.active().unwrap().doc.selection.is_some()); } + +/// #6017: a click into another tile activated that document and moved it to the first tile, so +/// the two side-by-side documents swapped places. Each document keeps its tile; with more +/// documents than tiles, the shown run slides just enough to include the active one. +#[test] +fn arranged_documents_keep_their_tiles() { + let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(900.0, 600.0)); + let (mut app, _) = app_with(3); + app.ui.view.arrange = "tileAllVertically".into(); + for active in 0..3 { + app.session.set_active(active); + let shown = crate::canvas::arranged_cells(&app, rect).unwrap(); + let order: Vec = shown.iter().map(|(d, _)| *d).collect(); + assert_eq!(order, vec![0, 1, 2], "active {active}"); + assert!(shown.windows(2).all(|w| w[0].1.left() < w[1].1.left()), "document 0 on the left"); + } + app.ui.view.arrange = "twoUpVertical".into(); + let docs = |app: &mut PhotocraftApp, active: usize| { + app.session.set_active(active); + crate::canvas::arranged_cells(app, rect).unwrap().iter().map(|(d, _)| *d).collect::>() + }; + assert_eq!(docs(&mut app, 0), vec![0, 1]); + assert_eq!(docs(&mut app, 1), vec![0, 1], "the second tile's document stays on the right"); + assert_eq!(docs(&mut app, 2), vec![1, 2], "the third document slides in next to the second"); +} + +/// #6017: rulers showed in the active tile only; the other tile had none and no room for them. +#[test] +fn every_arranged_tile_has_rulers() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "tileAllVertically".into(); + app.ui.extras.rulers = true; + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let right = h.state().last_canvas_rect.center().x; + // Ruler labels are 9 pt text: some lie in each half of the document area. + let mut labels = [0, 0]; + for s in &h.output().shapes { + if let egui::Shape::Text(t) = &s.shape + && t.galley.job.sections.first().is_some_and(|sec| sec.format.font_id.size == 9.0) + && t.galley.text().chars().all(|c| c.is_ascii_digit()) + { + labels[usize::from(t.pos.x > right)] += 1; + } + } + assert!(labels[0] > 0 && labels[1] > 0, "ruler labels per tile: {labels:?}"); +} From ec2627dbbbfc5a6a6a9348999f31abbaf45a6319 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:57:59 +0700 Subject: [PATCH 034/304] fix(path): validate Make Selection parameters before rasterization --- crates/engine/src/vector_cmds.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..15c14e5a1c 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1091,9 +1091,25 @@ fn path_rename(s: &mut Session, p: &Value) -> Result { fn path_to_selection(s: &mut Session, p: &Value) -> Result { let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; - let feather = f64p(p, "feather").unwrap_or(0.0) as f32; - let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); - let mode = SelectionMode::parse(p.get("mode").and_then(Value::as_str).unwrap_or("replace")); + // Validate before rasterizing or touching selection history. An unchecked feather value + // could silently become zero (negative/string), or expand into an excessive blur. + let feather = match p.get("feather") { + None => 0.0, + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .ok_or_else(|| bad("path.toSelection", "`feather` must be a finite number within 0..1000"))? as f32, + }; + let aa = match p.get("antiAlias") { + None => true, + Some(Value::Bool(v)) => *v, + _ => return Err(bad("path.toSelection", "`antiAlias` must be a boolean")), + }; + let mode = match p.get("mode") { + None => SelectionMode::Replace, + Some(Value::String(v)) if matches!(v.as_str(), "replace" | "add" | "subtract" | "intersect") => SelectionMode::parse(v), + _ => return Err(bad("path.toSelection", "`mode` must be replace, add, subtract or intersect")), + }; let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); let mut mask = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE).render(area); if !aa { From caceb01dda29c38e625bc6788751a808384a27e0 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:59:18 +0700 Subject: [PATCH 035/304] test(path): cover invalid Make Selection params and open-path modes --- crates/engine/src/vector_cmds/tests.rs | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..afbaf74495 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -335,6 +335,64 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } + +#[test] +fn path_make_selection_rejects_invalid_options_without_changing_history() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60], [12, 60]]}]}})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 12, "height": 12})).unwrap(); + let before = doc(&s).clone(); + let history = s.active().unwrap().history.past_len(); + for opts in [ + json!({"feather": -1}), + json!({"feather": 1001}), + json!({"feather": 1e100}), + json!({"feather": "4"}), + json!({"feather": true}), + json!({"antiAlias": "false"}), + json!({"antiAlias": 1}), + json!({"mode": "union"}), + json!({"mode": 42}), + ] { + assert!(s.execute("path.toSelection", opts.clone()).is_err(), "accepted invalid Make Selection options: {opts}"); + assert_eq!(*doc(&s), before, "invalid Make Selection modified the document: {opts}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "invalid Make Selection changed history: {opts}"); + } +} + +#[test] +fn open_pen_paths_make_the_same_selection_as_closed_paths() { + // The pen's unfinished work path should be implicitly closed when rasterized into a selection. + let mut s = session(80, 80, 8); + let knots = json!([[12, 12], [52, 12], [52, 52], [12, 52]]); + for (aa, feather) in [(false, 0.0), (true, 0.0), (false, 3.0), (true, 3.0)] { + let mut masks = Vec::new(); + for closed in [false, true] { + s.execute("path.set", json!({"path": {"subpaths": [{"closed": closed, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"antiAlias": aa, "feather": feather, "mode": "replace"})).unwrap(); + masks.push(selection_mask(&s)); + } + assert_eq!(masks[0], masks[1], "open and closed paths differ (antiAlias={aa}, feather={feather})"); + assert!(masks[0][32 * 80 + 32] > 0.99); + assert_eq!(masks[0][4 * 80 + 4], 0.0); + if feather > 0.0 { + assert!(masks[0].iter().any(|v| *v > 0.0 && *v < 1.0), "feather did not soften the selection"); + } + } + + // All boolean operations should use the same selection semantics for an open Pen path. + for mode in ["replace", "add", "subtract", "intersect"] { + s.execute("select.rect", json!({"x": 30, "y": 30, "width": 34, "height": 25})).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"closed": false, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"mode": mode, "antiAlias": false})).unwrap(); + let mask = selection_mask(&s); + let at = |x: usize, y: usize| mask[y * 80 + x]; + assert_eq!(at(20, 20), if matches!(mode, "replace" | "add") { 1.0 } else { 0.0 }, "{mode}: path-only"); + assert_eq!(at(60, 40), if matches!(mode, "add" | "subtract") { 1.0 } else { 0.0 }, "{mode}: previous-only"); + assert_eq!(at(40, 40), if mode == "subtract" { 0.0 } else { 1.0 }, "{mode}: overlap"); + } +} + #[test] fn selection_to_work_path_roundtrip_iou() { let mut s = session(240, 180, 8); From 35b4b30f2d2422385a8fbe4fd6f7c0d07ad9c0fd Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:00:44 +0700 Subject: [PATCH 036/304] style(path): keep regression cases consistently spaced --- crates/engine/src/vector_cmds/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index afbaf74495..a2e8047115 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -335,7 +335,6 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } - #[test] fn path_make_selection_rejects_invalid_options_without_changing_history() { let mut s = session(80, 80, 8); From 827e4e70949d0620164b5d69791923be069fc189 Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Thu, 15 Oct 2026 01:01:37 -0700 Subject: [PATCH 037/304] Codecs: cap what tiff_exif::read copies from overlapping EXIF values (#5982) An entry's value only has to lie inside the file, so many Exif entries can point at the same bytes: a 62 KB TIFF with 1,000 such entries made the reader copy 50 MB (and the block writer copy it again), on every TIFF import and on export of a document carrying an EXIF block. read() now has a copy budget shared by IFD0's descriptive tags and the GPS and Exif directories: at most 4 MiB, and never more than the stream's own length, since the values of a well-formed stream are distinct bytes of it. An entry that does not fit is left out and later, smaller ones are still kept; the small directories are read first so a huge maker note cannot crowd out the GPS position. Maker notes of 200 KB and 1 MB round-trip as before. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh --- crates/codecs/src/codecs/tiff_exif.rs | 46 ++++++++++--- crates/codecs/tests/tiff_exif.rs | 94 +++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/crates/codecs/src/codecs/tiff_exif.rs b/crates/codecs/src/codecs/tiff_exif.rs index d07f555f30..00d47d0702 100644 --- a/crates/codecs/src/codecs/tiff_exif.rs +++ b/crates/codecs/src/codecs/tiff_exif.rs @@ -6,7 +6,9 @@ //! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own //! directories, writing one copies the block's directories into the file. Values are copied as //! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive. -//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. +//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is +//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can +//! overlap (see [`read`]). use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind}; use tiff::tags::{Tag, Type}; @@ -27,6 +29,11 @@ const TAG_INTEROP_IFD: u16 = 40965; const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432]; /// At most this many entries are copied from one directory. const MAX_FIELDS: usize = 1024; +/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF +/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for +/// that and for a few such notes. A stream never yields more than its own length either (see +/// [`read`]). +const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024; /// One entry with its value bytes in the byte order of the stream it came from. #[derive(Clone, Debug, PartialEq)] @@ -53,13 +60,22 @@ impl ExifDirs { } } -fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec { - ifd.entries - .iter() - .filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) - .filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() })) - .take(MAX_FIELDS) - .collect() +/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has +/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may +/// still be copied (shared by all the directories of one stream), and an entry whose value does +/// not fit in what is left is skipped, so later, smaller entries can still be kept. +fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec { + let mut out = Vec::new(); + for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) { + if out.len() >= MAX_FIELDS { + break; + } + let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue }; + let Some(left) = budget.checked_sub(value.len()) else { continue }; + *budget = left; + out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() }); + } + out } fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { @@ -69,14 +85,24 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { /// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its /// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories. +/// +/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values +/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries +/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what +/// is left are left out (the small descriptive directories come first, so a huge maker note +/// cannot crowd out the GPS position). pub(crate) fn read(b: &[u8]) -> Option { let f = File::parse(b)?; if f.big { return None; } let ifd0 = f.ifd(f.first_ifd)?; - let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default(); - Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) }) + let mut budget = MAX_COPIED_BYTES.min(b.len()); + let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t)); + let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default(); + let gps = dir(TAG_GPS_IFD); + let exif = dir(TAG_EXIF_IFD); + Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps }) } /// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs. diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/tiff_exif.rs index 92e8c91df0..7e06a0e5b8 100644 --- a/crates/codecs/tests/tiff_exif.rs +++ b/crates/codecs/tests/tiff_exif.rs @@ -166,3 +166,97 @@ fn no_exif_is_written_without_embed_metadata() { let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap(); assert_eq!(decode(&tiff).unwrap().meta.exif, None); } + +// --------------------------------------------------------------------------- +// Bounded copying: an entry's value only has to lie inside the file, so a hostile file can have +// many entries point at the same bytes. +// --------------------------------------------------------------------------- + +/// A valid 1x1 gray TIFF whose Exif directory has `entries` UNDEFINED entries of `count` bytes +/// that all point at the same `count` bytes: the file is about `count` bytes long and asks to +/// copy `entries * count`. +fn overlapping_tiff(entries: usize, count: u32) -> Vec { + let entry = |tag: u16, ty: u16, count: u32, value: u32| { + [tag.to_le_bytes().to_vec(), ty.to_le_bytes().to_vec(), count.to_le_bytes().to_vec(), value.to_le_bytes().to_vec()].concat() + }; + let at_exif = 8 + 2 + 12 * 10 + 4; + let data_at = at_exif + 2 + 12 * entries + 4; + let mut out = b"II".to_vec(); + out.extend(42u16.to_le_bytes()); + out.extend(8u32.to_le_bytes()); + out.extend(10u16.to_le_bytes()); + for (tag, ty, value) in + [(256, 3, 1), (257, 3, 1), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 0), (277, 3, 1), (278, 3, 1), (279, 4, 1), (34665, 4, at_exif as u32)] + { + out.extend(entry(tag, ty, 1, value)); + } + out.extend(0u32.to_le_bytes()); + out.extend((entries as u16).to_le_bytes()); + for i in 0..entries { + out.extend(entry(i as u16 + 1, 7, count, data_at as u32)); + } + out.extend(0u32.to_le_bytes()); + out.extend(vec![0x5A; count as usize]); + out +} + +#[test] +fn overlapping_field_values_are_bounded() { + // About 62 KB on disk; copied entry by entry it would be 1,000 x 50,000 bytes. + let tiff = overlapping_tiff(1000, 50_000); + assert!(tiff.len() < 64 * 1024); + let img = decode(&tiff).expect("the image itself is fine"); + assert_eq!(img.dimensions(), (1, 1)); + let exif = img.meta.exif.expect("the first entries are kept"); + assert!(exif.len() <= 2 * tiff.len(), "{} bytes of EXIF from a {} byte file", exif.len(), tiff.len()); +} + +#[test] +fn overlapping_field_values_in_an_exif_block_are_bounded_on_export() { + // The same block as an image's EXIF: the writer reads it with the same code. + let block = overlapping_tiff(1000, 50_000); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() <= 2 * block.len(), "a {} byte block wrote a {} byte file", block.len(), tiff.len()); +} + +/// An EXIF block with a maker note of `len` bytes, an exposure time and a GPS latitude reference. +fn block_with_maker_note(le: bool, len: usize) -> Vec { + let b = Block { le }; + b.build( + vec![(271, 2, 4, b"Cam\0".to_vec())], + vec![(33434, 5, 1, b.rational(1, 125)), (37500, 7, len as u32, (0..len).map(|i| (i * 7) as u8).collect())], + vec![(1, 2, 2, b"N\0".to_vec())], + ) +} + +#[test] +fn large_maker_notes_still_survive() { + // Nikon maker notes run from 100 KB to nearly 1 MB. + for len in [200 * 1024, 1000 * 1024] { + let block = block_with_maker_note(true, len); + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (ble, _, bexif, bgps) = dirs(&back); + let (ole, _, oexif, ogps) = dirs(&block); + assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "{len} byte maker note"); + assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole)); + } +} + +#[test] +fn a_field_beyond_the_cap_is_dropped_and_the_rest_kept() { + let block = block_with_maker_note(false, 6 * 1024 * 1024); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() < 1024 * 1024, "{} bytes", tiff.len()); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (_, ifd0, exif, gps) = dirs(&back); + assert_eq!(exif.iter().map(|e| e.0).collect::>(), [33434], "the exposure time stays, the 6 MiB maker note goes"); + assert_eq!(gps.iter().map(|e| e.0).collect::>(), [1]); + assert_eq!(ifd0.iter().map(|e| e.0).collect::>(), [271]); +} From f116c3230a27fff30bc31b79b72c2cfb0f0ba074 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 15:47:38 +0400 Subject: [PATCH 038/304] Retouch: guard every stroke command, not only Pattern Stamp (#6110) Pattern Stamp was the only retouch command that refused a stroke it could not render. Clone Stamp, Healing Brush, Spot Healing and Remove all reach the same dense conversion: coverage is accumulated in tiles, but dense_coverage() materialises the whole bounding box, and the retouch paths read a Region over that box for every channel. The box comes from the stroke's own points, which are validated against MAX_COORD rather than the document, so it is not bounded by the canvas. Both allocations go through the infallible vec!, which aborts instead of returning an error. With the guards now shared in retouch_cmds, apply them to the rest: reject_huge_stroke before anything is rendered, and reject_bounds before the dense region read where the command can return one. spot_heal_surface returns a Rect, so Spot Healing charges the stroke in the command; Remove builds a list, so each stroke is charged as it is parsed. The allowance is unchanged, so no stroke that worked before starts failing. Test: every retouch stroke refuses the oversized box, leaves the pixels alone, and still accepts a stroke across the whole canvas. --- crates/engine/src/retouch_cmds.rs | 5 +++ crates/engine/src/retouch_cmds/remove.rs | 2 + crates/engine/src/retouch_cmds/tests.rs | 47 ++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9726172062..b462d37519 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -358,6 +358,7 @@ fn clamp_samples(fmt: &PixelFormat, data: &mut [f32]) { fn clone_stamp(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.cloneStamp"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); @@ -383,6 +384,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); } let (bounds, cov) = stroke_coverage(&stroke); + reject_bounds(CMD, bounds)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -638,6 +640,7 @@ fn heal_region(fmt: &PixelFormat, src: &Region, dst: &Region, mask: &[bool]) -> fn healing_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.healingBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); @@ -645,6 +648,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { let (bounds, cov) = stroke_coverage(&stroke); + reject_bounds(CMD, bounds)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; @@ -821,6 +825,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: fn spot_healing(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.spotHealing"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let kind = match string(p, "type", "contentAware") { "contentAware" => SpotType::ContentAware, "createTexture" => SpotType::CreateTexture, diff --git a/crates/engine/src/retouch_cmds/remove.rs b/crates/engine/src/retouch_cmds/remove.rs index 4e256fb7fc..55c53d3af7 100644 --- a/crates/engine/src/retouch_cmds/remove.rs +++ b/crates/engine/src/retouch_cmds/remove.rs @@ -71,6 +71,7 @@ fn stroke_bounds(stroke: &Stroke) -> Rect { fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option)> { let Some(list) = p.get("strokes") else { let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; return Ok((vec![stroke], id)); }; let list = list.as_array().ok_or_else(|| bad(CMD, "`strokes` is a list of point lists"))?; @@ -86,6 +87,7 @@ fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option o.insert("points".into(), points.clone()); } let (stroke, layer) = parse_brush(s, &one, CMD)?; + reject_huge_stroke(CMD, &stroke)?; strokes.push(stroke); id = layer; } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index d72924c430..35f6e81efd 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -544,10 +544,10 @@ fn history_brush_restores_earlier_pixels() { } } -/// #4961: the History Brush's coverage and its source region are both dense over the stroke's -/// bounds, so a stroke whose box dwarfs the canvas is refused from the points and brush size -/// alone, before the source surface is cloned or any coverage is rendered. The document is left -/// as it was, and ordinary strokes still paint at every depth. +/// #4961 and #6110: every retouch stroke renders its coverage densely over the stroke's bounds +/// and reads a source region over the same box, so a stroke whose box dwarfs the canvas is +/// refused from the points and brush size alone, before anything is cloned or rendered. The +/// document is left as it was, and ordinary strokes still paint at every depth. #[test] fn history_brush_refuses_a_stroke_too_large_to_render() { for depth in DEPTHS { @@ -565,6 +565,45 @@ fn history_brush_refuses_a_stroke_too_large_to_render() { } } +/// #6110: Pattern Stamp already refused a stroke it could not render; every other retouch stroke +/// reached the same dense conversion unguarded. Each command refuses the oversized stroke and +/// leaves the pixels alone, and a stroke across the whole canvas still works. +#[test] +fn every_retouch_stroke_refuses_a_box_it_cannot_render() { + let huge = json!([[0, 0], [900_000, 900_000]]); + let ok = json!([[0, 0], [40, 20]]); + let cases: [(&str, Value); 5] = [ + ("paint.cloneStamp", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.healingBrush", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.spotHealing", json!({"size": 8, "hardness": 100})), + ("paint.historyBrush", json!({"size": 8, "hardness": 100})), + ("paint.patternStamp", json!({"size": 8, "hardness": 100})), + ]; + for (cmd, base) in cases { + let mut s = session(40, 20, 8, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + let with = |points: &Value| { + let mut v = base.clone(); + if let Some(o) = v.as_object_mut() { + o.insert("points".into(), points.clone()); + } + v + }; + let err = s.execute(cmd, with(&huge)).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{cmd}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "{cmd}: a refused stroke leaves the pixels alone"); + // The allowance still covers a stroke across the whole canvas. + let across = s.execute(cmd, with(&ok)); + assert!(across.is_ok() || !format!("{across:?}").contains("stroke too large"), "{cmd}: {across:?}"); + } + + // Remove takes a list of strokes; each one is charged as it is parsed. + let mut s = session(40, 20, 8, "rgb"); + let err = s.execute("paint.remove", json!({"strokes": [[[0, 0], [900_000, 900_000]]], "size": 8})).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "paint.remove: {err}"); +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. From 6b3a6ffd8b224479ee634d5c150e00d3cc8e0387 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 21:52:28 +0800 Subject: [PATCH 039/304] Document conservative blend preview coverage Explain why clip_damage replaces the covered intersection, including an empty one, while retaining the affected region for uncovered to recompute. This is a comment-only follow-up to review on storytold/photocraft#5413. Existing controlled 24 MP release measurement: median refresh/upload- submission time was 734.677 ms for the full 6000x4000 region and 59.641 ms for a 1280x720 viewport, five measured samples per region after warmup. This compares regions in the same candidate binary on CPU fallback with Vulkan llvmpipe, not two revisions or physical-GPU completion latency. Evidence: https://github.com/jonathanwxh-cell/photocraft/actions/runs/38512473874 --- crates/ui-egui/src/blend_preview.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index b73ddd553b..b5a203727d 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -91,6 +91,9 @@ pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, ke return region; } let shown = region.intersect(&visible); + // `covered` records only this refresh, including an empty intersection; it is not cumulative. + // Keep `region` so `uncovered` can recompute the next visible area. Forgetting older coverage + // can cause a redundant refresh, but cannot skip newly exposed pixels for this key. p.covered = Some((key, region, shown)); shown } From 70ffad5a566e8b747084b036fed25d517b167faf Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:01:09 +0800 Subject: [PATCH 040/304] test(text): retain imported font metadata after text edits --- crates/text/src/tests.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..ff4dade6ea 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2008,3 +2008,27 @@ fn hyphenless_postscript_names_split_trailing_styles() { assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}"); } } + +#[test] +fn text_rebuild_preserves_imported_font_metadata() { + use crate::engine_data::Value as E; + let layer = point("Original", 24.0); + let mut template = crate::psd::build_engine_data(&layer, None, 72.0); + for key in ["ResourceDict", "DocumentResources"] { + let fonts = template.get_mut(key).unwrap().get_mut("FontSet").unwrap(); + let E::Array(fonts) = fonts else { panic!("font array") }; + for font in fonts.iter_mut() { + font.set("Script", E::Int(4)); + font.set("FontType", E::Int(2)); + font.set("Synthetic", E::Int(3)); + font.set("PrivateFontFlag", E::Int(17)); + } + } + let expected = template.path(&["ResourceDict", "FontSet"]).unwrap().clone(); + let mut edited = layer; + edited.text = "Edited".into(); + let rebuilt = crate::psd::build_engine_data(&edited, Some(template), 72.0); + for key in ["ResourceDict", "DocumentResources"] { + assert_eq!(rebuilt.path(&[key, "FontSet"]), Some(&expected), "existing font metadata must survive a text edit"); + } +} From 2656f8db3d3e0bd4f049aa7f85a49b025390e6a2 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:04:27 +0800 Subject: [PATCH 041/304] fix(text): preserve imported font records during text and style edits --- crates/text/src/psd.rs | 20 ++++++++++++-- crates/text/src/psd_styles.rs | 3 ++- crates/text/src/tests.rs | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 0092fcf92e..3950c699f2 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -901,6 +901,22 @@ pub(crate) fn font_entry(name: &str) -> E { E::Dict(vec![("Name".into(), E::String(name.into())), ("Script".into(), E::Int(0)), ("FontType".into(), E::Int(1)), ("Synthetic".into(), E::Int(0))]) } +/// Keep imported font metadata while retaining the requested font indices. New fonts use +/// the normal defaults; same-name duplicates retain their own positional records. +pub(crate) fn font_set(names: &[String], original: Option<&E>) -> E { + let entries = original.and_then(E::as_array).unwrap_or_default(); + E::Array( + names + .iter() + .enumerate() + .map(|(i, name)| { + let matches = |entry: &&E| entry.get("Name").and_then(E::as_str) == Some(name.as_str()); + entries.get(i).filter(matches).or_else(|| entries.iter().find(matches)).cloned().unwrap_or_else(|| font_entry(name)) + }) + .collect(), + ) +} + /// The `Txt2` auto-kern mode of a style: 0 manual, 1 metrics, 2 optical — the values /// [`apply_txt2`] maps back to [`Kerning`]. EngineData's `AutoKerning` can't say Metrics from /// Optical, so the mode only lives in `Txt2`. @@ -1150,7 +1166,6 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E )])); plens.push(E::Int(n)); } - let fontset = E::Array(fonts.iter().map(|f| font_entry(f)).collect()); let Some(dict) = e.get_mut("EngineDict") else { return fresh(); @@ -1231,7 +1246,8 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E continue; } let Some(rd) = e.get_mut(key) else { continue }; - rd.set("FontSet", fontset.clone()); + let fontset = font_set(&fonts, rd.get("FontSet")); + rd.set("FontSet", fontset); if rd.get("StyleSheetSet").is_none() { let base = runs.first().map(|r| r.style.clone()).unwrap_or_default(); rd.set( diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index 7c4f69cd8a..6f953f1cec 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -187,7 +187,8 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d rd.set("ParagraphSheetSet", E::Array(pset)); rd.set("TheNormalStyleSheet", E::Int(0)); rd.set("TheNormalParagraphSheet", E::Int(0)); - rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect())); + let fontset = crate::psd::font_set(&fonts, rd.get("FontSet")); + rd.set("FontSet", fontset); } let char_fonts = e.path(&["ResourceDict", "FontSet"]).or_else(|| e.path(&["DocumentResources", "FontSet"])).map(|f| names(Some(f))).unwrap_or_default(); let char_ix = |id: Option| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ff4dade6ea..d73c32dda6 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2032,3 +2032,54 @@ fn text_rebuild_preserves_imported_font_metadata() { assert_eq!(rebuilt.path(&[key, "FontSet"]), Some(&expected), "existing font metadata must survive a text edit"); } } + +#[test] +fn font_metadata_survives_tysh_and_named_style_rewrites() { + use crate::engine_data::Value as E; + let source = point("Original", 24.0); + let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); + let mut data = crate::psd::engine_data(&tysh.text).unwrap(); + for (key, marker) in [("ResourceDict", 17), ("DocumentResources", 29)] { + let E::Array(fonts) = data.get_mut(key).unwrap().get_mut("FontSet").unwrap() else { panic!("font array") }; + for font in fonts { + font.set("PrivateFontFlag", E::Int(marker)); + font.set("Script", E::Int(4)); + } + } + for (key, value) in &mut tysh.text.items { + if key.as_bytes() == b"EngineData" { + *value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data)); + } + } + let raw = crate::psd::write_tysh(&tysh); + let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); + imported.text = "Edited".into(); + let rebuilt = crate::psd::build_tysh(&imported, 72.0, None); + let styled = crate::psd_styles::write_style_sheets(&rebuilt, &imported, &photocraft_doc::TextStyles::default(), 72.0).unwrap(); + for bytes in [&rebuilt, &styled] { + let parsed = crate::psd::parse_tysh(bytes).unwrap(); + let actual = crate::psd::engine_data(&parsed.text).unwrap(); + for key in ["ResourceDict", "DocumentResources"] { + assert_eq!(actual.path(&[key, "FontSet"]), data.path(&[key, "FontSet"])); + } + assert_eq!(crate::psd::text_layer_from_tysh(bytes, 72.0).unwrap().text, "Edited"); + } +} + +#[test] +fn font_metadata_append_reorder_and_duplicate_indices() { + use crate::engine_data::Value as E; + let record = |name: &str, marker| E::Dict(vec![("Name".into(), E::String(name.into())), ("PrivateFontFlag".into(), E::Int(marker))]); + let entries = vec![record("A", 1), record("A", 2), record("B", 3)]; + let original = E::Array(entries.clone()); + let names = ["A", "A", "B", "New"].map(str::to_string); + let result = crate::psd::font_set(&names, Some(&original)); + let fonts = result.as_array().unwrap(); + assert_eq!(&fonts[..3], entries.as_slice()); + assert_eq!(fonts[3], crate::psd::font_entry("New")); + let reordered = crate::psd::font_set(&["B".into(), "A".into()], Some(&original)); + assert_eq!(reordered, E::Array(vec![entries[2].clone(), entries[1].clone()])); + for malformed in [None, Some(&E::Int(0))] { + assert_eq!(crate::psd::font_set(&["New".into()], malformed), E::Array(vec![crate::psd::font_entry("New")])); + } +} From e0ddefe7e0ced95765ce32738397604a962f2974 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:07:31 +0800 Subject: [PATCH 042/304] test(text): retain raw TySh as the document importer does --- crates/text/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d73c32dda6..4c904b716a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2053,6 +2053,8 @@ fn font_metadata_survives_tysh_and_named_style_rewrites() { } let raw = crate::psd::write_tysh(&tysh); let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); + // The IO importer retains the raw block separately from the parsed text model. + imported.psd_raw = Some(raw.into()); imported.text = "Edited".into(); let rebuilt = crate::psd::build_tysh(&imported, 72.0, None); let styled = crate::psd_styles::write_style_sheets(&rebuilt, &imported, &photocraft_doc::TextStyles::default(), 72.0).unwrap(); From 14ceeb927eaddf2ae821e39f466e7f7ddd0af298 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:27:15 +0800 Subject: [PATCH 043/304] Reproduce new selections remaining hidden after Extras is disabled --- crates/ui-egui/src/view_cmds/tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..80ab177f0f 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -506,3 +506,22 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { assert_eq!(app.ui.view.screen_mode, "standard"); assert!(app.session.active().unwrap().doc.selection.is_some()); } + +#[test] +fn creating_a_selection_restores_hidden_extras() { + let mut hidden = Vec::new(); + for (command, params) in + [("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), ("select.loadSelection", json!({"channel": "transparency"}))] + { + let (mut app, ctx) = app_with(1); + app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras, "explicit hide remains effective before a new selection"); + app.run(command, params).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some(), "{command} creates a selection"); + if !app.ui.view.shows(app.ui.view.show.selection_edges) || checked(&app, "view.extras") != Some(true) { + hidden.push(command); + } + } + assert!(hidden.is_empty(), "new selections stayed hidden after Extras was disabled: {hidden:?}"); +} From 0a608e63dbbeff4da8ff02f46cda41f02f2610f6 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:31:23 +0800 Subject: [PATCH 044/304] Cover marquee pointer gesture and subsequent explicit hide --- crates/ui-egui/src/input_tests.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..f2ac635123 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -1087,3 +1087,31 @@ fn move_tool_controls_rotate_from_just_outside_the_box() { let tilt = (end[1][1] - end[0][1]).atan2(end[1][0] - end[0][0]).to_degrees(); assert!((tilt - 90.0).abs() < 8.0 || (tilt + 270.0).abs() < 8.0, "rotated about a quarter turn, got {tilt}° (zoom {zoom})"); } + +#[test] +fn marquee_drag_after_hiding_extras_restores_selection_visibility() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::RectMarquee; + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + assert!(!h.state().ui.view.extras); + let start = h.state().last_canvas_rect.center() - vec2(30.0, 20.0); + let end = start + vec2(60.0, 40.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=4 { + h.hover_at(start + (end - start) * (i as f32 / 4.0)); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the pointer drag created a selection"); + assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "a new marquee must become visible after hiding Extras"); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + h.run_steps(2); + assert!(!h.state().ui.view.extras, "the user can hide the existing selection again"); + h.state_mut().run("edit.fill", json!({"color": "#22cc44"})).unwrap(); + assert!(!h.state().ui.view.extras, "painting an existing selection must preserve the user's hide choice"); +} From 67a2938cab585f7d458fca46436c985dda173113 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:32:18 +0800 Subject: [PATCH 045/304] Guard intentional hidden selections against failures and unrelated edits --- crates/ui-egui/src/view_cmds/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 80ab177f0f..c966f516db 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -525,3 +525,17 @@ fn creating_a_selection_restores_hidden_extras() { } assert!(hidden.is_empty(), "new selections stayed hidden after Extras was disabled: {hidden:?}"); } + +#[test] +fn failed_selection_and_unrelated_edits_keep_extras_hidden() { + let (mut app, ctx) = app_with(1); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras); + assert!(app.run("select.rect", json!({"x": "invalid"})).is_err()); + assert!(!app.ui.view.extras, "failed selection must not change the view"); + app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); + assert!(!app.ui.view.extras, "painting an existing hidden selection keeps it hidden"); + app.run("select.deselect", json!({})).unwrap(); + assert!(!app.ui.view.extras, "deselecting has no new outline to reveal"); +} From 7d42b5c838fd77003e8495c3690a7d70363183a2 Mon Sep 17 00:00:00 2001 From: ersoynarin-code Date: Thu, 15 Oct 2026 18:38:34 +0300 Subject: [PATCH 046/304] fix(perf): validate regression minimum threshold --- xtask/src/perf.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs index d2a3a75b85..3e49514c1c 100644 --- a/xtask/src/perf.rs +++ b/xtask/src/perf.rs @@ -168,6 +168,9 @@ pub fn parse_budgets(text: &str) -> Result { if !(b.settings.regression_pct.is_finite() && b.settings.regression_pct > 0.0) { return Err(format!("{BUDGETS}: settings.regression_pct must be > 0")); } + if !(b.settings.regression_min_ms.is_finite() && b.settings.regression_min_ms >= 0.0) { + return Err(format!("{BUDGETS}: settings.regression_min_ms must be finite and >= 0")); + } Ok(b) } @@ -980,6 +983,8 @@ not_measurable = "needs a frame harness" .is_err() ); assert!(parse_budgets("[settings]\nregression_pct = 0\n").is_err()); + assert!(parse_budgets("[settings]\nregression_min_ms = -1\n").is_err()); + assert!(parse_budgets("[settings]\nregression_min_ms = 0\n").is_ok()); assert!(parse_budgets("bogus = 1\n").is_err()); assert!(parse_budgets("not toml [").is_err()); } From 6bef23a7150b316d85dd841554ab67dc43fe091f Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:45:34 +0800 Subject: [PATCH 047/304] Reveal Extras when interactive selection commands succeed --- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/src/view_cmds/tests.rs | 23 ++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d95f2d662..0697409c21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1116,6 +1116,15 @@ impl PhotocraftApp { dock::reveal(self, dock::Group::Properties); } } + // Making a new selection reveals its outline after View > Extras was hidden (#5597). + // These interactive selection commands complete inline; a failed command, deselection, + // or painting inside an existing selection must leave the user's view choice alone. + if r.is_ok() + && matches!(id, "select.rect" | "select.lasso" | "select.magicWand" | "select.loadSelection" | "select.loadLuminosity") + && self.session.active().is_some_and(|st| st.doc.selection.is_some()) + { + self.ui.view.extras = true; + } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. self.ui.selected_path = Some("work".into()); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index c966f516db..299a47d22f 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -510,9 +510,14 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { #[test] fn creating_a_selection_restores_hidden_extras() { let mut hidden = Vec::new(); - for (command, params) in - [("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), ("select.loadSelection", json!({"channel": "transparency"}))] - { + for (command, params) in [ + ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "ellipse": true})), + ("select.lasso", json!({"points": [[10,10], [30,10], [20,30]]})), + ("select.magicWand", json!({"x": 10, "y": 10})), + ("select.loadSelection", json!({"channel": "transparency"})), + ("select.loadLuminosity", json!({})), + ] { let (mut app, ctx) = app_with(1); app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); @@ -539,3 +544,15 @@ fn failed_selection_and_unrelated_edits_keep_extras_hidden() { app.run("select.deselect", json!({})).unwrap(); assert!(!app.ui.view.extras, "deselecting has no new outline to reveal"); } + +#[test] +fn new_selection_preserves_individual_show_preferences() { + let (mut app, ctx) = app_with(1); + app.ui.view.show.selection_edges = false; + app.ui.view.show.grid = false; + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert!(app.ui.view.extras); + assert!(!app.ui.view.show.selection_edges); + assert!(!app.ui.view.show.grid); +} From 6fc1004b5056f38142599732d1d2c7f63ebcfe32 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:47:34 +0800 Subject: [PATCH 048/304] Cover Extras restoration through actual layer thumbnail clicks --- crates/ui-egui/src/path_selection_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index b4aaa9e503..a1cec58060 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -331,3 +331,24 @@ fn path_overlay_drawn_for_non_vector_tools() { the path must be drawn regardless of the active tool", ); } + +#[test] +fn thumbnail_selection_after_hiding_extras_restores_outline() { + let mut h = harness(); + let shape = + h.state_mut().run("shape.create", json!({"kind":"rect","rect":[40,20,60,40],"fill":"#cc3333","name":"Badge"})).unwrap()["layer"].as_u64().unwrap(); + let active = h.state_mut().run("layer.new.layer", json!({"name":"Other"})).unwrap()["layer"].as_u64().unwrap(); + panel(&mut h, 0); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + assert!(!h.state().ui.view.extras); + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == shape).unwrap(); + click(&mut h, row.name.unwrap().center(), command()); + assert!(!h.state().ui.view.extras, "selecting layers must not reveal Extras"); + h.state_mut().run("layer.select", json!({"layer": active})).unwrap(); + h.run_steps(2); + click(&mut h, egui::pos2(row.row.left() + 46.0, row.row.center().y), command()); + assert_eq!(selection(&h), Some([40, 20, 60, 40])); + assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "Ctrl-clicking the thumbnail reveals the new selection"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(active))); +} From 2cea742a00617efc890ff67ff915ec67fe9fb483 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:50:21 +0800 Subject: [PATCH 049/304] Cover remaining interactive selection routes and preserve no-op hiding --- crates/ui-egui/src/lib.rs | 15 +++++++++++++-- crates/ui-egui/src/path_selection_tests.rs | 13 +++++++++++++ crates/ui-egui/src/view_cmds/tests.rs | 14 ++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0697409c21..cb87960ff6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1119,8 +1119,19 @@ impl PhotocraftApp { // Making a new selection reveals its outline after View > Extras was hidden (#5597). // These interactive selection commands complete inline; a failed command, deselection, // or painting inside an existing selection must leave the user's view choice alone. - if r.is_ok() - && matches!(id, "select.rect" | "select.lasso" | "select.magicWand" | "select.loadSelection" | "select.loadLuminosity") + if r.as_ref().is_ok_and(|v| v.get("changed") != Some(&Value::Bool(false)) && v.get("pending") != Some(&Value::Bool(true))) + && matches!( + id, + "select.rect" + | "select.lasso" + | "select.magneticLasso" + | "select.magicWand" + | "select.quick" + | "select.object" + | "select.loadSelection" + | "select.loadLuminosity" + | "path.toSelection" + ) && self.session.active().is_some_and(|st| st.doc.selection.is_some()) { self.ui.view.extras = true; diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index a1cec58060..12b1670863 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -352,3 +352,16 @@ fn thumbnail_selection_after_hiding_extras_restores_outline() { assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges), "Ctrl-clicking the thumbnail reveals the new selection"); assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(active))); } + +#[test] +fn loading_a_path_after_hiding_extras_restores_outline() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(10.0, 10.0, 50.0, 40.0)})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "view.extras", json!({})).unwrap(); + assert!(!h.state().ui.view.extras); + press(&mut h, Key::Enter, command()); + assert_eq!(selection(&h), Some([10, 10, 50, 40])); + assert!(h.state().ui.view.shows(h.state().ui.view.show.selection_edges)); +} diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 299a47d22f..83611d0e1a 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -514,7 +514,9 @@ fn creating_a_selection_restores_hidden_extras() { ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})), ("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "ellipse": true})), ("select.lasso", json!({"points": [[10,10], [30,10], [20,30]]})), + ("select.magneticLasso", json!({"points": [[10,10], [30,10], [20,30]], "trace": false})), ("select.magicWand", json!({"x": 10, "y": 10})), + ("select.quick", json!({"points": [[10,10]], "size": 8})), ("select.loadSelection", json!({"channel": "transparency"})), ("select.loadLuminosity", json!({})), ] { @@ -556,3 +558,15 @@ fn new_selection_preserves_individual_show_preferences() { assert!(!app.ui.view.show.selection_edges); assert!(!app.ui.view.show.grid); } + +#[test] +fn object_selection_finding_nothing_keeps_extras_hidden() { + let (mut app, ctx) = app_with(1); + app.run("edit.fill", json!({"color": "#ffffff"})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + let result = app.run("select.object", json!({"point": [3,3]})).unwrap(); + assert_eq!(result["changed"], false); + assert!(app.session.active().unwrap().doc.selection.is_some()); + assert!(!app.ui.view.extras, "finding no object must not reveal an old selection"); +} From 173e7fd6bd7cfe3840b46129e7510a2f1731f3f5 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:51:18 +0800 Subject: [PATCH 050/304] Test successful Object Selection restoring hidden Extras --- crates/ui-egui/src/view_cmds/tests.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 83611d0e1a..27f05de544 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -570,3 +570,20 @@ fn object_selection_finding_nothing_keeps_extras_hidden() { assert!(app.session.active().unwrap().doc.selection.is_some()); assert!(!app.ui.view.extras, "finding no object must not reveal an old selection"); } + +#[test] +fn object_selection_restores_extras_when_it_finds_an_object() { + let (mut app, ctx) = app_with(0); + app.run("file.new", json!({"width": 200, "height": 160})).unwrap(); + app.run("edit.fill", json!({"color": "#4073b3"})).unwrap(); + app.run("select.rect", json!({"x": 55, "y": 35, "width": 90, "height": 90, "ellipse": true})).unwrap(); + app.run("edit.fill", json!({"color": "#e64d26"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras); + let result = app.run("select.object", json!({"rect": [45,25,110,110]})).unwrap(); + assert_eq!(result["changed"], true, "the fixture must produce a new object selection"); + let selection = app.session.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(selection.sample_channel(100, 80, 0) > 0.5, "the central object is selected"); + assert!(app.ui.view.shows(app.ui.view.show.selection_edges)); +} From ce0ba035728d52516bdb416c69e05bbf4fc8d911 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:56:30 +0800 Subject: [PATCH 051/304] Use the actual pixel grid Show preference in regression --- crates/ui-egui/src/view_cmds/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 27f05de544..9543fb7dd9 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -551,12 +551,12 @@ fn failed_selection_and_unrelated_edits_keep_extras_hidden() { fn new_selection_preserves_individual_show_preferences() { let (mut app, ctx) = app_with(1); app.ui.view.show.selection_edges = false; - app.ui.view.show.grid = false; + app.ui.view.show.pixel_grid = false; menu(&mut app, &ctx, "view.extras", json!({})).unwrap(); app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); assert!(app.ui.view.extras); assert!(!app.ui.view.show.selection_edges); - assert!(!app.ui.view.show.grid); + assert!(!app.ui.view.show.pixel_grid); } #[test] From 9a25a8a3908ba986a68e0db0596b9ce3726b3d23 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:14:38 +0700 Subject: [PATCH 052/304] style(svg): format expanded conversion state initializer --- crates/io/src/svg.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index a4384c8e17..6537aff28e 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -230,7 +230,17 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, let fmt = doc.pixel_format(); let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale); let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH)); - let mut conv = Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4], pixel_budget, retained_pixel_bytes: 0 }; + let mut conv = Conv { + fmt, + canvas: doc.bounds(), + global, + max_group_depth, + warnings, + noted: Vec::new(), + counts: [0; 4], + pixel_budget, + retained_pixel_bytes: 0, + }; if count_nodes(tree.root()) > MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); From c4de02978b23c6638bb888d6b2730d6d8704b053 Mon Sep 17 00:00:00 2001 From: artman67 <181856174+artman67@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:20:27 -0400 Subject: [PATCH 053/304] Menu bar: releasing Alt after Alt+scroll no longer focuses the bar, so Space can't run File > New Releasing Alt counted as a tap (keyboard focus to the menu bar, File lit) unless a key, text or pointer-button press came in between. A mouse-wheel event was not on that list, so zooming with Alt+wheel and letting go of Alt focused the bar; Space then opened File with New... lit, and a held Space's key repeat ran it. A wheel event, or the window losing focus while Alt is down (Alt went to a desktop shortcut), now cancels the tap. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menu_keys.rs | 7 +++++- crates/ui-egui/src/menu_keys_tests.rs | 31 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 5762b2a945..7260ef5387 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -173,7 +173,12 @@ impl Keys { self.alt_down = false; tap |= std::mem::take(&mut self.alt_tap); } - Event::Key { pressed: true, .. } | Event::Text(_) | Event::PointerButton { pressed: true, .. } => self.alt_tap = false, + // Alt+wheel (zoom, brush size…) or Alt going to the desktop is no tap either. + Event::Key { pressed: true, .. } + | Event::Text(_) + | Event::PointerButton { pressed: true, .. } + | Event::MouseWheel { .. } + | Event::WindowFocused(false) => self.alt_tap = false, _ => {} } } diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 1f226054e0..632d6d6cfc 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -76,6 +76,37 @@ fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() { assert!(!is_open(&h.ctx) && Keys::current(&h.ctx).focus.is_none(), "Esc leaves the menu and the bar"); } +/// Alt+wheel zooms; letting go of Alt after it is no tap, so Space (the hand tool) can't then +/// open File and run New…. +#[test] +fn alt_with_a_wheel_scroll_or_a_lost_focus_is_no_tap() { + let mut h = bar(); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.event_modifiers( + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::ALT, + }, + egui::Modifiers::ALT, + ); + h.run_steps(2); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "Alt+scroll leaves the bar alone"); + key(&mut h, egui::Key::Space); + assert!(!is_open(&h.ctx), "Space opens no menu"); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.event(egui::Event::WindowFocused(false)); + h.event(egui::Event::WindowFocused(true)); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "Alt that went to the desktop leaves the bar alone"); +} + #[test] fn a_second_alt_or_esc_or_a_click_takes_the_keyboard_off_the_bar() { let mut h = bar(); From a4cdc63d88eebf3debf73da572830244f8ad8f3e Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 23:50:55 +0400 Subject: [PATCH 054/304] Retouch: admit the rendered stroke extent, not an estimate (#4961, #6110) Review of f116c32 was right that the guard did not establish the bound it advertised. reject_bounds ran on the value stroke_coverage() returned, and that function calls dense_coverage() internally, so the dense Vec was already allocated by the time it was checked. reject_huge_stroke, the only check that ran first, estimated from the raw points and half the tip, which brush dynamics exceed: Scattering offsets a dab centre by its amount times the tip size. Split rendering from dense extraction. stroke_coverage_within renders the stroke into the renderer's tiles, which hold only what the dabs touch, reads the extent once every dab is placed and refuses before allocating the width x height buffer. retouch_cmds::coverage wraps it at MAX_STROKE_PIXELS and every stroke command goes through it, so the bound now holds whatever the dynamics do. reject_bounds is removed; it could not do the job it was named for. spot_heal_surface returned a bare Rect and so had nowhere to refuse; it returns Result now. reject_huge_stroke stays as the cheap early-out and pads by the scatter reach and the dual-brush radius so it no longer falls short. The allowance is unchanged, so no stroke that worked before starts failing. Tests: coverage_within takes the budget as a parameter, and a scattering brush proves the rendered extent exceeds what the points alone suggest, is refused one pixel under it and fits at it. Every command's refusal now asserts the pixels and the history step count are untouched, and the ordinary stroke uses a valid fixture and must succeed rather than merely avoid the size error. --- crates/engine/src/retouch_cmds.rs | 57 +++++++++++-------- .../engine/src/retouch_cmds/pattern_stamp.rs | 13 ++--- crates/engine/src/retouch_cmds/remove.rs | 8 +-- crates/engine/src/retouch_cmds/tests.rs | 46 ++++++++++++--- crates/paint/src/retouch.rs | 18 ++++++ 5 files changed, 99 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index b462d37519..8ef8722c16 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -15,7 +15,7 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; @@ -43,7 +43,16 @@ const MAX_STROKE_PIXELS: u64 = 67_108_864; /// Refuse a stroke whose footprint would not fit the coverage budget, from the points and brush /// size alone, before the renderer allocates anything for it. fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { - let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); + // Dynamics place dabs beyond the input points, so half the tip is not the whole reach: + // Scattering offsets a centre by up to its amount times the tip size, and a Dual Brush dab + // reaches its own radius further. Pad by what those can add so this estimate does not fall + // short of what the renderer will cover. It is only the cheap early-out; the rendered extent + // is admitted for real in `coverage` below. + let b = &stroke.brush; + let size = f64::from(b.size.max(1.0)); + let scatter = if b.scattering.enabled { size * f64::from(b.scattering.scatter.jitter.max(0.0)) } else { 0.0 }; + let dual = if b.dual_brush.enabled { f64::from(b.dual_brush.size.max(0.0)) * 0.5 } else { 0.0 }; + let pad = size.mul_add(0.5, 4.0) + scatter + dual; let mut min_x = f64::INFINITY; let mut min_y = f64::INFINITY; let mut max_x = f64::NEG_INFINITY; @@ -69,16 +78,19 @@ fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { Ok(()) } -fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { - if bounds.is_empty() { - return Ok(()); - } - let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); - let area = w.saturating_mul(h); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) +/// The stroke's coverage, with the **rendered** extent admitted before the dense buffer exists. +/// +/// `reject_huge_stroke` estimates from the input points; this is the real bound. Rendering is +/// tiled, so it holds only the tiles the dabs touch; the extent is read from those tiles once +/// every dab is placed (tips, dynamics and scattering included) and checked against `max`, and +/// only a stroke that fits is made dense. `max` is a parameter so tests can inject a small budget. +fn coverage_within(cmd: &str, stroke: &Stroke, max: u64) -> Result<(Rect, Vec)> { + photocraft_paint::retouch::stroke_coverage_within(stroke, max).ok_or_else(|| bad(cmd, format!("stroke too large (at most {max} px)"))) +} + +/// [`coverage_within`] at the shared allowance. +fn coverage(cmd: &str, stroke: &Stroke) -> Result<(Rect, Vec)> { + coverage_within(cmd, stroke, MAX_STROKE_PIXELS) } /// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush). @@ -383,8 +395,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); } - let (bounds, cov) = stroke_coverage(&stroke); - reject_bounds(CMD, bounds)?; + let (bounds, cov) = coverage(CMD, &stroke)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -647,8 +658,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { - let (bounds, cov) = stroke_coverage(&stroke); - reject_bounds(CMD, bounds)?; + let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; @@ -695,8 +705,7 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; - let (bounds, cov) = stroke_coverage(&stroke); - reject_bounds(CMD, bounds)?; + let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; @@ -773,12 +782,12 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Rect { - let (bounds, cov) = stroke_coverage(stroke); +fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { + let (bounds, cov) = coverage("paint.spotHealing", stroke)?; let size = stroke.brush.size; let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); if region_rect.is_empty() { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; @@ -795,7 +804,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); if !hole.iter().any(|h| *h) { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels // carry synthesised values that differ from the untouched surroundings: that mismatch is what the @@ -819,7 +828,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); - apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal) + Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) } fn spot_healing(s: &mut Session, p: &Value) -> Result { @@ -833,7 +842,7 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock)))?; + let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; Ok(json!({ "damage": damage_json(dmg) })) } diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 3c90c6d2a4..b4b71745ae 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -8,12 +8,12 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::Pattern; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_bounds, reject_huge_stroke}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; use crate::{Result, Session}; const STREAM_PHASE_X: u64 = 101; @@ -150,8 +150,7 @@ fn paint_aligned( mode: BlendMode, cmd: &str, ) -> Result { - let (bounds, cov) = stroke_coverage(stroke); - reject_bounds(cmd, bounds)?; + let (bounds, cov) = super::coverage(cmd, stroke)?; if bounds.is_empty() { return Ok(Rect::EMPTY); } @@ -259,9 +258,9 @@ mod tests { #[test] fn a_diagonal_across_a_24_mp_canvas_is_not_too_large() { - assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0)).is_ok()); - assert!(reject_bounds("t", Rect::from_xywh(-60, -60, 6136, 4120)).is_ok()); + let across = stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0); + assert!(reject_huge_stroke("t", &across).is_ok()); + assert!(super::super::coverage("t", &across).is_ok(), "the rendered extent fits too"); assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (9000.0, 9000.0)], 100.0)).is_err()); - assert!(reject_bounds("t", Rect::from_xywh(0, 0, 9000, 9000)).is_err()); } } diff --git a/crates/engine/src/retouch_cmds/remove.rs b/crates/engine/src/retouch_cmds/remove.rs index 55c53d3af7..d298e8f02c 100644 --- a/crates/engine/src/retouch_cmds/remove.rs +++ b/crates/engine/src/retouch_cmds/remove.rs @@ -117,12 +117,12 @@ fn plan(s: &Session, p: &Value) -> Result { /// The hole over the plan's window: the strokes' footprints, their enclosed areas and a margin, /// limited to the selection. Empty when nothing would change. -fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { +fn hole(plan: &Plan, sel: Option<&Surface>) -> Result> { let win = plan.window; let (w, h) = (win.width() as usize, win.height() as usize); let mut m = vec![false; w * h]; for stroke in &plan.strokes { - let (bounds, cov) = stroke_coverage(stroke); + let (bounds, cov) = super::coverage(CMD, stroke)?; let bw = bounds.width() as usize; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32); @@ -143,13 +143,13 @@ fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { *v &= c > 0.0; } } - m + Ok(m) } /// The filled window and the hole it fills, computed on `doc` (a snapshot). `None` when the /// layer's own window is fully transparent: nothing to remove and nothing to fill from. fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result)>> { - let hole = hole(plan, doc.selection.as_ref()); + let hole = hole(plan, doc.selection.as_ref())?; if !hole.iter().any(|m| *m) { return Err(bad(CMD, "the stroke doesn't cover anything that can change (check the selection)")); } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 35f6e81efd..edbf33c317 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -566,23 +566,24 @@ fn history_brush_refuses_a_stroke_too_large_to_render() { } /// #6110: Pattern Stamp already refused a stroke it could not render; every other retouch stroke -/// reached the same dense conversion unguarded. Each command refuses the oversized stroke and -/// leaves the pixels alone, and a stroke across the whole canvas still works. +/// reached the same dense conversion unguarded. Each command refuses the oversized stroke without +/// touching the pixels or the history, and an ordinary stroke still succeeds. #[test] fn every_retouch_stroke_refuses_a_box_it_cannot_render() { let huge = json!([[0, 0], [900_000, 900_000]]); - let ok = json!([[0, 0], [40, 20]]); - let cases: [(&str, Value); 5] = [ + let ok = json!([[2, 2], [30, 16]]); + // Fixtures that are valid for a plain RGB document with one pixel layer. + let cases: [(&str, Value); 4] = [ ("paint.cloneStamp", json!({"offset": [4, 0], "size": 8, "hardness": 100})), ("paint.healingBrush", json!({"offset": [4, 0], "size": 8, "hardness": 100})), ("paint.spotHealing", json!({"size": 8, "hardness": 100})), ("paint.historyBrush", json!({"size": 8, "hardness": 100})), - ("paint.patternStamp", json!({"size": 8, "hardness": 100})), ]; for (cmd, base) in cases { let mut s = session(40, 20, 8, "rgb"); s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); let before = rgba(&s, 20, 10); + let steps = s.active().unwrap().history.past_len(); let with = |points: &Value| { let mut v = base.clone(); if let Some(o) = v.as_object_mut() { @@ -593,9 +594,11 @@ fn every_retouch_stroke_refuses_a_box_it_cannot_render() { let err = s.execute(cmd, with(&huge)).unwrap_err().to_string(); assert!(err.contains("stroke too large"), "{cmd}: {err}"); assert_eq!(rgba(&s, 20, 10), before, "{cmd}: a refused stroke leaves the pixels alone"); - // The allowance still covers a stroke across the whole canvas. - let across = s.execute(cmd, with(&ok)); - assert!(across.is_ok() || !format!("{across:?}").contains("stroke too large"), "{cmd}: {across:?}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd}: and records no history step"); + + // The allowance still covers ordinary work: this must succeed, not merely avoid the error. + s.execute(cmd, with(&ok)).unwrap_or_else(|e| panic!("{cmd}: an ordinary stroke must work: {e}")); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{cmd}: and records one step"); } // Remove takes a list of strokes; each one is charged as it is parsed. @@ -604,6 +607,33 @@ fn every_retouch_stroke_refuses_a_box_it_cannot_render() { assert!(err.contains("stroke too large"), "paint.remove: {err}"); } +/// The review on #6110: the estimate from the input points is not the bound. Brush dynamics place +/// dabs beyond those points, so the budget is applied to the extent the renderer actually produced, +/// before the dense buffer is allocated. An injected budget makes that observable without asking +/// for a stroke big enough to exhaust anything. +#[test] +fn the_budget_is_applied_to_the_rendered_extent_not_the_input_points() { + use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; + let mut brush = BrushSettings { size: 24.0, hardness: 1.0, ..Default::default() }; + brush.scattering.enabled = true; + brush.scattering.scatter.jitter = 6.0; + let stroke = Stroke { brush, points: vec![StrokePoint::new(10.0, 10.0, 1.0), StrokePoint::new(40.0, 10.0, 1.0)] }; + + // What the renderer really covered, once scattering has placed every dab. + let (bounds, cov) = super::coverage_within("t", &stroke, u64::MAX).unwrap(); + let rendered = u64::from(bounds.width()) * u64::from(bounds.height()); + assert_eq!(cov.len() as u64, rendered, "the dense buffer is exactly the rendered extent"); + + // Scattering reaches past the points plus half the tip, which is what the estimate alone sees. + let points_only = (40.0 - 10.0 + 24.0 + 8.0) * (24.0 + 8.0); + assert!(rendered as f64 > points_only, "scattering expanded the stroke: {rendered} vs {points_only}"); + + // A budget one pixel under what it rendered refuses it; exactly the rendered extent fits. + let err = super::coverage_within("t", &stroke, rendered - 1).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{err}"); + assert!(super::coverage_within("t", &stroke, rendered).is_ok(), "the rendered extent itself fits"); +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 9ef837c0c0..023e04b5ec 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -130,6 +130,24 @@ pub fn stroke_coverage(stroke: &Stroke) -> (Rect, Vec) { r.dense_coverage() } +/// [`stroke_coverage`] that admits the **rendered** extent before it is made dense: `None` when the +/// stroke covers more than `max_pixels`. +/// +/// Rendering itself is tiled, so it holds only the tiles the dabs touch. The bounds are read from +/// those tiles, after tips, dynamics and scattering have placed every dab, so this is the stroke's +/// real extent rather than an estimate from the input points. Only then is the `width × height` +/// buffer allocated, which is what callers need bounded. +pub fn stroke_coverage_within(stroke: &Stroke, max_pixels: u64) -> Option<(Rect, Vec)> { + let mut r = crate::StrokeRenderer::new(&stroke.brush, None, 1.0); + r.push(&stroke.points); + r.finish(); + let b = r.bounds(); + if u64::from(b.width()).saturating_mul(u64::from(b.height())) > max_pixels { + return None; + } + Some(r.dense_coverage()) +} + /// Index of the alpha channel, if the format has one. #[inline] pub fn alpha_index(fmt: &PixelFormat) -> Option { From a008842a853ce81882d2e8b6056df4e99c4a459e Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 17:32:01 -0400 Subject: [PATCH 055/304] =?UTF-8?q?Help=20=E2=80=BA=20Search:=20=E2=86=90?= =?UTF-8?q?=20/=20=E2=86=92=20and=20Space=20edit=20the=20field=20instead?= =?UTF-8?q?=20of=20driving=20the=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a text field in an open menu has the keyboard, Nav::keys leaves ← / → (cursor) and Space (a typed space) to it: ← / → no longer switch Help to Window / File, and Space no longer runs the lit result. ↑ / ↓, ↩ and Esc are unchanged. Follow-up to #5996 / #6030 (letters). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menu_nav.rs | 7 +++++- crates/ui-egui/src/menu_nav_tests.rs | 35 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index 822fcae2bd..879bd4397a 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -336,7 +336,12 @@ impl Nav { let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return }; let level = self.level.min(self.rows.len().saturating_sub(1)); self.level = level; - let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + // A text field in the menu (Help › Search) keeps the keys that edit it: ← / → move its + // cursor and Space types a space. + let typing = ctx.text_edit_focused(); + let press = |k: Key| { + !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) + }; let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned(); let switch = |by: usize| { let n = tops.len(); diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 1643e2765b..659a794b3e 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -265,6 +265,41 @@ fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() { assert!(!is_open(&h.ctx), "shortcuts work again"); } +/// In Help › Search, ← / → move the field's cursor instead of switching to Window / File, and +/// Space types a space instead of running the lit row. +#[test] +fn help_search_keeps_the_arrows_and_space_that_edit_it() { + let mut h = harness((1280.0, 720.0, 1.0)); + open(&mut h, "Help"); + let field = egui::Id::new("help-menu-search").with("field"); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(field), "the search field has the keyboard"); + let text = |h: &Harness<'static, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("help-menu-search"))).unwrap_or_default(); + let typing = |h: &mut Harness<'static, PhotocraftApp>, s: &str| { + h.event(egui::Event::Text(s.to_string())); + h.run_steps(3); + }; + typing(&mut h, "levls"); + key(&mut h, egui::Key::ArrowLeft); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "← keeps Help open"); + key(&mut h, egui::Key::ArrowLeft); + typing(&mut h, "e"); + assert_eq!(text(&h), "levels", "← moved the cursor"); + key(&mut h, egui::Key::ArrowRight); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "→ keeps Help open"); + // The pointer resting on a result lights it; a space typed then is text, not a click. + let levels = h.get_by_label_contains("› Levels").rect().center(); + h.hover_at(levels); + h.run_steps(3); + assert!(Nav::current(&h.ctx).highlighted(0).is_some(), "a result is lit"); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::Text(" ".into())); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert!(is_open(&h.ctx) && h.query_by_label_contains("About PhotoCraft").is_some(), "Space keeps Help open"); + assert!(h.state().ui.dialogs.is_empty(), "Space ran no command: {:?}", h.state().ui.dialogs); + assert_eq!(text(&h), "levels "); +} + /// A level's visible height (its view) and the full height of its rows: equal means no scrolling. fn fits(h: &Harness<'static, PhotocraftApp>, level: usize) -> (f32, f32) { let nav = Nav::current(&h.ctx); From 4258089716133d7776d02a618c5830c143555bde Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:38:01 +0200 Subject: [PATCH 056/304] Replace Color: the sample eyedropper is armed when the dialog opens (#6279) Photoshop's first click on the image after opening Replace Color samples the colour. The dialog now opens (and resets) with its canvas eyedropper active; the pipette button still turns it off and on. --- crates/ui-egui/src/filter_dialog.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..83944a1b03 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -388,6 +388,11 @@ fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map Date: Thu, 15 Oct 2026 23:46:54 +0200 Subject: [PATCH 057/304] Smart filters: an edited filter's dialog previews again (#6324) Re-opening a smart filter dropped the dialog's Preview, since the live preview ran the filter command and would have stacked a second copy. The preview now runs layer.smartFilter.setParams on the proxy, as OK does, at full size and reduced afterwards (the smart object renders again from its source, which the proxy doesn't scale), so it shows the filter replaced in its stack. --- crates/ui-egui/src/canvas.rs | 10 ++++-- crates/ui-egui/src/filter_dialog.rs | 7 +++-- crates/ui-egui/src/smart_ui.rs | 48 +++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..22568ba3f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1704,8 +1704,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e return None; } let cmd = d.fields.get("__command")?.as_str()?.to_string(); - // Previews edit what the command will: a targeted layer mask included (#780). - let mut params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields)); + // Previews edit what the command will: a targeted layer mask included (#780), and a smart + // filter being edited is replaced in its stack rather than stacked again (#6324). + let (cmd, mut params) = if d.fields.contains_key("__smartFilter") { + crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields)) + } else { + let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields)); + (cmd, params) + }; // Select and Mask previews in its view mode; holding X shows the image without one. if crate::select_mask_view::owns(&d.fields) { if crate::select_mask_view::hides_view(&d.fields) { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..80bcd57d23 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -607,6 +607,8 @@ fn uses_angle_dial(command: &str, key: &str) -> bool { /// eyedropper like Photoshop's. While armed, the next click on the image writes the merged /// composite colour into the `color` field (the same sampler the Color Picker uses). const REPLACE_COLOR: &str = "image.adjustments.replaceColor"; +/// What OK on an edited smart filter runs (`smart_ui::confirm_command`); its preview too. +const SMART_FILTER_SET_PARAMS: &str = "layer.smartFilter.setParams"; const COLOR_EYEDROPPER: &str = "__colorEyedropper"; fn color_eyedropper_owns(f: &Map) -> bool { @@ -896,8 +898,9 @@ pub(crate) fn preview_document_for_target( return crate::select_mask_view::preview_document(doc, active, params, k, cancel); } // Warp Text renders the type again from the layer model (font size, position), which a - // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). - let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) }; + // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). So does + // a smart filter being edited: the smart object renders again from its source (#6324). + let (run_k, reduce_k) = if command == "type.warpText" || command == SMART_FILTER_SET_PARAMS { (1, k) } else { (k, 1) }; let proxy = crate::proxy::proxy_document(doc, run_k); let mut s = photocraft_engine::Session::new(); s.set_inline_job_ctx(cancel.cloned()); diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 7f49843c53..722e8fe910 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -314,8 +314,12 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: /// filter (`layer.smartFilter.setParams`, see [`confirm_command`]) instead of adding another. fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, params: &Value) { if let Some(d) = app.ui.dialog_mut(id) { - // The live preview would stack a second copy of the filter on the smart object. - d.fields.remove("__preview"); + // A filter's live preview runs `layer.smartFilter.setParams` on the proxy, as OK does + // (`canvas::ensure_filter_preview`, #6324). An adjustment's previews on the layer and + // would stack a second copy of it on the smart object. + if d.fields.contains_key("__adjust") { + d.fields.remove("__preview"); + } d.fields.insert("__smartFilter".into(), json!({"layer": layer, "index": index})); if let Value::Object(p) = params { for (k, v) in p { @@ -452,4 +456,44 @@ mod tests { assert_eq!(sm.smart_filters[0].params.get("brightness").and_then(Value::as_f64), Some(40.0)); assert_eq!(sm.smart_filters[0].params.get("contrast").and_then(Value::as_f64), Some(-10.0)); } + + /// #6324: a smart filter opened for editing previews, and its preview replaces the filter in + /// the stack (as OK does) instead of stacking a second one. + #[test] + fn an_edited_smart_filter_previews_what_ok_gives() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "#ffffff"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.stroke", json!({"width": 4, "color": "#000000", "location": "inside"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1.0})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.background_jobs = false; + let smart = |app: &PhotocraftApp, doc: &Document| match &doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {other:?} ({})", app.ui.status), + }; + let sm = smart(&app, &app.session.active().unwrap().doc); + open_editor(&mut app, &egui::Context::default(), id, &sm, 0); + let d = app.ui.dialogs.last_mut().expect("the filter's dialog opened"); + assert_eq!(d.fields.get("__preview"), Some(&json!(true)), "the dialog previews"); + d.fields.insert("radius".into(), json!(6.0)); + let dialog = d.id; + + crate::canvas::ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + let preview = app.filter_preview.as_ref().unwrap(); + assert_eq!(preview.key.command, "layer.smartFilter.setParams"); + let shown = preview.result.clone().expect("the preview's document"); + let filters = smart(&app, &shown).smart_filters; + assert_eq!(filters.len(), 1, "the edited filter is replaced, not stacked"); + assert_eq!(filters[0].params.get("radius").and_then(Value::as_f64), Some(6.0)); + assert_eq!(smart(&app, &app.session.active().unwrap().doc).smart_filters[0].params["radius"], json!(1.0), "the document is untouched"); + + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(smart(&app, doc).smart_filters.len(), 1); + assert!(photocraft_compose::flatten(&shown).px == photocraft_compose::flatten(doc).px, "OK gives what the preview showed"); + } } From f71eb28f844f8d47c73edf88227137a499cc5088 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:55:58 +0200 Subject: [PATCH 058/304] Replace Color: a Result swatch whose Color Picker sets the sliders (#6278) Photoshop's Replace Color shows the replacement colour under the Hue, Saturation and Lightness sliders; picking one in its Color Picker sets the sliders so the sampled colour becomes it. tone::replace_color_result gives the swatch (the sampled colour after the shift) and tone::replace_color_shift the slider values for a picked colour. --- crates/algo/src/tone.rs | 51 +++++++++++++++++ crates/ui-egui/src/filter_dialog.rs | 85 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 5 files changed, 139 insertions(+) diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 2632d4a29a..5a0f752fac 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: mask } +/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100) +/// shift, as a fully matched pixel of that colour becomes. +pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { + let mut px = [[color[0], color[1], color[2], 1.0]]; + replace_color_masked(&mut px, &[1.0], hue, saturation, lightness); + [px[0][0], px[0][1], px[0][2]] +} + +/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as +/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has +/// no hue to turn, black or white no saturation to give) goes as far as they do. +pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) { + let [fh, fs, fl] = rgb_to_hsl(from); + let [th, ts, tl] = rgb_to_hsl(to); + // Hue turns the short way round; a grey target keeps the hue where it is. + let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 }; + // Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0. + let amount = |from: f32, to: f32| { + if to >= from { + if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 } + } else if from <= 0.0 { + 0.0 + } else { + (to / from - 1.0) * 100.0 + } + }; + (hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0)) +} + /// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask` /// (`0..=1`, one per pixel: the colour match, localized or not) covers it. pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) { @@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) { mod tests { use super::*; + /// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets + /// the sliders that give it. + #[test] + fn replace_color_result_and_shift_round_trip() { + let from = [0.8, 0.2, 0.2]; + let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3); + assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change"); + for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] { + let (h, s, l) = replace_color_shift(from, to); + assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l)); + assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l)); + } + // Red to blue turns the short way (−120°, not +240°). + assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5); + // A grey target: saturation all the way down, the hue left alone. + let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]); + assert_eq!((h, s), (0.0, -100.0)); + // Black has no saturation to raise: the slider stays put rather than dividing by zero. + let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]); + assert!(h.is_finite() && s.is_finite() && l > 0.0); + } + fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> { (0..w * h) .map(|i| { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index fa7cb9524d..f2212efa6c 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -637,6 +637,49 @@ pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) - true } +/// The Color Picker of Replace Color's Result swatch writes here (#6278). +const RESULT_PICKED: &str = "__resultPicked"; +/// The Result the picker opened on: a picked colour that differs from it sets the sliders. +const RESULT_FROM: &str = "__resultFrom"; + +/// Replace Color's Result swatch (#6278), as in Photoshop: the sampled colour after the Hue, +/// Saturation and Lightness shift. Picking a Result in its Color Picker sets the three sliders so +/// the sampled colour becomes it. +fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { + use photocraft_algo::tone; + let t = Tokens::get(ui.ctx()); + let from = f.get("color").and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex).unwrap_or([0.0; 3]); + let picked = f.get(RESULT_PICKED).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex); + if let Some(to) = picked + && f.get(RESULT_PICKED) != f.get(RESULT_FROM) + { + let (hue, saturation, lightness) = tone::replace_color_shift(from, to); + for (key, v) in [("hue", hue), ("saturation", saturation), ("lightness", lightness)] { + f.insert(key.into(), json!(v.round())); + } + f.remove(RESULT_PICKED); + f.remove(RESULT_FROM); + } + let num = |key: &str| f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let rgb = tone::replace_color_result(from, num("hue"), num("saturation"), num("lightness")); + let bytes = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let name = tl!("Result"); + let mut clicked = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), name).clicked(); + ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); + }); + }); + if clicked { + let hex = json!(crate::color_picker_ui::hex(rgb)); + f.insert(RESULT_PICKED.into(), hex.clone()); + f.insert(RESULT_FROM.into(), hex); + crate::color_picker_ui::request_field(f, RESULT_PICKED, &crate::color_picker_ui::title_for(name)); + } +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -832,6 +875,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } } + if cmd == REPLACE_COLOR { + replace_color_result(ui, f); + } // Read-only context the dialog opener supplies (e.g. the monitor profile in use). if let Some(note) = f.get("__note").and_then(Value::as_str) { ui.add_space(4.0); @@ -2383,6 +2429,45 @@ mod tests { assert!(!color_eyedropper_armed(h.state())); } + /// #6278: the Result swatch shows the sampled colour after the shift, and a colour picked for + /// it sets Hue, Saturation and Lightness so the sampled colour becomes it. + #[test] + fn replace_color_result_swatch_sets_the_sliders() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.run("tools.setColors", json!({"foreground": "#cc3333"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + // No shift yet: the Result is the sampled colour. + assert_eq!(h.get_all_by_label("#cc3333").count(), 2, "the sampled colour and the Result both read #cc3333"); + h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Result").click(); + h.run_steps(3); + let picker = h.state().ui.dialogs.last().unwrap(); + assert_ne!(picker.id, id, "the Color Picker opened over the dialog"); + assert_eq!(picker.fields["color"], json!("#cc3333"), "on the current Result"); + let picker = picker.id; + h.state_mut().ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!("#3355cc")); + crate::dialogs::confirm(h.state_mut(), picker).unwrap(); + h.state_mut().ui.dialogs.retain(|d| d.id != picker); + h.run_steps(3); + let f = h.state_mut().ui.dialog_mut(id).unwrap().fields.clone(); + let n = |k: &str| f[k].as_f64().unwrap() as f32; + let got = photocraft_algo::tone::replace_color_result([0.8, 0.2, 0.2], n("hue"), n("saturation"), n("lightness")); + let want = crate::color_picker_ui::parse_hex("#3355cc").unwrap(); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "the sliders turn the sampled colour into the pick: {got:?}"); + assert!(n("hue") != 0.0, "the hue moved"); + // Moving a slider afterwards is not mistaken for another pick. + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("lightness".into(), json!(10.0)); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["lightness"], json!(10.0)); + } + /// #4457: Localized Color Clusters and its Range, as in Photoshop's dialog; the sampled point /// and both settings reach the command. #[test] diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2e12c33799..723d048568 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3374,3 +3374,4 @@ curves Smooth 매끄럽게 Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택 Undo Changes 변경 사항 취소 {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. + Result 결과 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cbf3c9d08f..f02dfc1ff7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2812,3 +2812,4 @@ curves Smooth 平滑 Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它 Undo Changes 撤消更改 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 + Result 结果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 30aa3f5a94..3d82e5b4d3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2797,3 +2797,4 @@ curves Smooth 平滑 Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它 Undo Changes 還原變更 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 + Result 結果 From 8369b4b55cfb1cffd04c02fc8150c19174115435 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:04:50 +0200 Subject: [PATCH 059/304] =?UTF-8?q?File=20=E2=80=BA=20Open:=20a=20file=20t?= =?UTF-8?q?ype=20filter=20per=20format=20(#6337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After All Formats the Open dialog listed only PhotoCraft, OpenRaster, Krita, SVG and Paint.NET. Every format it reads now has its own filter (Photoshop, Affinity, HEIF, PNG, JPEG, TIFF, Portable Bitmap, OpenEXR, Radiance HDR, camera raw, …), and together they cover All Formats exactly. --- apps/photocraft/src/services.rs | 74 +++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9de1c475ea..b65a4b69e1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -36,16 +36,47 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec { extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect() } -/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to -/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens. +/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder +/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of +/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native +/// picker defaults to another filter. +const OPEN_FILTERS: &[(&str, &[&str])] = &[ + ("PhotoCraft", &["pcraft"]), + ("Photoshop", &["psd", "psb", "psdt"]), + ("Affinity", &["af", "afdesign", "afphoto", "afpub"]), + ("Krita", &["kra", "krz"]), + ("OpenRaster", &["ora"]), + ("Paint.NET", &["pdn"]), + ("SVG", SVG_EXTS), + ("PNG", &["png"]), + ("JPEG", &["jpg", "jpeg"]), + ("HEIF", &["heic", "heif", "hif"]), + ("TIFF", &["tif", "tiff"]), + ("WebP", &["webp"]), + ("GIF", &["gif"]), + ("BMP", &["bmp"]), + ("Targa", &["tga"]), + ("ICO", &["ico"]), + ("QOI", &["qoi"]), + ("PCX", &["pcx"]), + ("DDS", &["dds"]), + ("XPM", &["xpm"]), + ("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]), + ("OpenEXR", &["exr"]), + ("Radiance HDR", &["hdr"]), + ("FITS", &["fits", "fit", "fts"]), + ("DPX and Cineon", &["dpx", "cin"]), + ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]), + ("Brushes", &["abr"]), + ("Gradients", &["grd"]), + ("Swatches", &["aco", "ase"]), + ("Keyboard Shortcuts", &["kys"]), +]; + fn open_filters() -> Vec<(&'static str, Vec)> { - vec![ - ("All Formats", open_filter_extensions(OPEN_EXTS)), - ("PhotoCraft", open_filter_extensions(&["pcraft"])), - ("OpenRaster", open_filter_extensions(&["ora"])), - ("Krita", open_filter_extensions(&["kra", "krz"])), - ("SVG", open_filter_extensions(SVG_EXTS)), - ] + std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS))) + .chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts)))) + .collect() } /// File › Save As formats: (filter name, extensions). The filter matching the suggested name's @@ -127,10 +158,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, let dialog = if let Some(exts) = extensions { dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::>())) } else { - open_filters() - .into_iter() - .fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) - .add_filter("Paint.NET", &open_filter_extensions(&["pdn"])) + open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) }; if multiple { let picked = dialog.pick_files(); @@ -619,6 +647,26 @@ mod tests { assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz"))); } + /// #6337: after All Formats, every format File › Open reads has its own filter, and no filter + /// offers an extension All Formats doesn't. + #[test] + fn every_open_format_has_its_own_filter() { + let filters = open_filters(); + assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first"); + let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect(); + let total = named.len(); + named.sort_unstable(); + named.dedup(); + assert_eq!(named.len(), total, "an extension is listed under two filters"); + let mut all = OPEN_EXTS.to_vec(); + all.sort_unstable(); + assert_eq!(named, all, "the named filters cover All Formats exactly"); + let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect(); + for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] { + assert!(names.contains(&name), "{name}"); + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] #[test] fn open_filters_keep_literal_extensions_on_windows_and_macos() { From 8176b613ca2a95cb8afc6a535bd4ae8ff532c64f Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:15:50 +0200 Subject: [PATCH 060/304] Smart filters: the dialog's preview window shows the edited filter too (#6324) The filter dialog's own preview window matched the canvas preview by the filter's command, but an edited smart filter previews as layer.smartFilter.setParams, so the window kept the unfiltered image. smart_ui::preview_command names the command the preview runs. --- crates/ui-egui/src/filter_preview_ui.rs | 4 +++- crates/ui-egui/src/smart_ui.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 90eaf4f6fe..66499ab9f0 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -43,7 +43,9 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu // decides whether the filtered result (rather than the original) is shown. let preview_on = f.get("__preview").and_then(Value::as_bool) == Some(true); let filtered: Option<(FilterPreviewKey, std::sync::Arc)> = app.filter_preview.as_ref().and_then(|p| { - (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == cmd).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() + // An edited smart filter previews as `layer.smartFilter.setParams` (#6324). + let previewed = crate::smart_ui::preview_command(f, &cmd); + (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == previewed).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() }); // The proxy factor the preview runs at (the filtered key's when it exists, else the request's). let base_k = filtered.as_ref().map(|(k, _)| k.k).unwrap_or_else(|| { diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 722e8fe910..2acc77ceaf 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -331,6 +331,12 @@ fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, param } } +/// The command a filter dialog's live preview runs ([`confirm_command`]'s): `layer.smartFilter.setParams` +/// while it edits a smart filter (#6324), else `command`. +pub fn preview_command<'a>(fields: &serde_json::Map, command: &'a str) -> &'a str { + if fields.contains_key("__smartFilter") { "layer.smartFilter.setParams" } else { command } +} + /// The command a confirmed filter dialog runs: smart-filter edits update the recorded filter. pub fn confirm_command(fields: &serde_json::Map, command: String, params: Value) -> (String, Value) { match fields.get("__smartFilter") { @@ -485,6 +491,10 @@ mod tests { crate::canvas::ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); let preview = app.filter_preview.as_ref().unwrap(); assert_eq!(preview.key.command, "layer.smartFilter.setParams"); + // The dialog's own preview window matches it to the dialog by that command. + let fields = &app.ui.dialogs.last().unwrap().fields; + assert_eq!(preview_command(fields, "filter.blur.gaussianBlur"), preview.key.command); + assert_eq!(preview_command(&serde_json::Map::new(), "filter.blur.gaussianBlur"), "filter.blur.gaussianBlur"); let shown = preview.result.clone().expect("the preview's document"); let filters = smart(&app, &shown).smart_filters; assert_eq!(filters.len(), 1, "the edited filter is replaced, not stacked"); From c04da11bb3986a8da42642dbbcf272473e0bdfbe Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:16:06 +0200 Subject: [PATCH 061/304] Smart filters: rustfmt --- crates/ui-egui/src/filter_preview_ui.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 66499ab9f0..859a829568 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -45,7 +45,9 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu let filtered: Option<(FilterPreviewKey, std::sync::Arc)> = app.filter_preview.as_ref().and_then(|p| { // An edited smart filter previews as `layer.smartFilter.setParams` (#6324). let previewed = crate::smart_ui::preview_command(f, &cmd); - (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == previewed).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() + (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == previewed) + .then(|| p.result.clone().map(|r| (p.key.clone(), r))) + .flatten() }); // The proxy factor the preview runs at (the filtered key's when it exists, else the request's). let base_k = filtered.as_ref().map(|(k, _)| k.k).unwrap_or_else(|| { From 944d0cf722e504594bc0433454be6d73a304e675 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 18:27:56 -0400 Subject: [PATCH 062/304] Path Selection: the canvas right-click opens the path menu, as the Pen's does (#1056) The Path Selection and Direct Selection tools had no canvas menu, so Make Selection, Fill and Stroke Path, Free Transform Path, Delete Path and the combine and style rows needed the Pen tool or the Paths panel. Like Photoshop, their right-click now opens the path menu for the selected path, with the Pen menu's rows and the same enablement. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas_tool_menu.rs | 44 ++++++++++++++++++++++---- docs/context-menu-parity.md | 4 +-- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index da835a786f..a02859c5cd 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -284,8 +284,14 @@ pub fn applies(tool: Tool) -> bool { | Tool::MagneticLasso | Tool::MagicWand | Tool::ObjectSelection - | Tool::Pen - ) + ) || path_menu(tool) +} + +/// Tools whose canvas right-click opens the path menu ([`PEN_MENU`]): the Pen, and the Path +/// Selection and Direct Selection tools, whose Photoshop menu offers the same whole-path actions +/// for the selected path (#1056). +pub fn path_menu(tool: Tool) -> bool { + matches!(tool, Tool::Pen | Tool::PathSelection | Tool::DirectSelection) } /// Tools whose plain canvas right-click offers the view menu. @@ -310,7 +316,7 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { VIEW_MENU } else if menu.text { TYPE_EDIT_MENU - } else if menu.tool == Tool::Pen { + } else if path_menu(menu.tool) { PEN_MENU } else { selection_rows(menu.has_selection) @@ -471,7 +477,7 @@ pub fn pasteboard_at(app: &PhotocraftApp, tool: Tool, doc: [f64; 2]) -> bool { let Some(st) = app.session.active() else { return false }; let (w, h) = (f64::from(st.doc.size.width), f64::from(st.doc.size.height)); let outside = doc.iter().all(|v| v.is_finite()) && (doc[0] < 0.0 || doc[1] < 0.0 || doc[0] >= w || doc[1] >= h); - outside && tool != Tool::Pen && !crate::paint_mouse::has_brush_picker(tool) && !crate::paint_mouse::right_erases(app, tool) && app.ui.text_edit.is_none() + outside && !path_menu(tool) && !crate::paint_mouse::has_brush_picker(tool) && !crate::paint_mouse::right_erases(app, tool) && app.ui.text_edit.is_none() } /// Right-click on the pasteboard: its colour menu at screen point `pos`. @@ -572,7 +578,7 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; } - if menu.transform || menu.tool != Tool::Pen { + if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } let Some(st) = app.session.active() else { return false }; @@ -699,7 +705,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_spelling(app, &menu, command) } else if menu.text { choose_type(app, ctx, command) - } else if menu.tool == Tool::Pen && !menu.transform { + } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) } else if command == "select.toWorkPath" { // Photoshop's Make Work Path… asks for the tolerance first. @@ -1492,6 +1498,32 @@ mod tests { assert!(app.session.active().unwrap().doc.selection.is_some()); } + /// #1056: the Path Selection and Direct Selection tools' right-click opens the path menu for + /// the selected path, as the Pen's does, instead of nothing. + #[test] + fn path_selection_tools_open_the_path_menu() { + for tool in [Tool::PathSelection, Tool::DirectSelection] { + let mut app = app(); + app.ui.tool = Tool::Pen; + for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { + crate::vector_ui::pen_down(&mut app, x, y, false); + crate::vector_ui::pen_up(&mut app); + } + crate::vector_ui::pen_commit(&mut app, false); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + app.ui.tool = tool; + assert!(applies(tool)); + assert!(open(&mut app, &Context::default(), tool, [10.0, 10.0]), "{tool:?}"); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert_eq!(rows(&menu), PEN_MENU, "{tool:?}"); + assert!(menu.has_path && entry_enabled(&app, &menu, "path.toSelection"), "{tool:?}"); + choose(&mut app, &Context::default(), "path.toSelection"); + let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some(), "{tool:?}: Make Selection made one"); + } + } + #[test] fn pen_menu_has_all_reference_rows_in_order_and_guards_unavailable_actions() { let labels: Vec<_> = PEN_MENU.iter().map(|row| row.map(|(label, _)| label)).collect(); diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 76d6724b7e..1e8abf9a67 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (layer-mask thumbnail menu, #4575) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** minor (Path Selection canvas menu, #1056) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -29,7 +29,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | | Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | -| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas | +| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. The Path Selection and Direct Selection tools open the same path menu for the selected path (#1056, 2026-10-15); node- and segment-specific rows remain open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | | Slice, Slice Select | Slice properties, duplicate/delete, divide, guides **(inferred context placement)** | No tool menu | P2 | | Any tool while Free Transform is active | Scale, Rotate, Skew, Distort, Perspective, Warp, rotate/flip variants, commit/cancel **(inferred exact context placement)** | Missing per docs/scorecard.md TOOL-214-5 | P0; transform operations [documented by Adobe](https://helpx.adobe.com/photoshop/desktop/crop-resize-transform/transform-manipulate-reshape/adjust-scale-rotation-and-perspective.html); type restrictions [documented here](https://helpx.adobe.com/photoshop/using/creating-type.html) | From a76d3295ba2b2d22d94f87fe2a2d3a5205cdc041 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:38:48 +0200 Subject: [PATCH 063/304] Type: the text caret shows on a white background (part of #6085) In the Photoshop themes the caret was a 1.5 pt white line with a 0.75 pt black hairline inside, so on a white background only a faint grey line showed. It is now a solid 1 pt black line inside a 3 pt white halo, readable on any background, in every theme. --- crates/ui-egui/src/type_tool.rs | 11 +++++-- crates/ui-egui/src/type_tool_tests.rs | 43 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..d85964ed62 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1088,13 +1088,18 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if ed.dragging || (time * 1000.0 / 530.0) as i64 % 2 == 0 { let (x, top, bot) = l.caret(a); let (x, top, bot) = if l.lines.is_empty() { (0.0, -(12.0 * l.px_per_pt.max(1.0)), 3.0) } else { (x, top, bot) }; - let c = if crate::theme::Tokens::get(painter.ctx()).pro { Color32::WHITE } else { Color32::BLACK }; - painter.line_segment([scr(x, top), scr(x, bot)], Stroke::new(1.5, c)); - painter.line_segment([scr(x, top), scr(x, bot)], Stroke::new(0.75, Color32::BLACK)); + for stroke in CARET_STROKES { + painter.line_segment([scr(x, top), scr(x, bot)], stroke); + } } } } +/// The text caret, drawn in order: a white halo, then a solid black line inside it, so it reads on +/// any background (#6085). The black line used to be a 0.75 pt hairline inside a white one, which +/// on a white background left only a faint grey line. +const CARET_STROKES: [Stroke; 2] = [Stroke { width: 3.0, color: Color32::WHITE }, Stroke { width: 1.0, color: Color32::BLACK }]; + /// The Type Mask tools while typing: Quick Mask's colour over the canvas with the glyphs clear, /// as Photoshop shows the selection-to-be. The glyphs come from the hidden working layer's pixels. fn draw_mask_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, id: LayerId) { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index c7b54c8871..d27fdbcdac 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -2065,3 +2065,46 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); } + +/// #6085: the text caret is a solid black line inside a white halo, so it shows on a white +/// background (the black used to be a 0.75 pt hairline inside a white line) and on a dark one. +#[test] +fn the_caret_reads_on_white_and_on_black() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = new_app(); + app.ui.tool = crate::state::Tool::Type; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + // A caret, not the placeholder's selection; solid, whatever the blink (as while dragging). + let ed = app.ui.text_edit.as_mut().expect("editing"); + ed.anchor = ed.caret; + ed.dragging = true; + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 100.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut h = + Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + // Vertical line segments are the caret (the frame and underline run sideways). + let caret: Vec<(f32, egui::Color32)> = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], stroke } if (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() > 1.0 => Some((stroke.width, stroke.color)), + _ => None, + }) + .collect(); + assert_eq!(caret.len(), 2, "a halo and a line: {caret:?}"); + let ((halo_w, halo), (line_w, line)) = (caret[0], caret[1]); + assert_eq!(line, egui::Color32::BLACK); + assert!(line_w >= 1.0, "a solid black line, not a hairline: {line_w}"); + assert_eq!(halo, egui::Color32::WHITE); + assert!(halo_w >= line_w + 2.0, "at least 1 pt of white on each side: {halo_w}"); +} From 8237d76170a15027e1795ba6b1c6d9de9fcdab9b Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 19:28:58 -0400 Subject: [PATCH 064/304] Help search test: take the first Levels result and move the cursor to the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "levels" now finds two results (Image › Adjustments › Levels… and the Levels adjustment layer), and one → only reaches level|s, so the test needs a second → before typing the space. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menu_nav_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 659a794b3e..ebe89db424 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -284,10 +284,13 @@ fn help_search_keeps_the_arrows_and_space_that_edit_it() { key(&mut h, egui::Key::ArrowLeft); typing(&mut h, "e"); assert_eq!(text(&h), "levels", "← moved the cursor"); + // The cursor sits at `leve|ls`: two → take it to the end. key(&mut h, egui::Key::ArrowRight); assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "→ keeps Help open"); + key(&mut h, egui::Key::ArrowRight); // The pointer resting on a result lights it; a space typed then is text, not a click. - let levels = h.get_by_label_contains("› Levels").rect().center(); + // "levels" finds both Image › Adjustments › Levels… and the Levels adjustment layer. + let levels = h.get_all_by_label_contains("› Levels").next().expect("a Levels result").rect().center(); h.hover_at(levels); h.run_steps(3); assert!(Nav::current(&h.ctx).highlighted(0).is_some(), "a result is lit"); From dedf89feae2df0636a1cad6c06e6761ed46f3442 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:33:24 +0200 Subject: [PATCH 065/304] Type: move the caret test next to its helper, away from the file's end --- crates/ui-egui/src/type_tool_tests.rs | 86 +++++++++++++-------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index d27fdbcdac..1202ba28fa 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -115,6 +115,49 @@ fn new_app() -> PhotocraftApp { app } +/// #6085: the text caret is a solid black line inside a white halo, so it shows on a white +/// background (the black used to be a 0.75 pt hairline inside a white line) and on a dark one. +#[test] +fn the_caret_reads_on_white_and_on_black() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = new_app(); + app.ui.tool = crate::state::Tool::Type; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + // A caret, not the placeholder's selection; solid, whatever the blink (as while dragging). + let ed = app.ui.text_edit.as_mut().expect("editing"); + ed.anchor = ed.caret; + ed.dragging = true; + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 100.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut h = + Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + // Vertical line segments are the caret (the frame and underline run sideways). + let caret: Vec<(f32, egui::Color32)> = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], stroke } if (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() > 1.0 => Some((stroke.width, stroke.color)), + _ => None, + }) + .collect(); + assert_eq!(caret.len(), 2, "a halo and a line: {caret:?}"); + let ((halo_w, halo), (line_w, line)) = (caret[0], caret[1]); + assert_eq!(line, egui::Color32::BLACK); + assert!(line_w >= 1.0, "a solid black line, not a hairline: {line_w}"); + assert_eq!(halo, egui::Color32::WHITE); + assert!(halo_w >= line_w + 2.0, "at least 1 pt of white on each side: {halo_w}"); +} + fn xf(app: &PhotocraftApp) -> ViewXform { let v = &app.ui.views[0]; ViewXform { @@ -2065,46 +2108,3 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); } - -/// #6085: the text caret is a solid black line inside a white halo, so it shows on a white -/// background (the black used to be a 0.75 pt hairline inside a white line) and on a dark one. -#[test] -fn the_caret_reads_on_white_and_on_black() { - use crate::canvas::{ToolEvent, tool_event}; - let mut app = new_app(); - app.ui.tool = crate::state::Tool::Type; - tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); - tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); - // A caret, not the placeholder's selection; solid, whatever the blink (as while dragging). - let ed = app.ui.text_edit.as_mut().expect("editing"); - ed.anchor = ed.caret; - ed.dragging = true; - let xf = ViewXform { - rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), - zoom: 1.0, - center: [100.0, 100.0], - flip: false, - rotation: 0.0, - aspect: 1.0, - }; - let mut h = - Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); - h.run(); - // Vertical line segments are the caret (the frame and underline run sideways). - let caret: Vec<(f32, egui::Color32)> = h - .output() - .shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::LineSegment { points: [a, b], stroke } if (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() > 1.0 => Some((stroke.width, stroke.color)), - _ => None, - }) - .collect(); - assert_eq!(caret.len(), 2, "a halo and a line: {caret:?}"); - let ((halo_w, halo), (line_w, line)) = (caret[0], caret[1]); - assert_eq!(line, egui::Color32::BLACK); - assert!(line_w >= 1.0, "a solid black line, not a hairline: {line_w}"); - assert_eq!(halo, egui::Color32::WHITE); - assert!(halo_w >= line_w + 2.0, "at least 1 pt of white on each side: {halo_w}"); -} From cf3d26881d77d9e92e1def2e7d713da41095bed0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 20:41:08 -0400 Subject: [PATCH 066/304] Windows: start without Windows.Data.Pdf.dll; delay-load it for printing (#5884) winprint's PdfCreateRenderer made photocraft.exe and photocraft-cli.exe import Windows.Data.Pdf.dll at load time, so Windows images without the PDF component (NTLite-stripped, some N/LTSC builds) refused to start: "windows.data.pdf.dll was not found". Both apps now delay-load it, and PDF printing checks the DLL is installed first and reports an error instead of aborting. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-cli/build.rs | 8 ++++++++ apps/photocraft/build.rs | 8 ++++++++ crates/engine/src/print_cmds.rs | 20 ++++++++++++++++++++ crates/engine/src/print_cmds/tests.rs | 10 ++++++++++ 4 files changed, 46 insertions(+) diff --git a/apps/photocraft-cli/build.rs b/apps/photocraft-cli/build.rs index c757a4a7c5..3951c46b81 100644 --- a/apps/photocraft-cli/build.rs +++ b/apps/photocraft-cli/build.rs @@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return Ok(()); } + // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which + // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe + // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; + // the print command checks for it first and reports an error instead. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll"); + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); // FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left // to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as diff --git a/apps/photocraft/build.rs b/apps/photocraft/build.rs index cbba622589..48abc9f1a5 100644 --- a/apps/photocraft/build.rs +++ b/apps/photocraft/build.rs @@ -11,6 +11,14 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which + // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe + // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; + // the print command checks for it first and reports an error instead. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll"); + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set_icon("../../assets/app-icon/photocraft.ico") .set("ProductName", "PhotoCraft") diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..69f4505535 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -427,6 +427,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let mut spool = Value::Null; if send && !dry { require_printing_enabled()?; + require_windows_pdf()?; spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } @@ -435,6 +436,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { ) } +/// Windows prints a PDF through Windows.Data.Pdf.dll, which the apps delay-load (see their +/// `build.rs`) so a Windows image without it still starts. Calling into it when it is missing +/// would abort the process, so check that it is installed first (#5884). +fn require_windows_pdf() -> Result<()> { + if !cfg!(windows) { + return Ok(()); + } + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows")); + if windows_pdf_installed(std::path::Path::new(&root)) { + Ok(()) + } else { + Err(other("printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.")) + } +} + +fn windows_pdf_installed(system_root: &std::path::Path) -> bool { + system_root.join("System32").join("Windows.Data.Pdf.dll").is_file() +} + /// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently /// draws RGB/gray and vector marks only; it must never silently omit requested page content. fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..677fdec6ba 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -434,3 +434,13 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +#[test] +fn windows_pdf_component_is_detected_by_its_dll() { + // #5884: a Windows image without Windows.Data.Pdf.dll reports an error instead of aborting. + let dir = tempfile::tempdir().unwrap(); + assert!(!windows_pdf_installed(dir.path())); + std::fs::create_dir(dir.path().join("System32")).unwrap(); + std::fs::write(dir.path().join("System32").join("Windows.Data.Pdf.dll"), b"MZ").unwrap(); + assert!(windows_pdf_installed(dir.path())); +} From 0ce7e00fee45c2db22ede12184bb6a1fd3203777 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 03:06:07 +0200 Subject: [PATCH 067/304] Brush: the Dual Brush tip keeps its proportion at tiny main sizes (#4809) Photoshop scales the Dual Brush diameter by the main size ratio, unrounded, with a 5000 px cap and no lower floor: main 100 / dual 20 resized to 1 px gives a 0.2 px dual tip, and back at 100 px it is 20 px again. PhotoCraft already scaled the dual tip (#4856) but floored it at 0.5 px, so a trip to 1 px and back left a 50 px dual tip. Drop the floor (rendering bounds the diameter itself), keep the cap, and leave a non-positive or NaN result alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/brush_cmds/tests.rs | 51 +++++++++++++++++++++++++++ crates/paint/src/brush.rs | 12 ++++++- crates/paint/src/brush_tests.rs | 48 +++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index eaf69281bc..0e34997bb4 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -371,6 +371,57 @@ fn resizing_a_brush_resizes_its_enabled_secondary_tip() { assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0)); } +/// Every way of changing the main size scales the Dual Brush tip as Photoshop does (#4809): `[` / +/// `]`, the options bar / Brushes panel / Alt+right-drag (`tools.setBrush` with only a size), down +/// to 1 px and back without losing the proportion to a floor. +#[test] +fn every_size_change_scales_the_dual_tip_and_keeps_its_proportion() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + let sizes = |s: &Session| (s.tools.brush.size, s.tools.brush.dual_brush.size); + s.execute("tools.decreaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (80.0, 16.0)); + s.execute("tools.increaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (100.0, 20.0)); + // A coalesced drag (options bar slider, Alt+right-drag) is a run of size-only patches. + for size in [60, 10, 1] { + s.execute("tools.setBrush", json!({"brush": {"size": size}, "coalesce": "drag"})).unwrap(); + } + assert_eq!(s.tools.brush.size, 1.0); + assert!((s.tools.brush.dual_brush.size - 0.2).abs() < 1e-5, "no 0.5 px floor: {}", s.tools.brush.dual_brush.size); + s.execute("tools.setBrush", json!({"brush": {"size": 100}})).unwrap(); + assert_eq!(s.tools.brush.size, 100.0); + assert!((s.tools.brush.dual_brush.size - 20.0).abs() < 1e-3, "back to 20 px: {}", s.tools.brush.dual_brush.size); + // Painting with the sub-pixel dual tip works (the renderer bounds it). + s.execute("tools.setBrush", json!({"brush": {"size": 1}})).unwrap(); + s.execute("paint.stroke", json!({"points": [[4, 16], [28, 16]]})).unwrap(); +} + +/// Picking a preset that captured its size sets both diameters from the preset: the dual tip is +/// not scaled on top of that. +#[test] +fn a_captured_preset_size_is_not_scaled_twice() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual40"})).unwrap(); + s.execute("tools.setBrush", json!({"size": 200})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 50.0); + s.execute("tools.setBrush", json!({"preset": "Dual40"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 10.0)); +} + +#[test] +fn bad_sizes_fail_and_leave_the_dual_tip_alone() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + for bad in [json!(1e30), json!("big"), json!([1]), json!({"x": 1}), json!(null)] { + let _ = s.execute("tools.setBrush", json!({"brush": {"size": bad}})); + assert_eq!(s.tools.brush.dual_brush.size, 20.0, "{bad}"); + assert_eq!(s.tools.brush.size, 100.0, "{bad}"); + } + assert!(s.execute("tools.setBrush", json!({"brush": {"size": 1e30}})).is_err()); +} + #[test] fn an_uncaptured_preset_size_keeps_dual_brush_proportions() { let mut s = session(32, 32); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index d60412a58d..3943073855 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -589,9 +589,19 @@ impl Default for BrushSettings { impl BrushSettings { /// Resize the primary tip and keep an enabled Dual Brush tip at the same relative size. /// An explicit Dual Brush size edit should bypass this and retain the requested diameter. + /// + /// As in Photoshop, the dual diameter is multiplied by the size ratio unrounded and only capped + /// at the top ([`MAX_BRUSH_SIZE`]): a 100 px brush with a 20 px dual tip shrunk to 1 px has a + /// 0.2 px dual tip and grows back to exactly 20 px. A sub-pixel floor here would lose the + /// proportion on the way back up (#4809); rendering bounds the diameter itself + /// ([`Self::bounded_for_render`]). pub fn resize_with_dual_brush(&mut self, new_size: f32) { if self.dual_brush.enabled && self.size.is_finite() && self.size > 0.0 && new_size.is_finite() && new_size > 0.0 { - self.dual_brush.size = (self.dual_brush.size * (new_size / self.size)).clamp(0.5, MAX_BRUSH_SIZE); + let scaled = self.dual_brush.size * (new_size / self.size); + // NaN fails the comparison; infinity is capped. + if scaled > 0.0 { + self.dual_brush.size = scaled.min(MAX_BRUSH_SIZE); + } } self.size = new_size; } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index d0d7de4110..051b3652f8 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1493,3 +1493,51 @@ fn an_aliased_stroke_at_partial_opacity_is_one_flat_colour() { assert_eq!(s.rgba(x, 5), first, "pixel {x} of the Pencil line"); } } + +/// Photoshop (measured): a 100 px brush with a 20 px Dual Brush tip resized to 50 / 2 / 1 px has a +/// 10 / 0.4 / 0.2 px dual tip, unrounded and with no floor, and back at 100 px it is 20 px again; past +/// 5000 px the dual tip is capped there and the proportion is lost (#4809). +#[test] +fn resizing_scales_the_dual_tip_like_photoshop() { + let mut b = BrushSettings { size: 100.0, ..brush() }; + b.dual_brush.enabled = true; + b.dual_brush.size = 20.0; + for (main, dual) in [(50.0, 10.0), (2.0, 0.4), (1.0, 0.2), (100.0, 20.0), (240.0, 48.0), (100.0, 20.0)] { + b.resize_with_dual_brush(main); + assert_eq!(b.size, main); + assert!((b.dual_brush.size - dual).abs() < 1e-4, "main {main}: dual {} != {dual}", b.dual_brush.size); + } + // The cap: 300 px at 100 px would be 15000 px at 5000 px; Photoshop stores 5000 and scales from it. + b.dual_brush.size = 300.0; + b.resize_with_dual_brush(MAX_BRUSH_SIZE); + assert_eq!(b.dual_brush.size, MAX_BRUSH_SIZE); + b.resize_with_dual_brush(100.0); + assert_eq!(b.dual_brush.size, 100.0); + // A sub-pixel dual tip renders at the renderer's smallest diameter. + b.dual_brush.size = 20.0; + b.resize_with_dual_brush(1.0); + assert_eq!(b.bounded_for_render().dual_brush.size, 0.5); +} + +#[test] +fn resizing_with_bad_sizes_leaves_the_dual_tip_alone() { + let mut b = BrushSettings { size: 100.0, ..brush() }; + b.dual_brush.enabled = true; + b.dual_brush.size = 20.0; + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -5.0] { + b.size = 100.0; + b.resize_with_dual_brush(bad); + assert_eq!(b.dual_brush.size, 20.0, "{bad}"); + } + for bad_dual in [f32::NAN, 0.0, -3.0] { + b.size = 100.0; + b.dual_brush.size = bad_dual; + b.resize_with_dual_brush(50.0); + assert_eq!(b.dual_brush.size.to_bits(), bad_dual.to_bits(), "a bad dual size {bad_dual} is left as it was"); + } + // A disabled dual tip keeps its size. + b.dual_brush = DualBrush { enabled: false, size: 20.0, ..Default::default() }; + b.size = 100.0; + b.resize_with_dual_brush(50.0); + assert_eq!(b.dual_brush.size, 20.0); +} From f22d08da8c55dee9885f83e6ace2756fd358045d Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:32:47 +0800 Subject: [PATCH 068/304] style: apply rustfmt to upstream smart-filter regression --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From 7bc556ec02aae434f0a83ff2efc16d48c3311e24 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 21:54:09 -0400 Subject: [PATCH 069/304] Web: a requested download no longer marks the document saved (#4962) The web writer only triggers an anchor download, which the user can cancel and which reports nothing back, yet Save treated it as a confirmed write: the document was marked clean and the leave-page guard disarmed. Services gains `write_unconfirmed` (set by the web shell); a save through such a writer names the document but keeps its changes unsaved. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 2 ++ crates/ui-egui/src/lib.rs | 14 +++++++++++++- crates/ui-egui/src/save_identity_tests.rs | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index eb350d17eb..7084396cbe 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -379,6 +379,8 @@ fn services(inbox: Inbox) -> Services { } })), write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))), + // A download may be cancelled and never reports back: it doesn't save the document. + write_unconfirmed: true, encode_png: Some(Box::new(|w, h, rgba| { let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?; photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..4802fbe91a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -354,6 +354,13 @@ pub type WriteFn = Box Result<(), String>>; /// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails. pub type SaveFileFn = std::sync::Arc Result, String> + Send + Sync>; +/// The revision a document counts as saved at after `written` went to its writer: that one when +/// the writer confirms the bytes were stored, else still `previous`, so the document stays +/// unsaved. A browser download is only a request the user can cancel (#4962). +pub(crate) fn saved_revision_after_write(confirmed: bool, written: u64, previous: u64) -> u64 { + if confirmed { written } else { previous } +} + /// Who a finished save serves. #[derive(Clone, Copy)] pub(crate) enum SaveKind { @@ -446,6 +453,10 @@ pub struct Services { pub file_dialog: Option, /// Write bytes to a path (native) or trigger a download (web). pub write: Option, + /// `write` only asks for the bytes to be kept (a browser download, which the user can + /// cancel and which reports nothing back), so a save through it names the document but + /// leaves its changes unsaved and the leave-page guard armed (#4962). + pub write_unconfirmed: bool, /// Encode and write in one step on a worker thread, so a large save doesn't freeze the /// window (#2017). With background jobs on, saves use it instead of `export` and `write`. pub save_file: Option, @@ -1592,11 +1603,12 @@ impl PhotocraftApp { return jobs_ui::start_save(self, path, settings.clone(), copy, save, false); } let st = self.session.active().ok_or("no document")?; - let (doc, revision) = (st.doc.id, st.revision); + let (doc, written, previous) = (st.doc.id, st.revision, st.saved_revision); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&st.doc, &path, settings)?; let write = self.services.write.as_mut().ok_or("no writer configured")?; write(&path, &bytes)?; + let revision = saved_revision_after_write(!self.services.write_unconfirmed, written, previous); self.saved(doc, revision, &path, &warnings, copy); Ok(Some((path, warnings))) } diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index c947afc9b6..3396dfcdd5 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -254,3 +254,21 @@ fn save_settings_start_at_the_opened_files_encoding() { app.open_bytes("art.webp", &lossless).unwrap(); assert!(app.save_settings().webp_lossless); } + +/// A browser download is only requested: the document takes the name but keeps its unsaved +/// changes, so the leave-page guard stays armed (#4962). A confirmed write still saves it. +#[test] +fn unconfirmed_download_names_the_document_but_keeps_it_unsaved() { + assert_eq!(crate::saved_revision_after_write(true, 7, 3), 7); + assert_eq!(crate::saved_revision_after_write(false, 7, 3), 3); + let mut app = app(); + app.services.write_unconfirmed = true; + let (_, _, revision, saved) = identity(&app); + app.save_as(Some("download.psd".into())).unwrap(); + assert_eq!(identity(&app), ("download.psd".into(), Some("download.psd".into()), revision, saved)); + assert!(app.session.active().unwrap().is_dirty()); + assert!(app.has_unsaved_work()); + app.services.write_unconfirmed = false; + app.save_as(Some("download.psd".into())).unwrap(); + assert!(!app.has_unsaved_work()); +} From 76f29f67d55f3554c25d47125145cefeb0e9a030 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:16:03 -0400 Subject: [PATCH 070/304] rustfmt: format after merging main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From c972acf98fb455c28eac2971ec532a19fe7a1352 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:16:12 -0400 Subject: [PATCH 071/304] rustfmt: format after merging main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- crates/ui-egui/src/menu_nav.rs | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index 879bd4397a..c9aedd77d7 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -339,9 +339,7 @@ impl Nav { // A text field in the menu (Help › Search) keeps the keys that edit it: ← / → move its // cursor and Space types a space. let typing = ctx.text_edit_focused(); - let press = |k: Key| { - !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) - }; + let press = |k: Key| !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned(); let switch = |by: usize| { let n = tops.len(); From 5f8d92f9f02ef896d9c49381eb368a7a435be5f0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:27:09 -0400 Subject: [PATCH 072/304] rustfmt; merge main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- crates/engine/src/print_cmds.rs | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 69f4505535..88ed6c7883 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -447,7 +447,9 @@ fn require_windows_pdf() -> Result<()> { if windows_pdf_installed(std::path::Path::new(&root)) { Ok(()) } else { - Err(other("printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.")) + Err(other( + "printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.", + )) } } From 5c0e40784a1056a6dfcda902e44c3e0242bf0464 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:29:42 -0400 Subject: [PATCH 073/304] rustfmt; merge main (CI Format step) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/smart_filter_authorization.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From d8a23d3ec38aa6666c434265c7416aa71fcd3470 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 07:30:48 +0200 Subject: [PATCH 074/304] Automation: a control token authenticates in either letter case (#6425) validate_token accepts uppercase hex and server_token keeps the token lowercased, but authentication_reply passed the client's token to token_matches unchanged, which compared bytes exactly. A client sending the token exactly as configured (--control-token or a token file in uppercase) was refused; only the bundled bridge, which lowercases, got in. token_matches now folds ASCII letters on both sides, still without an early exit. Only A-Z fold, so a non-hex byte never matches a hex digit. --- crates/automation/src/security.rs | 45 ++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 7928420b12..3f62c191b5 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -97,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> { } /// Compare fixed-width tokens without an early exit on a mismatching byte. +/// +/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps +/// the token lowercased, so a client sending the token exactly as it was configured must still +/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit. pub fn token_matches(expected: &str, supplied: &str) -> bool { if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN { return false; } - expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0 + expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0 } /// Validate the first TCP frame without exposing any control method before authentication. @@ -348,6 +352,45 @@ mod tests { assert_eq!(reply["result"]["authenticated"], true); } + fn auth_line(token: &str) -> String { + json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string() + } + + /// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a + /// client sending the token exactly as it was configured was refused. + #[test] + fn a_token_authenticates_in_any_letter_case() { + let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef"; + let expected = server_token(Some(configured), None).unwrap(); + for supplied in [configured, &configured.to_ascii_lowercase(), mixed] { + let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected); + assert!(authenticated, "{supplied}: {reply}"); + assert_eq!(reply["result"]["authenticated"], true); + } + // The comparison is case-insensitive in both directions. + assert!(token_matches(configured, &configured.to_ascii_lowercase())); + + // A different token, or a non-hex character that only folds to a hex one, still fails. + let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let (_, authenticated) = authentication_reply(&auth_line(other), &expected); + assert!(!authenticated); + let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + assert!(!token_matches(&expected, not_hex)); + } + + /// #6425: the same for a token file whose contents are uppercase. + #[test] + fn an_uppercase_token_file_authenticates_its_exact_contents() { + let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap())); + let contents = generate_token().unwrap().to_ascii_uppercase(); + std::fs::write(&path, format!("{contents}\n")).unwrap(); + let expected = server_token(None, Some(&path)); + std::fs::remove_file(&path).unwrap(); + let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap()); + assert!(authenticated, "{reply}"); + } + #[test] fn repeat_auth_on_an_authenticated_connection_says_so() { let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string(); From a08992c9d93676affdabf97981547a4bce6974bc Mon Sep 17 00:00:00 2001 From: highperfocused Date: Fri, 16 Oct 2026 09:14:24 +0200 Subject: [PATCH 075/304] Desktop: a control/automation flag without its value is a usage error (#6429) --- apps/photocraft/src/main.rs | 288 +++++++++++++++++++++++++----------- 1 file changed, 203 insertions(+), 85 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index c0f93bd319..337399d99a 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -149,12 +149,115 @@ fn parse_control_port(value: &str, source: &str) -> Result { value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) } -/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None` -/// when there are none, else 2 (a command-line usage error), never 0. +/// Process exit status for the collected control/automation argument errors: `None` when there +/// are none, else 2 (a command-line usage error), never 0. fn control_args_exit_code(errors: &[String]) -> Option { if errors.is_empty() { None } else { Some(2) } } +/// Everything the desktop launcher takes from the command line, on top of the environment +/// defaults handed in to `parse_launch_args`. +#[derive(Debug, Default, PartialEq, Eq)] +struct LaunchArgs { + control_port: Option, + control_token: Option, + control_token_file: Option, + automation_read_root: Option, + automation_write_root: Option, + files: Vec, + /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 + /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are + /// reported in a notice once the app is up; a control token is a usage error like a bad port. + unreadable_paths: Vec, + safe_gpu: bool, + new_instance: bool, + /// Options that apply only to the process they're given to; a launch with any keeps its own. + launch_options: Vec<&'static str>, + present_mode_arg: Option, + frame_latency_arg: Option, + stroke_trace_arg: Option, + in_window_menus: bool, + version: bool, + /// Usage errors in the control/automation flags; a non-empty list fails the launch (issue #701). + errors: Vec, +} + +/// Parse the desktop launch arguments over the environment defaults in `out`. A control or +/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent +/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. +fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { + let mut args = args; + while let Some(a) = args.next() { + match a.to_str() { + Some("--control") => match args.next() { + Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { + Ok(port) => out.control_port = Some(port), + Err(error) => out.errors.push(error), + }, + None => out.errors.push("--control: missing port value (expected `--control `)".to_string()), + }, + Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(token)) => out.control_token = Some(token), + Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), + None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), + }, + Some("--control-token-file") => match args.next() { + Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), + }, + Some("--automation-read-root") => { + out.launch_options.push("--automation-read-root"); + match args.next() { + Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), + } + } + Some("--automation-write-root") => { + out.launch_options.push("--automation-write-root"); + match args.next() { + Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), + } + } + Some("--safe-gpu") => { + out.launch_options.push("--safe-gpu"); + out.safe_gpu = true; + } + Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { + out.launch_options.push(match flag { + "--present-mode" => "--present-mode", + "--frame-latency" => "--frame-latency", + _ => "--stroke-trace", + }); + let flag = flag.to_string(); + match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(v)) => match flag.as_str() { + "--present-mode" => out.present_mode_arg = Some(v), + "--frame-latency" => out.frame_latency_arg = Some(v), + _ => out.stroke_trace_arg = Some(v), + }, + // A bad value is reported and ignored; it never fails the launch. + _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), + } + } + Some("--in-window-menus") => { + out.launch_options.push("--in-window-menus"); + out.in_window_menus = true; + } + Some("--new-instance") => out.new_instance = true, + Some("--version") => { + out.version = true; + break; + } + // Old macOS passes a process serial number when launched from Finder. + Some(s) if s.starts_with("-psn_") => {} + Some(s) => out.files.push(s.to_owned()), + None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), + } + } + out +} + #[cfg(test)] mod control_port_tests { use super::{control_args_exit_code, parse_control_port}; @@ -184,105 +287,120 @@ mod control_port_tests { } } +#[cfg(test)] +mod launch_args_tests { + use super::{control_args_exit_code, parse_launch_args, LaunchArgs}; + use std::ffi::OsString; + + fn parse(values: &[&str]) -> LaunchArgs { + parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) + } + + #[test] + fn flags_without_a_value_are_usage_errors() { + // Issue #6429: a missing value must fail the launch, not silently fall back to a + // generated token or to no root. + for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { + let parsed = parse(&[flag]); + assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); + assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); + assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); + assert_eq!(control_args_exit_code(&parsed.errors), Some(2)); + } + } + + #[test] + fn missing_value_does_not_clear_an_environment_default() { + // `--automation-read-root` with no value must not drop a root set through the env var. + let defaults = LaunchArgs { + automation_read_root: Some("/env/read".into()), + automation_write_root: Some("/env/write".into()), + ..Default::default() + }; + let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + } + + #[test] + fn values_are_parsed_and_no_errors_remain() { + let parsed = parse(&["--control", "50494", "--control-token", "abcd", "--control-token-file", "/tmp/token", "--automation-read-root", "/r", "--automation-write-root", "/w", "--safe-gpu", "--in-window-menus", "--new-instance", "--present-mode", "mailbox", "photo.psd"]); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + assert_eq!(parsed.control_port, Some(50494)); + assert_eq!(parsed.control_token.as_deref(), Some("abcd")); + assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); + assert!(parsed.safe_gpu); + assert!(parsed.in_window_menus); + assert!(parsed.new_instance); + assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); + assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); + assert_eq!(parsed.files, vec!["photo.psd".to_string()]); + } + + #[test] + fn version_stops_parsing() { + let parsed = parse(&["--version", "--control"]); + assert!(parsed.version); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + } +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. photocraft_ui_egui::type_tool::warm_fonts(); - let mut control_port: Option = None; - let mut control_arg_errors: Vec = Vec::new(); + let mut launch = LaunchArgs { + automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from), + automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from), + in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"), + ..Default::default() + }; if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) { match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") { - Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), + Ok(port) => launch.control_port = Some(port), + Err(error) => launch.errors.push(error), } } - let mut control_token = None; - let mut control_token_file = None; - let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from); - let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from); - let mut files = Vec::new(); - // Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 - // file name on Linux, and `std::env::args()` would panic before any window opened. Paths are - // reported in a notice once the app is up; a control token is a usage error like a bad port. - let mut unreadable_paths: Vec = Vec::new(); - let mut safe_gpu = false; - let mut new_instance = false; - // Options that apply only to the process they're given to; a launch with any keeps its own. - let mut launch_options: Vec<&'static str> = Vec::new(); - let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option, Option, Option) = (None, None, None); - let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); - let mut args = std::env::args_os().skip(1); - while let Some(a) = args.next() { - match a.to_str() { - Some("--control") => match args.next() { - Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { - Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), - }, - None => control_arg_errors.push("--control: missing port value (expected `--control `)".to_string()), - }, - Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(token)) => control_token = Some(token), - Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), - None => control_token = None, - }, - Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from), - Some("--automation-read-root") => { - launch_options.push("--automation-read-root"); - automation_read_root = args.next().map(std::path::PathBuf::from); - } - Some("--automation-write-root") => { - launch_options.push("--automation-write-root"); - automation_write_root = args.next().map(std::path::PathBuf::from); - } - Some("--safe-gpu") => { - launch_options.push("--safe-gpu"); - safe_gpu = true; - } - Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { - launch_options.push(match flag { - "--present-mode" => "--present-mode", - "--frame-latency" => "--frame-latency", - _ => "--stroke-trace", - }); - let flag = flag.to_string(); - match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(v)) => match flag.as_str() { - "--present-mode" => present_mode_arg = Some(v), - "--frame-latency" => frame_latency_arg = Some(v), - _ => stroke_trace_arg = Some(v), - }, - // A bad value is reported and ignored; it never fails the launch. - _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), - } - } - Some("--in-window-menus") => { - launch_options.push("--in-window-menus"); - in_window_menus = true; - } - Some("--new-instance") => new_instance = true, - Some("--version") => { - println!("photocraft {}", photocraft_engine::build_info::long_version()); - return Ok(()); - } - // Old macOS passes a process serial number when launched from Finder. - Some(s) if s.starts_with("-psn_") => {} - Some(s) => files.push(s.to_owned()), - None => unreadable_paths.push(a.to_string_lossy().into_owned()), - } + launch = parse_launch_args(std::env::args_os().skip(1), launch); + if launch.version { + println!("photocraft {}", photocraft_engine::build_info::long_version()); + return Ok(()); } - // A malformed control port must not silently drop the control server (issue #701): name the - // bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo. - // Exit status 2 is the usual command-line usage error, so a launcher sees the failure. - if let Some(code) = control_args_exit_code(&control_arg_errors) { - for error in &control_arg_errors { + // A malformed control port must not silently drop the control server (issue #701), and a + // control or automation flag without its value must not silently fall back to a generated + // token or to no root (issue #6429): name the flag and fail the launch, like + // `photocraft-cli serve --port` does for the same typo. Exit status 2 is the usual + // command-line usage error, so a launcher sees the failure. + if let Some(code) = control_args_exit_code(&launch.errors) { + for error in &launch.errors { eprintln!("photocraft: {error}"); } std::process::exit(code); } + let LaunchArgs { + control_port, + control_token, + control_token_file, + automation_read_root, + automation_write_root, + files, + unreadable_paths, + safe_gpu, + new_instance, + launch_options, + present_mode_arg, + frame_latency_arg, + stroke_trace_arg, + in_window_menus, + version: _, + errors: _, + } = launch; // The log file lives under the settings directory; opened after the arguments, so `--version` // and usage errors leave no file behind. Records logged until now are written to it first. From 3f216a0cd1b9549a3ec81db75f5d160badf14d89 Mon Sep 17 00:00:00 2001 From: PerchAnton Date: Fri, 16 Oct 2026 10:28:57 +0300 Subject: [PATCH 076/304] fix(engine): reject numeric overflow before document edits --- crates/engine/src/adjust_cmds.rs | 2 +- crates/engine/src/analysis_cmds.rs | 14 +- crates/engine/src/artboard_cmds.rs | 10 +- crates/engine/src/channel_cmds.rs | 16 +- crates/engine/src/color_to_alpha_cmds.rs | 9 +- crates/engine/src/commands.rs | 60 ++++++-- crates/engine/src/extra_cmds.rs | 2 +- crates/engine/src/fill_cmds.rs | 2 +- crates/engine/src/layer_style.rs | 31 +++- crates/engine/src/lens_cmds.rs | 2 +- crates/engine/src/notes_cmds.rs | 14 +- crates/engine/src/type_cmds.rs | 5 +- crates/engine/tests/finite_command_params.rs | 154 +++++++++++++++++++ 13 files changed, 270 insertions(+), 51 deletions(-) create mode 100644 crates/engine/tests/finite_command_params.rs diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..e465b0ddc1 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -335,7 +335,7 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { fn replace_color(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.adjustments.replaceColor"; - let c = crate::commands::color_param(p, "color", s.tools.foreground); + let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?; let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); // Localized Color Clusters, as in Select › Color Range: the colour only matches within Range diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 85a6be4513..e6e285993e 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -722,10 +722,12 @@ fn group_index(d: &Document, p: &Value) -> Option { (i < d.measurement.count_groups.len()).then_some(i) } -fn color_of(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } /// Photoshop's colours for new count groups, in order. @@ -840,7 +842,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result { fn count_new_group(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let color = color_of(p, "color"); + let color = color_of("count.newGroup", p, "color")?; s.edit("New Count Group", |d, _| { let g = new_group(d, name, color); d.measurement.count_groups.push(g); @@ -887,7 +889,7 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { if let Some(n) = p.get("name").and_then(Value::as_str) { grp.name = n.to_string(); } - if let Some(c) = color_of(p, "color") { + if let Some(c) = color_of(CMD, p, "color")? { grp.color = c; } if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? { diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 5cebe86aa9..4f551b2d77 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -92,10 +92,12 @@ fn preset_size(name: &str) -> Option<(u32, u32)> { } fn background_param(p: &Value, cmd: &str) -> Result> { - let custom = p.get("color").map(|_| { - let c = color_param(p, "color", [1.0; 4]); - ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3])) - }); + let custom = if p.get("color").is_some() { + let c = color_param(cmd, p, "color", [1.0; 4])?; + Some(ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3]))) + } else { + None + }; Ok(match p.get("background").and_then(Value::as_str) { None => custom, Some("white") => Some(ArtboardBackground::White), diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 468db2698b..991f40dc0b 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -1086,17 +1086,19 @@ fn indicates_param(v: Option<&Value>) -> Option { } } -fn color_value(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_value(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn new_channel(s: &mut Session, p: &Value) -> Result { let fill = p.get("fill").and_then(Value::as_str).unwrap_or("black").to_string(); let name = p.get("name").and_then(Value::as_str).map(str::to_string); let indicates = indicates_param(p.get("indicates")); - let color = color_value(p, "color"); + let color = color_value("channel.new", p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let i = s.edit("New Channel", |doc, _| { let area = doc.bounds(); @@ -1125,7 +1127,7 @@ fn new_channel(s: &mut Session, p: &Value) -> Result { } fn new_spot(s: &mut Session, p: &Value) -> Result { - let color = color_value(p, "color").unwrap_or(Color::rgb(1.0, 0.0, 0.0)); + let color = color_value("channel.newSpot", p, "color")?.unwrap_or(Color::rgb(1.0, 0.0, 0.0)); let solidity = p.get("solidity").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0) as f32 / 100.0; let name = p.get("name").and_then(Value::as_str).map(str::to_string); let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); @@ -1421,7 +1423,7 @@ fn options(s: &mut Session, p: &Value) -> Result { let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let r = p.get("channel").and_then(|v| parse_ref(v, doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?; let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - let color = color_value(p, "color"); + let color = color_value(cmd, p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let solidity = p.get("solidity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let kind = p.get("indicates").and_then(Value::as_str).map(str::to_string); diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs index a6208e8246..81a60640fa 100644 --- a/crates/engine/src/color_to_alpha_cmds.rs +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -34,7 +34,7 @@ fn unit(v: f32, default: f32) -> f32 { /// Algorithm parameters (lenient: out-of-range values are clamped, unreadable ones fall back to /// the defaults). [`run`] validates strictly before it gets here. pub(crate) fn params(p: &Value) -> FilterParams { - let c = crate::commands::color_param(p, "color", WHITE); + let c = crate::commands::color_param(ID, p, "color", WHITE).unwrap_or(WHITE); let num = |k: &str, d: f32| p.get(k).and_then(Value::as_f64).map_or(d, |v| unit(v as f32, d)); FilterParams::ColorToAlpha { color: [unit(c[0], 1.0), unit(c[1], 1.0), unit(c[2], 1.0), 1.0], @@ -65,9 +65,10 @@ fn validate(p: &Value) -> Result<()> { crate::commands::parse_hex(s).ok_or_else(|| bad(format!("`color` must be \"#rrggbb\", not {s:?}")))?; } Some(Value::Array(a)) if (3..=4).contains(&a.len()) => { - if !a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) { - return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1")); - } + a.iter() + .all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) + .then_some(()) + .ok_or_else(|| bad("`color` as an array must hold 3 or 4 numbers in 0..1"))?; } Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")), } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..83b0717528 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -232,15 +232,53 @@ pub(crate) fn u32_id_param(cmd: &str, p: &Value, key: &str) -> Result f32 { p.get(key).and_then(Value::as_f64).map(|v| v as f32).unwrap_or(default) } -pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] { - match p.get(key) { +/// JSON numbers may be finite f64 values but overflow when stored in a document's f32 field. +pub(crate) fn finite_f32(cmd: &str, key: &str, value: f64) -> Result { + let narrowed = value as f32; + if narrowed.is_finite() { Ok(narrowed) } else { Err(bad(cmd, format!("`{key}` must fit a finite 32-bit number"))) } +} + +/// Effect/type parameters contain nested colour arrays and snapshots as well as scalars. +/// Validate before parsing or editing, including numbers hidden in an effect snapshot. +pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> { + // Bound the call stack, without allocating a work list proportional to an input array. + fn check(cmd: &str, value: &Value, depth: usize) -> Result<()> { + if depth > 64 { + return Err(bad(cmd, "parameters nested deeper than 64 levels")); + } + match value { + Value::Number(n) => { + finite_f32(cmd, "number", n.as_f64().ok_or_else(|| bad(cmd, "invalid number"))?)?; + } + Value::Array(a) => { + for v in a { + check(cmd, v, depth + 1)?; + } + } + Value::Object(o) => { + for v in o.values() { + check(cmd, v, depth + 1)?; + } + } + _ => {} + } + Ok(()) + } + check(cmd, value, 0) +} + +pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> { + Ok(match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)] + let mut out = [0.0, 0.0, 0.0, 1.0]; + for (slot, value) in out.iter_mut().zip(a) { + *slot = finite_f32(cmd, key, value.as_f64().unwrap_or(0.0))?; + } + out } Some(Value::String(s)) => parse_hex(s).unwrap_or(default), _ => default, - } + }) } pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { let s = s.trim_start_matches('#'); @@ -968,7 +1006,7 @@ fn build() -> Vec { r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, has_doc, |s, p| { - let c = color_param(p, "color", s.tools.foreground); + let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?; let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); @@ -988,8 +1026,8 @@ fn build() -> Vec { r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, has_doc, |s, p| { - let a = color_param(p, "from", s.tools.foreground); - let b = color_param(p, "to", s.tools.background); + let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; + let b = color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)?; let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); @@ -1120,8 +1158,10 @@ fn build() -> Vec { } }; let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); - s.tools.foreground = color_param(p, "foreground", s.tools.foreground); - s.tools.background = color_param(p, "background", s.tools.background); + let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?; + let background = color_param("tools.setColors", p, "background", s.tools.background)?; + s.tools.foreground = foreground; + s.tools.background = background; if let Some(k) = fg_cmyk { let [r, g, b, _] = s.tools.foreground; s.tools.remember_cmyk([r, g, b], k); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index c291862167..abd739b842 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -135,7 +135,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut fn stroke(s: &mut Session, p: &Value) -> Result { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; - let color = color_param(p, "color", s.tools.foreground); + let color = color_param("edit.stroke", p, "color", s.tools.foreground)?; // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 2aa0a02bdc..b8e6033cb3 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let source = match contents { "foreground" => color_source(s, s.tools.foreground), "background" => color_source(s, s.tools.background), - "color" => color_source(s, color_param(p, "color", s.tools.foreground)), + "color" => color_source(s, color_param(CMD, p, "color", s.tools.foreground)?), "black" => Source::Color([0.0, 0.0, 0.0, 1.0]), "gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]), "white" => Source::Color([1.0; 4]), diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..8933f838b9 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -594,6 +594,9 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { /// a dialog round trip never touches what it doesn't model: an imported /// gradient stroke keeps its gradient while its opacity is edited. pub fn overlay_effect(fx: &mut Effect, p: &Value) { + if crate::commands::check_f32_values("layer.layerStyle", p).is_err() { + return; + } fn set_color(p: &Value, k: &str, c: &mut Color) { if let Some(x) = color_of(p, k) { *c = x; @@ -821,6 +824,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { /// Builds an effect of `kind` from JSON params (Photoshop units: opacity, /// spread, range in percent; sizes in px; angles in degrees). pub fn effect_from_params(kind: &str, p: &Value) -> Option { + crate::commands::check_f32_values("layer.layerStyle", p).ok()?; let mut fx = fresh_effect(kind)?; overlay_effect(&mut fx, p); Some(fx) @@ -856,6 +860,7 @@ const MAX_EFFECTS: usize = 256; /// mismatches and malformed snapshots are errors, never panics. fn replace_effects(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.layerStyle.replace"; + crate::commands::check_f32_values(CMD, p)?; let id = crate::commands::layer_param(s, p)?; let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?; if entries.len() > MAX_EFFECTS { @@ -905,6 +910,7 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { } fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { + crate::commands::check_f32_values(&format!("layer.layerStyle.{kind}"), p)?; let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?; let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?; let label = format!("Layer Style: {}", fx.label()); @@ -936,6 +942,7 @@ fn make_default(s: &mut Session, p: &Value) -> Result { Some(v) if v.is_object() => v.clone(), _ => return Err(bad(CMD, "`params` must be an object")), }; + crate::commands::check_f32_values(CMD, ¶ms)?; s.presets.layer_defaults.insert(kind.to_string(), params); s.presets_changed(); Ok(json!({"kind": kind})) @@ -1152,17 +1159,25 @@ mod tests { /// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width /// is capped at the effect reach, so the document still renders, quickly. #[test] - fn oversized_satin_and_bevel_sizes_render() { - for (cmd, p) in [ - ("satin", json!({"size": 1e308})), - ("satin", json!({"size": 1_000_000})), - ("bevelEmboss", json!({"size": 1e308})), - ("bevelEmboss", json!({"soften": 1e308})), - ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000})), + fn oversized_satin_and_bevel_sizes_stay_saveable_and_render() { + for (cmd, p, accepted) in [ + ("satin", json!({"size": 1e308}), false), + ("satin", json!({"size": 1e30}), true), + ("satin", json!({"size": 1_000_000}), true), + ("bevelEmboss", json!({"size": 1e308}), false), + ("bevelEmboss", json!({"soften": 1e308}), false), + ("bevelEmboss", json!({"size": 1e30, "soften": 1e30}), true), + ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000}), true), ] { let mut s = session(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); - s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap(); + let result = s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()); + if accepted { + result.unwrap(); + } else { + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + } + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); let start = std::time::Instant::now(); s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed()); diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..ff72fbb664 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -62,7 +62,7 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera "edgeExtension" | "extension" => EdgeMode::Extension, "black" => EdgeMode::Color([0.0, 0.0, 0.0, 1.0]), "white" => EdgeMode::Color([1.0, 1.0, 1.0, 1.0]), - "background" | "color" => EdgeMode::Color(crate::commands::color_param(p, "edgeColor", [0.0, 0.0, 0.0, 1.0])), + "background" | "color" => EdgeMode::Color(crate::commands::color_param(cmd, p, "edgeColor", [0.0, 0.0, 0.0, 1.0])?), other => return Err(bad(cmd, format!("unknown edge `{other}` (transparency|edgeExtension|black|white|color)"))), }; let focal = num(p, "focalLength", 0.0); diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs index 54bbd60b7a..57116e4c8c 100644 --- a/crates/engine/src/notes_cmds.rs +++ b/crates/engine/src/notes_cmds.rs @@ -40,10 +40,12 @@ fn list_json(d: &Document) -> Value { json!({"notes": d.notes.iter().enumerate().map(|(i, n)| note_json(i, n)).collect::>()}) } -fn color_of(p: &Value) -> Option { - p.get("color")?; - let c = crate::commands::color_param(p, "color", [1.0, 1.0, 0.51, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value) -> Result> { + if p.get("color").is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn index_of(s: &Session, p: &Value, cmd: &str) -> Result { @@ -68,7 +70,7 @@ fn add(s: &mut Session, p: &Value) -> Result { modified: pdf_date(), ..Default::default() }; - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } let i = s.edit("New Note", |d, _| { @@ -90,7 +92,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if let Some(a) = p.get("author").and_then(Value::as_str) { n.author = a.to_string(); } - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } if let Some(o) = p.get("open").and_then(Value::as_bool) { diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..8216b5d2c9 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -34,14 +34,14 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { } fn f32p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_f64).map(|v| v as f32) + p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()) } fn color(v: &Value) -> Option { match v { Value::Array(a) if a.len() >= 3 => { let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) + c.iter().take(4).all(|v| v.is_finite()).then(|| Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) } Value::String(s) => { let s = s.trim_start_matches('#'); @@ -743,6 +743,7 @@ fn info(s: &Session, p: &Value) -> Result { /// paragraph `box`, `orientation`, character and paragraph keys, starting from the default type /// styles in the foreground colour. Shared with the Type Mask tools' `type.createSelection`. pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result { + crate::commands::check_f32_values(cmd, p)?; let orientation = match p.get("orientation") { None => Orientation::Horizontal, Some(Value::String(value)) if value == "horizontal" => Orientation::Horizontal, diff --git a/crates/engine/tests/finite_command_params.rs b/crates/engine/tests/finite_command_params.rs new file mode 100644 index 0000000000..0350d74493 --- /dev/null +++ b/crates/engine/tests/finite_command_params.rs @@ -0,0 +1,154 @@ +//! #6424: rejecting an overflowing f32 parameter must leave a saveable document and history. +use photocraft_engine::{EngineError, Session}; +use serde_json::{Value, json}; + +fn fresh(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12, "mode": mode, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s +} + +fn saved(s: &Session) -> Vec { + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap() +} + +fn rejected(cases: Vec<(&str, Value)>) { + let mut failures = Vec::new(); + for (id, p) in cases { + let mut s = fresh("rgb", 8); + let before = saved(&s); + let undo = s.active().unwrap().history.undo_label().map(str::to_owned); + let result = s.execute(id, p.clone()); + let save = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()); + if !matches!(result, Err(EngineError::BadParams { .. })) { + failures.push(format!("{id} {p}: result={result:?}, save error={:?}", save.as_ref().err())); + continue; + } + assert_eq!(save.unwrap(), before, "rejected {id} must not change the document"); + assert_eq!(s.active().unwrap().history.undo_label(), undo.as_deref(), "rejected {id} must not add history"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn overflowing_layer_styles_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for value in [1e39, -1e39] { + for (kind, key) in [ + ("dropShadow", "angle"), + ("innerShadow", "angle"), + ("outerGlow", "size"), + ("innerGlow", "size"), + ("stroke", "size"), + ("satin", "angle"), + ("bevelEmboss", "depth"), + ("gradientOverlay", "angle"), + ("patternOverlay", "phaseX"), + ] { + let id = match kind { + "dropShadow" => "layer.layerStyle.dropShadow", + "innerShadow" => "layer.layerStyle.innerShadow", + "outerGlow" => "layer.layerStyle.outerGlow", + "innerGlow" => "layer.layerStyle.innerGlow", + "stroke" => "layer.layerStyle.stroke", + "satin" => "layer.layerStyle.satin", + "bevelEmboss" => "layer.layerStyle.bevelEmboss", + "gradientOverlay" => "layer.layerStyle.gradientOverlay", + _ => "layer.layerStyle.patternOverlay", + }; + let params = json!({key: value}); + cases.push((id, params.clone())); + cases.push(("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]}))); + } + cases.push(("layer.layerStyle.colorOverlay", json!({"color": [value, 0, 0]}))); + } + rejected(cases); +} + +#[test] +fn overflowing_document_colors_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for id in ["layer.newFillLayer.solidColor", "channel.new", "channel.newSpot", "layer.new.artboard", "count.newGroup"] { + for value in [1e39, -1e39] { + for component in 0..4 { + let mut color = [0.5; 4]; + color[component] = value; + cases.push((id, json!({"color": color}))); + } + } + } + rejected(cases); +} + +#[test] +fn overflowing_type_positions_and_colors_are_rejected_before_mutation() { + rejected(vec![ + ("type.create", json!({"text": "hello", "x": 1e39})), + ("type.create", json!({"text": "hello", "y": -1e39})), + ("type.create", json!({"text": "hello", "color": [1e39, 0, 0]})), + ]); +} + +#[test] +fn valid_color_and_style_edits_still_save_and_undo_at_each_depth() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = fresh(mode, depth); + let before = saved(&s); + s.execute("layer.layerStyle.dropShadow", json!({"angle": -45, "color": [0.1, 0.3, 0.8]})).unwrap(); + let edited = saved(&s); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(saved(&s), edited); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + let bytes = saved(&s); + photocraft_format::load_from_bytes(&bytes).unwrap(); + } + } +} + +#[test] +fn effect_snapshots_cannot_bypass_numeric_validation() { + let mut fx = serde_json::to_value(photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap()).unwrap(); + fx["DropShadow"]["angle"] = json!(1e39); + rejected(vec![( + "layer.layerStyle.replace", + json!({"effects": [ + {"kind": "colorOverlay", "params": {"color": "#00ff00"}}, + {"kind": "dropShadow", "fx": fx, "params": {}} + ]}), + )]); +} + +#[test] +fn direct_effect_helpers_do_not_introduce_infinity() { + let original = photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap(); + for p in [json!({"angle": 1e39}), json!({"color": [0, -1e39, 0]})] { + assert!(photocraft_engine::layer_style::effect_from_params("dropShadow", &p).is_none()); + let mut edited = original.clone(); + photocraft_engine::layer_style::overlay_effect(&mut edited, &p); + assert_eq!(edited, original); + } +} + +#[test] +fn bad_background_color_does_not_partially_change_tool_colors() { + let mut s = fresh("rgb", 32); + let before = (s.tools.foreground, s.tools.background); + let result = s.execute("tools.setColors", json!({"foreground": "#40a0c0", "background": [0, 1e39, 0]})); + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + assert_eq!((s.tools.foreground, s.tools.background), before); +} + +#[test] +fn finite_hdr_colors_are_preserved_without_unit_clamping() { + let mut s = fresh("rgb", 32); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": [4.0, -0.5, 0.25]})).unwrap()["layer"].as_u64().unwrap(); + let bytes = saved(&s); + let reopened = photocraft_format::load_from_bytes(&bytes).unwrap(); + let layer = reopened.layer(photocraft_doc::LayerId(id)).unwrap(); + let photocraft_doc::LayerContent::Fill(photocraft_doc::Fill::Solid(c)) = &layer.content else { panic!("expected a solid fill") }; + assert_eq!(c.to_rgb(), [4.0, -0.5, 0.25]); +} From f59b75b058b954e55d6df67b7d8521536e66ec7b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 09:35:06 +0200 Subject: [PATCH 077/304] Character and Paragraph panels: size, leading, baseline shift and indents follow the Type units preference (fixes #6491) --- crates/ui-egui/src/type_tool.rs | 36 ++++++++++++++------------- crates/ui-egui/src/type_tool_tests.rs | 21 ++++++++++++++++ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..f95b1bdf1b 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2130,6 +2130,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section}; use crate::theme::ROW_GAP; let Some((c, para)) = styles_at(app) else { return }; + // Preferences ▸ Units & Rulers ▸ Type, as in the options bar: the model keeps points. + let (u, unit) = type_unit(app); let t = crate::theme::Tokens::get(ui.ctx()); // Row helper: `ui.horizontal` with the column gap, then the shared row gap. let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| { @@ -2167,13 +2169,13 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa let k = shown_scale(app); // Hover tips stay English until wired through `tl!` in a multi-locale change // (`every_tl_literal_is_translated` requires every complete catalog). - if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) { - app.ui.tool_options.type_size = v; - apply(app, ui.ctx(), json!({"size": v, "metricsAsShown": true})); + if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k * u, 0.1 * u..=1296.0 * u, unit, w) { + app.ui.tool_options.type_size = v / u; + apply(app, ui.ctx(), json!({"size": v / u, "metricsAsShown": true})); } let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k; - if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) { - apply(app, ui.ctx(), json!({"leading": v, "metricsAsShown": true})); + if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead * u, 0.1 * u..=5000.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"leading": v / u, "metricsAsShown": true})); } }); row(ui, &mut |ui| { @@ -2198,8 +2200,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"baselineShift": v})); + if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"baselineShift": v / u})); } // Colour: label + a swatch filling the rest of the second column. ui.horizontal(|ui| { @@ -2306,24 +2308,24 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa }); ui.add_space(ROW_GAP); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"startIndent": v})); + if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"startIndent": v / u})); } - if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"endIndent": v})); + if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"endIndent": v / u})); } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"firstLineIndent": v})); + if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"firstLineIndent": v / u})); } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"spaceBefore": v})); + if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt * u, 0.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"spaceBefore": v / u})); } - if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"spaceAfter": v})); + if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt * u, 0.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"spaceAfter": v / u})); } }); let mut hy = para.hyphenate; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 7a23c9ccc3..c2931fc463 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1549,6 +1549,27 @@ mod multi_layer_formatting { assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } + /// #6491: with Units & Rulers ▸ Type set to pixels, the Character and Paragraph fields show + /// and take pixels, like the options bar. 144 ppi so a pixel is not a point. + #[test] + fn character_and_paragraph_fields_follow_the_type_units_preference() { + use egui_kittest::kittest::NodeT; + let (mut app, ids) = fixture(); + app.run("prefs.set", json!({"path": "unitsAndRulers.typeUnits", "value": "pixels"})).unwrap(); + app.run("image.imageSize", json!({"resolution": 144, "resample": "none"})).unwrap(); + selected(&mut app, &[ids[0]]); + let mut h = controls(app, true); + let shown = h.get_by_role_and_label(Role::SpinButton, "Font size").accesskit_node().numeric_value(); + assert_eq!(shown, Some(24.0), "12 pt at 144 ppi"); + number(&mut h, "Font size", "60"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "84"); + number(&mut h, "Baseline shift", "10"); + number(&mut h, "Indent left margin", "9"); + let c = style(h.state(), ids[0], 0); + assert_eq!((c.size_pt, c.leading_pt, c.baseline_shift_pt), (30.0, Some(42.0), 5.0)); + assert_eq!(text(h.state(), ids[0]).paragraph_runs()[0].style.start_indent_pt, 4.5); + } + /// #5717: a layer scaled so its shown size or leading is past a field's range (2400 pt, or /// 0.08 pt) keeps it while the Character panel and the options bar only show it. #[test] From 9c914a9fd29bf27d8e1ecd72203578cc52412c8e Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 15:58:41 +0800 Subject: [PATCH 078/304] Refuse Affinity spot colours nested more than 4 levels deep paint::color followed a spot colour's RGB equivalent and base colour with no depth limit. Colours are shared objects, so a spot colour that links to itself overflowed the stack and aborted the import, and a chain whose levels link to the next through both took exponential time. Stop after 4 levels of spot colours defined through spot colours, with a warning. Co-Authored-By: Claude Opus 5.5 --- crates/affinity/README.md | 2 +- crates/affinity/src/paint.rs | 17 +++- crates/affinity/src/tests.rs | 171 +++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 3e4e066156..e881352c8b 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -139,7 +139,7 @@ zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve -nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient +nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index c4ebff8446..343c276b60 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option> { } } +/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB +/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared +/// objects, so one can link to itself, and with both links followed at every level a colour read +/// costs up to 2^(depth+1) lookups. +const MAX_SPOT_DEPTH: usize = 4; + /// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { + color_at(r, id, 0) +} + +/// [`color`] of an object `depth` spot colours down. +fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option { let s = r.s; let class = s.class(id)?; let col = || -> Option> { @@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? } } b"Pant" => { + if depth >= MAX_SPOT_DEPTH { + r.warn("spot colours nested deeper than 4 levels (left empty)"); + return None; + } r.warn("spot colours (imported as their RGB equivalent)"); - return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c))); + return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1))); } b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, _ => { diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 59ae99044a..2c68a064a3 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -538,3 +538,174 @@ mod pixels { ); } } + +/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB +/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process +/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded +/// recursion fails the one test that caused it instead of taking the other tests in the binary +/// down with it. +mod spot_colours { + use std::io::{Read, Write}; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::paint::{Color, Paint}; + use crate::stream::Tag; + use crate::synth::{self, F, Method, tag}; + + /// A one-page document whose page holds `top`, read in a child process first and then here. + fn read(top: Vec) -> Document { + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]); + let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); + let test = std::thread::current().name().unwrap_or("reader").to_owned(); + read_in_child(&bytes, &test); + read_on_small_stack(bytes, &test).unwrap() + } + + /// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test. + fn read_on_small_stack(bytes: Vec, test: &str) -> Result { + let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap(); + reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)) + } + + /// Set in the child process to the name of the test it reads for. + const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD"; + + /// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if + /// that process crashes, panics or takes over 20 s, or never read the document. + fn read_in_child(bytes: &[u8], test: &str) { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"]) + .env(CHILD, test) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let drain = |mut pipe: Box| { + std::thread::spawn(move || { + let mut text = String::new(); + let _ = pipe.read_to_string(&mut text); + text + }) + }; + let stdout = drain(Box::new(child.stdout.take().unwrap())); + let stderr = drain(Box::new(child.stderr.take().unwrap())); + // Fed from a thread, so a child that never reads cannot block the deadline below; a child + // that dies first breaks the pipe and is reported by its exit status. + let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec()); + let feed = std::thread::spawn(move || { + let _ = stdin.write_all(&input); + }); + let deadline = Instant::now() + Duration::from_secs(20); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(10)); + }; + feed.join().unwrap(); + let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap()); + match status { + None => panic!("reading the document did not finish within 20 s:\n{output}"), + Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"), + // A filter that matched no test would also exit successfully, having read nothing. + Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"), + } + } + + /// Not a test of its own: `read_in_child` runs it in a child process to read the document on + /// its stdin. Without the variable, as when run with `--ignored`, it does nothing. + #[test] + #[ignore = "run by read_in_child in a child process"] + fn read_child() { + let Some(test) = std::env::var_os(CHILD) else { return }; + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + let len = bytes.len(); + let _ = read_on_small_stack(bytes, &test.to_string_lossy()); + println!("read {len} bytes"); + } + + /// An ellipse filled with `colour`. + fn filled(id: u32, colour: F) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])), + (tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])), + (tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])), + ], + ) + } + + fn spot(id: u32, fields: Vec<(Tag, F)>) -> F { + F::Def(id, vec![tag(b"Pant")], fields) + } + + fn fills(doc: &Document) -> &[Paint] { + match &doc.spreads[0].nodes[0].kind { + Kind::Shape { fills, .. } => fills, + other => panic!("expected a shape, got {other:?}"), + } + } + + fn warned(doc: &Document, what: &str) -> bool { + doc.warnings.iter().any(|w| w.contains(what)) + } + + #[test] + fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_of_spot_colours_do_not_take_exponential_time() { + // Each level's RGB equivalent and base are the next level, and the last is in an unknown + // model, so both are followed everywhere: 2^41 colour reads before the fix. + fn level(id: u32, left: u32) -> F { + if left == 0 { + return F::Def(id, vec![tag(b"XXXX")], vec![]); + } + spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))]) + } + let doc = read(vec![filled(10, level(100, 40))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + } + + /// `spots` spot colours, each defined through the next, around an opaque RGB colour. + fn spots(id: u32, spots: u32) -> F { + if spots == 0 { + let rgba: Vec = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect(); + return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]); + } + spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))]) + } + + #[test] + fn spot_colours_nest_four_levels_and_no_deeper() { + let doc = read(vec![filled(10, spots(100, 4))]); + assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]); + let doc = read(vec![filled(10, spots(100, 5))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } +} From 4d6f3f4d1e2d87056898d1ae589d0f298afb03d4 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Fri, 16 Oct 2026 11:31:52 +0200 Subject: [PATCH 079/304] style: cargo fmt (review on #6499) --- apps/photocraft/src/main.rs | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 337399d99a..085adff597 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -289,7 +289,7 @@ mod control_port_tests { #[cfg(test)] mod launch_args_tests { - use super::{control_args_exit_code, parse_launch_args, LaunchArgs}; + use super::{LaunchArgs, control_args_exit_code, parse_launch_args}; use std::ffi::OsString; fn parse(values: &[&str]) -> LaunchArgs { @@ -312,11 +312,7 @@ mod launch_args_tests { #[test] fn missing_value_does_not_clear_an_environment_default() { // `--automation-read-root` with no value must not drop a root set through the env var. - let defaults = LaunchArgs { - automation_read_root: Some("/env/read".into()), - automation_write_root: Some("/env/write".into()), - ..Default::default() - }; + let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); @@ -325,7 +321,24 @@ mod launch_args_tests { #[test] fn values_are_parsed_and_no_errors_remain() { - let parsed = parse(&["--control", "50494", "--control-token", "abcd", "--control-token-file", "/tmp/token", "--automation-read-root", "/r", "--automation-write-root", "/w", "--safe-gpu", "--in-window-menus", "--new-instance", "--present-mode", "mailbox", "photo.psd"]); + let parsed = parse(&[ + "--control", + "50494", + "--control-token", + "abcd", + "--control-token-file", + "/tmp/token", + "--automation-read-root", + "/r", + "--automation-write-root", + "/w", + "--safe-gpu", + "--in-window-menus", + "--new-instance", + "--present-mode", + "mailbox", + "photo.psd", + ]); assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); assert_eq!(parsed.control_port, Some(50494)); assert_eq!(parsed.control_token.as_deref(), Some("abcd")); From 1302e08791731d88506f64634308d3f9035eeb8e Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:04:34 +0200 Subject: [PATCH 080/304] Arrange: the shown tiles stay put when a visible document is activated (#6017) With more documents than tiles, the run of shown documents was worked out from the active index alone, so after activating document 2 in Two-up, a click into the visible document 1 moved the tiles back to [0, 1]. The run is now kept from frame to frame and slides only when a hidden document becomes active. --- crates/ui-egui/src/canvas.rs | 8 +++++--- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/view_cmds/tests.rs | 8 +++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 05ee6ba855..cd6d3739c3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2058,9 +2058,10 @@ pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option>() + let shown: Vec = crate::canvas::arranged_cells(app, rect).unwrap().iter().map(|(d, _)| *d).collect(); + app.arrange_start = shown[0]; + shown }; assert_eq!(docs(&mut app, 0), vec![0, 1]); assert_eq!(docs(&mut app, 1), vec![0, 1], "the second tile's document stays on the right"); assert_eq!(docs(&mut app, 2), vec![1, 2], "the third document slides in next to the second"); + assert_eq!(docs(&mut app, 1), vec![1, 2], "a click into the visible document 1 leaves the tiles as they are"); + assert_eq!(docs(&mut app, 0), vec![0, 1], "a hidden document slides in from the left"); + assert_eq!(docs(&mut app, 1), vec![0, 1]); } /// #6017: rulers showed in the active tile only; the other tile had none and no room for them. From d21683cae3752c89c14942b90599a38e496ebb99 Mon Sep 17 00:00:00 2001 From: Davos <58948657+Elements2720@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:01:28 +0300 Subject: [PATCH 081/304] fix(ui): disable Batch without a recorded action --- crates/ui-egui/src/view_cmds.rs | 3 +++ crates/ui-egui/src/view_cmds/tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 8acd676f95..b677c5e2fa 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -476,6 +476,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { || (doc && matches!(&i["window.arrange.".len()..], "floatAllInWindows" | "cascade" | "tile" | "tileAllVertically" | "tileAllHorizontally")) } }, + // Batch's dialog is built from an Actions-panel action. Keep it disabled until one + // exists instead of opening a menu item that can only report "record an action". + "file.automate.batch" => crate::actions::selected_action(app).is_some(), i if wraps(i) => app.session.is_enabled(i), _ => true, }) diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..a8a0f44f4f 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -207,6 +207,20 @@ fn type_menu_checkmarks_follow_the_layer() { assert_eq!(checked(&app, "type.orientation.horizontal"), Some(true)); } +#[test] +fn batch_menu_requires_a_recorded_action() { + let (mut app, ctx) = app_with(1); + assert!(!crate::menus::is_enabled(&app, "file.automate.batch")); + + app.run("actions.record", json!({"name": "Invert"})).unwrap(); + app.run("image.adjustments.invert", json!({})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + + assert!(crate::menus::is_enabled(&app, "file.automate.batch")); + let dialog = menu(&mut app, &ctx, "file.automate.batch", json!({})).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["action"], "Invert"); +} + #[test] fn engine_commands_open_their_dialogs() { let (mut app, ctx) = app_with(2); From 489feb2333d404f035b39392ec1ad9c3711d952c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:57:15 +0200 Subject: [PATCH 082/304] Tests: pcraft keeps text run baseline; tighten sub/superscript size check (#5645) Co-Authored-By: Claude Opus 5.5 --- crates/format/tests/roundtrip.rs | 23 +++++++++++++++++++++++ crates/text/src/tests.rs | 6 ++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..5a4fe87b2d 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -213,6 +213,29 @@ fn exhausted_document_ids_and_maximal_text_runs_roundtrip() { assert_eq!(text.paragraph_runs().iter().map(|r| r.len).sum::(), text.text.len()); } +#[test] +fn text_run_baseline_roundtrips() { + use photocraft_doc::text::FontBaseline; + let mut doc = Document::new("scripts", photocraft_doc::Size::new(2, 1), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Text", + LayerContent::Text(TextLayer { + text: "H2O2".into(), + runs: vec![ + TextRun { len: 1, style: CharStyle::default() }, + TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Subscript, ..Default::default() } }, + TextRun { len: 1, style: CharStyle::default() }, + TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Superscript, ..Default::default() } }, + ], + ..Default::default() + }), + )); + let loaded = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let LayerContent::Text(text) = &loaded.layers.last().unwrap().content else { panic!("text layer must roundtrip") }; + let baselines: Vec = text.char_runs().iter().map(|r| r.style.baseline).collect(); + assert_eq!(baselines, [FontBaseline::Normal, FontBaseline::Subscript, FontBaseline::Normal, FontBaseline::Superscript]); +} + #[test] fn zip_incremental_only_new_tiles() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f00a8e8862..4ae059be41 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -449,9 +449,11 @@ fn superscript_and_subscript_shrink_and_shift_the_run() { let plain = rect(&mut e, FontBaseline::Normal); let sup = rect(&mut e, FontBaseline::Superscript); let sub = rect(&mut e, FontBaseline::Subscript); - let ratio = |r: &photocraft_geom::Rect| r.height() as f32 / plain.height() as f32; + // Scripts render at 58.3% of the size. The pixel-snapped bounds of the antialiased glyph land + // within 2 px of the exact scaled height (measured: +2.2 / +1.2 px at 46 px plain height). + let expect = plain.height() as f32 * 0.583; for r in [&sup, &sub] { - assert!((ratio(r) - 0.583).abs() < 0.06, "plain {plain:?}, scripted {r:?}"); + assert!((r.height() as f32 - expect).abs() <= 2.5, "plain {plain:?}, scripted {r:?}"); } // The cap's foot sits on the shifted baseline: 20 px (33.3% of 60) above or below. assert!((plain.y1 - sup.y1 - 20).abs() <= 2, "plain {plain:?}, superscript {sup:?}"); From 4354113e730dc1c2c83fd02dede69ebf222be877 Mon Sep 17 00:00:00 2001 From: nanopink Date: Fri, 16 Oct 2026 16:23:53 -0400 Subject: [PATCH 083/304] fix: match Photoshop Gradient Fill scale limits --- crates/engine/src/gradient_fill_cmds.rs | 9 +++--- crates/engine/src/gradient_fill_cmds/tests.rs | 28 ++++++++++--------- crates/engine/src/presets/gradients.rs | 4 +-- .../src/gradient_ui/properties_panel.rs | 3 +- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index 041d311691..356d2252db 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -28,9 +28,10 @@ pub const GET: &str = "gradient.fill.get"; /// Largest coordinate accepted for a start/end point (pixels). const MAX_COORD: f32 = 1.0e7; -/// Scale range of a gradient fill (a fraction): Photoshop allows 10–150 % (the PSD stores a -/// percentage). A handle drag past it clamps, so the far handle stops where the scale does. -const SCALE_RANGE: (f32, f32) = (0.1, 1.5); + +/// Scale range of a Gradient Fill layer (a fraction): Photoshop allows 1–1000 %. +/// Gradient Overlay effects have a separate range; the live tool creates fill layers. +pub const SCALE_RANGE: (f32, f32) = (0.01, 10.0); fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } @@ -149,7 +150,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -fn clamp_scale(v: f32) -> f32 { +pub(crate) fn clamp_scale(v: f32) -> f32 { v.clamp(SCALE_RANGE.0, SCALE_RANGE.1) } diff --git a/crates/engine/src/gradient_fill_cmds/tests.rs b/crates/engine/src/gradient_fill_cmds/tests.rs index efcdb494d9..3c0c2f3c6a 100644 --- a/crates/engine/src/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/gradient_fill_cmds/tests.rs @@ -27,7 +27,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { let r = s .execute( CREATE, - json!({"from": [14, 16], "to": [34, 8], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), + json!({"from": [14, 16], "to": [200, -40], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), ) .unwrap(); assert_eq!(layer_count(&s), 2); @@ -42,7 +42,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { assert!(!align && dither); let g = s.execute(GET, json!({})).unwrap(); let (from, to) = (g["from"].as_array().unwrap(), g["to"].as_array().unwrap()); - for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 34.0, 8.0]) { + for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 200.0, -40.0]) { assert!((v.as_f64().unwrap() - want).abs() < 1e-3, "{g}"); } // One undo step removes the layer. @@ -225,11 +225,11 @@ fn stops_add_move_recolour_delete_and_midpoints() { #[test] fn preview_equals_the_committed_edit() { let mut s = session(8, "white"); - s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "diamond"})).unwrap(); + s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "radial"})).unwrap(); let d = s.active().unwrap(); let l = d.doc.layer(d.active_layer.unwrap()).unwrap().clone(); let LayerContent::Fill(f) = &l.content else { panic!() }; - let p = json!({"from": [10, 12], "to": [30, 2]}); + let p = json!({"from": [10, 12], "to": [150, -80]}); let preview = apply_set(&l, f, d.doc.bounds(), &p, [0.0; 4], [1.0; 4]).unwrap(); s.execute(SET, p).unwrap(); assert_eq!(active_fill(&s), preview); @@ -316,12 +316,12 @@ fn commands_fail_gracefully() { s.execute(SET, json!({"scale": 1e30, "angle": 1e9, "offset": [1e4, -1e4]})).unwrap(); let g = s.execute(GET, json!({})).unwrap(); assert!(g["scale"].as_f64().unwrap().is_finite() && g["from"][0].as_f64().unwrap().is_finite()); - // Scale stays within Photoshop's 10–150 %, also for a handle dragged far away. - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); + // Fill layers use Photoshop's 1–1000 %, also for a handle dragged far away. + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); let g = s.execute(SET, json!({"from": [0, 0], "to": [1e6, 0]})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); - let g = s.execute(SET, json!({"scale": 1})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}"); + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); + let g = s.execute(SET, json!({"scale": 0.1})).unwrap(); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{g}"); } /// Dragging one handle past the scale range stops it; the other handle stays put (#1243). @@ -332,17 +332,19 @@ fn a_clamped_handle_drag_leaves_the_other_handle_alone() { for style in STYLES { for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])] { - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); - // End dragged almost onto the start: far below 10 %. + // End dragged almost onto the start: below 1 % on this larger canvas. let g = s.execute(SET, json!({"to": short})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}"); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{style}: {g}"); assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from")); // The end stops on the line towards the pointer. let e = pt(&g, "to"); assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}"); // And the same for the start handle. - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); let g = s.execute(SET, json!({"from": short_start})).unwrap(); assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}"); diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..2a700aad99 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -604,7 +604,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD)?; let stops = g.doc_stops(s.tools.foreground, s.tools.background); let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0) as f32; - let scale = (p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.1, 1.5); + let scale = crate::gradient_fill_cmds::clamp_scale(p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { @@ -696,7 +696,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"## + r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":1..1000=100,"reverse":bool}} → {{layer}}"## )), enabled: super::has_doc, run: apply, diff --git a/crates/ui-egui/src/gradient_ui/properties_panel.rs b/crates/ui-egui/src/gradient_ui/properties_panel.rs index 39b961ee20..d975102539 100644 --- a/crates/ui-egui/src/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/gradient_ui/properties_panel.rs @@ -49,7 +49,8 @@ fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if let Some(value) = numeric_row(ui, "angle", "Angle", angle, -180.0..=180.0, "°") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "angle": value})); } - if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, 10.0..=150.0, "%") { + let (min, max) = cmds::SCALE_RANGE; + if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, min * 100.0..=max * 100.0, "%") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "scale": value})); } ui.horizontal_wrapped(|ui| { From 9b6a85824d3ccd0bddd29a3a0b71a542c9bd1c30 Mon Sep 17 00:00:00 2001 From: Ian Swope Date: Fri, 16 Oct 2026 16:13:44 -0500 Subject: [PATCH 084/304] Mesh warp: tiles encoded in parallel and in-bounds taps read directly, about twice as fast with identical pixels --- crates/algo/src/warp.rs | 60 ++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 188de07d47..54a6c7c868 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -14,8 +14,9 @@ //! later (lower-right) cell wins. Work is split per destination tile (parallel on native). use photocraft_color::PixelFormat; -use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_geom::{Rect, TileCoord}; +use photocraft_raster::{Surface, Tile}; +use std::sync::Arc; use crate::transform::{Interp, bicubic}; @@ -207,7 +208,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let n = fmt.channels(); let a = n - 1; - let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec)> { + let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc)> { if tris.is_empty() { return None; } @@ -296,6 +297,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c]) } }; + // Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so + // the samplers can index `px` directly instead of bounds-checking every tap. + let base = |x: i32, y: i32, taps: i32| -> Option { + (x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n) + }; let mut outp = vec![0.0f32; w * h * n]; let mut any = false; let mut acc = [0.0f64; 8]; @@ -313,6 +319,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 Interp::Bilinear => { let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (fx, fy) = (u - f64::from(ix), v - f64::from(iy)); + if let Some(o) = base(ix, iy, 2) { + for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { + for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { + let p = &px[o + (dy * fw + dx) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * wx * wy; + } + } + } + return; + } for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { @@ -327,6 +344,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 let (u, v) = (snap(u), snap(v)); let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy))); + if let Some(o) = base(ix - 1, iy - 1, 4) { + for (j, wyj) in wy.iter().enumerate() { + for (i, wxi) in wx.iter().enumerate() { + let k = wxi * wyj; + if k == 0.0 { + continue; + } + let p = &px[o + (j * fw + i) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * k; + } + } + } + return; + } for (j, wyj) in wy.iter().enumerate() { for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; @@ -378,19 +410,27 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } outp[b + a] = al as f32; } - any.then_some((*t, outp)) + if !any { + return None; + } + // Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at + // a time once all are done. `t` lies in one tile. + let mut one = Surface::new(fmt); + one.write_region(*t, &outp); + one.prune(); + if one.tile_count() == 0 { + return None; + } + one.take_tiles(*t).pop() }; #[cfg(not(target_arch = "wasm32"))] - let done: Vec<(Rect, Vec)> = { + let done: Vec<(TileCoord, Arc)> = { use rayon::prelude::*; tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect() }; #[cfg(target_arch = "wasm32")] - let done: Vec<(Rect, Vec)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); - for (r, v) in done { - out.write_region(r, &v); - } - out.prune(); + let done: Vec<(TileCoord, Arc)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); + out.put_tiles(done); out } From 0fc5660f6021366e95c76d1566ea3be8ef0e444c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:19:40 +0200 Subject: [PATCH 085/304] Image Size: a gradient fill's centre offset stays a fraction of the frame (#6778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit map_fill multiplied the offset by the full linear part of the canvas transform, so Image Size (and Perspective Crop) scaled it: 200% moved a 25% offset to 50%. Each axis of the linear part is now normalised, so scale drops out while flips and 90° turns still mirror and turn it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/canvas_geom.rs | 10 ++++++-- crates/engine/src/canvas_geom/tests.rs | 33 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 0722c6bd10..76142f9a75 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -55,9 +55,15 @@ fn map_fill(a: &Affine, f: &mut Fill) { if let Fill::Gradient { angle, offset, .. } = f { *angle = map_angle(a, *angle); // The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns - // and flips, where the frame's sides swap with the axes. + // and flips, where the frame's sides swap with the axes. Scale (Image Size) resizes the + // frame too, so only each axis's direction applies. + let unit = |x: f64, y: f64| { + let n = x.hypot(y); + if n < EPS { (x, y) } else { (x / n, y / n) } + }; + let ((m0, m1), (m2, m3)) = (unit(a.m[0], a.m[1]), unit(a.m[2], a.m[3])); let (ox, oy) = (f64::from(offset.0), f64::from(offset.1)); - *offset = ((a.m[0] * ox + a.m[2] * oy) as f32, (a.m[1] * ox + a.m[3] * oy) as f32); + *offset = ((m0 * ox + m2 * oy) as f32, (m1 * ox + m3 * oy) as f32); } } diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 804a18b665..f27d9756fd 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -188,6 +188,39 @@ fn gradient_fill_angle_follows_the_canvas() { assert_eq!(map_angle(&Turn::Ccw90.affine(10.0, 20.0), 0.0), 90.0); } +/// The gradient fill's centre offset after `cmd`, as `gradient.fill.get` reports it (% of the frame). +fn fill_offset_after(cmd: &str, p: serde_json::Value) -> (f64, f64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("gradient.fill.create", json!({"from": [10, 50], "to": [90, 50], "style": "radial"})).unwrap(); + s.execute("gradient.fill.set", json!({"angle": 0, "offset": [25, 0]})).unwrap(); + s.execute(cmd, p).unwrap(); + let g = s.execute("gradient.fill.get", json!({})).unwrap(); + (g["offset"][0].as_f64().unwrap(), g["offset"][1].as_f64().unwrap()) +} + +#[test] +fn gradient_fill_offset_stays_a_fraction_of_the_frame() { + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + // Image Size scales the frame, so the offset (a fraction of it) stays put. + for (w, h) in [(200, 200), (50, 50), (200, 50)] { + let o = fill_offset_after("image.imageSize", json!({"width": w, "height": h})); + assert!(near(o, (25.0, 0.0)), "{w}x{h}: {o:?}"); + } + // Flips and 90° turns still mirror and turn it. + assert!(near(fill_offset_after("image.imageRotation.flipCanvasHorizontal", json!({})), (-25.0, 0.0))); + assert!(near(fill_offset_after("image.imageRotation.90cw", json!({})), (0.0, 25.0))); + assert!(near(fill_offset_after("image.imageRotation.180", json!({})), (-25.0, 0.0))); + // Non-uniform scale with a turn: only the axis swap applies. + let mut f = Fill::gradient(Vec::new(), 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false); + if let Fill::Gradient { offset, .. } = &mut f { + *offset = (0.25, -0.1); + } + map_fill(&Affine { m: [0.0, 3.0, -0.5, 0.0, 7.0, 1.0] }, &mut f); + let Fill::Gradient { offset, .. } = f else { panic!("not a gradient") }; + assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}"); +} + fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) { let mut t = None; let mut k = None; From ec83a1117f9fb3c0522670e8642766b26ba79fe1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:20:35 +0200 Subject: [PATCH 086/304] Presets: refuse renaming a group to a name another group has (#6768) renameGroup in the shared preset group editor renamed without checking that the new name was free, so two groups could share a name and a later replace import of that name removed both. It now refuses like the Swatches sibling; renaming a group to its own name stays fine. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/presets/mod.rs | 3 +++ crates/engine/src/presets/tests.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 6c722c62ef..236c2a1328 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -343,6 +343,9 @@ pub(crate) fn edit_groups(groups: &mut Vec>, action: &str, p: let g = req_str(p, "group", cmd)?; let to = req_str(p, "name", cmd)?.to_string(); let i = groups.iter().position(|x| x.name == g).ok_or_else(|| bad(cmd, format!("no group \"{g}\"")))?; + if g != to && groups.iter().any(|x| x.name == to) { + return Err(bad(cmd, format!("a group named \"{to}\" already exists"))); + } groups[i].name = to.clone(); Ok(json!({"group": to})) } diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index dcfc4e94d8..8d95cdb51e 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -317,6 +317,23 @@ fn gradient_new_rename_move_delete_groups() { assert!(s.execute("gradient.presets.edit", json!({"action": "explode"})).is_err()); } +#[test] +fn gradient_rename_group_refuses_a_name_another_group_has() { + let mut s = Session::new(); + s.execute("gradient.presets.edit", json!({"action": "newGroup", "name": "Pastels"})).unwrap(); + let names = |s: &mut Session| -> Vec { + s.execute("gradient.presets.list", json!({})).unwrap()["groups"].as_array().unwrap().iter().map(|g| g["name"].clone()).collect() + }; + let before = names(&mut s); + assert!(before.contains(&json!("Blues"))); + let err = s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Blues"})).unwrap_err(); + assert!(err.to_string().contains("already exists"), "{err}"); + assert_eq!(names(&mut s), before, "a refused rename leaves the groups unchanged"); + // Renaming a group to its own name is fine. + s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Pastels"})).unwrap(); + assert_eq!(names(&mut s), before); +} + #[test] fn presets_persist_through_preferences_json() { let mut s = Session::new(); From 8c6412bbb21c0abb7593dd5b13b4a39fed4925c4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 22:49:26 +0200 Subject: [PATCH 087/304] Plug-ins: running a filter plug-in respects layer locks (#6770) plugin.run wrote into raster and smart object layers without checking locks. It now refuses layers with locked pixels or Lock All like the built-in filters, and with Lock Transparency it keeps the layer's alpha (as built-in filters do, via filters::keep_alpha). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/filters.rs | 2 +- crates/engine/src/plugin_cmds.rs | 7 ++++ crates/engine/src/plugin_cmds/tests.rs | 50 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index e40be7b6ac..221709080e 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -313,7 +313,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result } /// Lock transparency: put the layer's alpha back after a filter, in the tiles it changed. -fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { +pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { if old.format() != new.format() || !new.format().alpha { return; } diff --git a/crates/engine/src/plugin_cmds.rs b/crates/engine/src/plugin_cmds.rs index 5c7d2dd71a..a2044f5e8c 100644 --- a/crates/engine/src/plugin_cmds.rs +++ b/crates/engine/src/plugin_cmds.rs @@ -77,10 +77,17 @@ fn run(s: &mut Session, p: &Value) -> Result { *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; return Ok(()); } + crate::commands::check_pixels_unlocked(doc, layer)?; + let keep_alpha = doc.effective_locks(layer).transparency; let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?; match &mut l.content { LayerContent::Raster(surf) => { + // Lock Transparency: the plug-in may change colour but not alpha (as built-in filters). + let before = keep_alpha.then(|| surf.clone()); *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; + if let Some(before) = &before { + crate::filters::keep_alpha(before, surf); + } Ok(()) } LayerContent::Smart(_) => { diff --git a/crates/engine/src/plugin_cmds/tests.rs b/crates/engine/src/plugin_cmds/tests.rs index 3d178fada9..39bb1acc7d 100644 --- a/crates/engine/src/plugin_cmds/tests.rs +++ b/crates/engine/src/plugin_cmds/tests.rs @@ -155,6 +155,56 @@ fn params_are_validated_and_passed() { Session::new().execute("plugin.remove", json!({"id": "test.cmds.grey"})).unwrap(); } +#[test] +fn locked_layers_refuse_plugin_filters() { + // Like built-in filters: locked pixels (or Lock All) refuse the run with no undo step. + let id = "test.cmds.locks"; + Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, "{}", GREY))})).unwrap(); + for lock_all in [false, true] { + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + let l = doc.layer_mut(layer).unwrap(); + if lock_all { + l.locks.all = true; + } else { + l.locks.pixels = true; + } + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + let r = s.execute("plugin.run", json!({"id": id})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("is locked")), "lock_all {lock_all}: {r:?}"); + assert_eq!(pixels(&s), before, "lock_all {lock_all}"); + s.execute("edit.undo", json!({})).unwrap(); + let l = s.active().unwrap().doc.layer(layer).unwrap(); + assert!(!l.locks.pixels && !l.locks.all, "the refused run left no undo step"); + // Unlocked, the plug-in runs. + s.execute("plugin.run", json!({"id": id})).unwrap(); + assert_ne!(pixels(&s), before); + } + // Lock Transparency: the run changes colour but keeps every pixel's alpha (GREY sets alpha too). + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(layer).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + s.execute("plugin.run", json!({"id": id})).unwrap(); + let after = pixels(&s); + assert_ne!(after, before); + for (a, b) in after.as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) { + assert_eq!(a[3], b[3], "alpha kept"); + if b[3] > 0.0 { + assert!((a[0] - 0.5).abs() < 1e-3, "colour changed: {a:?}"); + } + } + Session::new().execute("plugin.remove", json!({"id": id})).unwrap(); +} + #[test] fn hostile_plugins_fail_without_touching_the_document() { for (id, body) in [ From 2e8d20ca9cd0b9cd2cd4f13151728d20c2ac5879 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 00:24:53 +0300 Subject: [PATCH 088/304] Fix rustfmt on snap_ui smart-target assignment. CI Format step failed on a line break rustc-fmt does not want. Co-authored-by: Cursor --- crates/ui-egui/src/snap_ui.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index b0cb95e2fc..ccdd4fea88 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -228,8 +228,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // A moving selection never snaps back to where it started. targets = targets.filtered(|k| k != SnapKind::Selection); } - let smart = - if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; + let smart = if snap_on(app) && matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); } From cd75fa78efe47736d90542006f52e922eaeef416 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 17:38:42 -0400 Subject: [PATCH 089/304] PSD/PSB: a 16/32-bit file's "Transparency" channel stays the merged transparency on re-save (#4654) Photoshop's 16-bit PSBs can mark the merged transparency only by naming it "Transparency" in the alpha channel names (positive Lr16 layer count, no Mt16). The importer read it as a user alpha channel, and the next save wrote a second one. merged_has_alpha now also accepts that name when the layers sit in a global Lr16/Lr32 block and the names cover every extra channel. Co-Authored-By: Claude Opus 5.5 --- crates/io/tests/psd16_merged_transparency.rs | Bin 0 -> 3511 bytes crates/io/tests/psd32_export.rs | 4 +-- crates/psd/src/file.rs | 36 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 crates/io/tests/psd16_merged_transparency.rs diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/psd16_merged_transparency.rs new file mode 100644 index 0000000000000000000000000000000000000000..3f04970626e7f21e776f84241be0ee3a46d66a99 GIT binary patch literal 3511 zcmdPbS5)vXNX^U4OIJwDOHn9F)h$je1M?IDGV)9Ei!<^I6b#Mujg54ZGD{TFGILTD z$}{qdQx$Ski_%k56iSK`^NI@+i&FEFD-|+}6><}cvQtwO^7C>k6_P5sKo(?{6f5K< z=B6rSr=}JZE0koUDkNtl=H;d4C?w~X=9O51g_J^&Oj3fY$b@Rh&n?I=&MZk)FIGs* zDac6FQOL_LQAh+^P+FW?1QCU6E6GSK0UMi_T2Z1X-Z;7ETG-^Gl18QxyyiOiVR(6jE~wN-7n~GfOh^OG^|oOZ2$X z^1!LCSOb)3trXOt7HBHy+9`ykCffv;6lLb6+bL9YDJUo?~b{oYXuGO{foGzQ|LsRVXz!va(7m%Fm5YN{vsdEJ-Z}`zt!JL{AT5M66y(QDuB) zUP-=&re0}Yc~N44hNh-MVzEMLab{I2#7>xzrFoep#a19^+LRia*(pG5kA|28Gf_cX zAx}X|!6;TQIioZ$yI8|W6A~`TH43GMX6WvTPL4K+)lo=}HjItc)YMDP&&f$mF453L z*qmQlqE}E_oS^}UDX0OZC259c@j3a$#g!UrV25eyl@w*>#;4|`#OEfKBxj@+YpBN< zsB7w#P)X)q$k6Br{85_kS#UMy$ zK_XP6QiWbYaf*_T0+^Acgsc!2H_3^`si5SVUzD3zqNt%%4Yt?HzE+_cWKOLTlGkB+ zKq)UJKUu+60hXB|$y2FB36gUmTnilqGff=@c;R4W6_lQ&1MwIZQ`2%m$u%WES+5|o zA~h#I4eTW37zNo6k_VTN#d_e#g_KMhO2rw81*u9p3Te3|ItrjN1mXrm9R))j1ydab z6HOfjV;zN(qS934*aR5~%DefcMa2rX3eZdrO)HR;ua;T?Dz!D#Qu32^6hMKZqoC#r z7V|GC$;{6ywz5h|O-n4zDbdi>)Jv@>03}2vh%O~fJ+LVV@q)yn;#BldO)M@>Eh>pm zEmYJ1xkN7`H8CZ%NDo#t>nND$C@58f;;Pn40aWUi7Aa_Bt3yEsgVQ3&V<4=rk5VUr z0@VsfZ3C)f6d?7Beu8g_u~7n~Ixd3LGoXqWl4#K_Ku*KpLNf)FZb9n7buFx#14|X_ zq38yMJt#R9fI=Va_2iuVyj1j9FDTXnM|yl_URu6hVsU(KX^93ROu=%+N}75Q_r*i3 zwN*$<%qd1Hi$LK~P^_1plb@8B6A!LJi}i|9OA<5lG-{G+6cnNPUr|FXNiRFKQb!?4 z$+x5^Pf4K$%rZ2Cv5bwB&{Gj43KcaJ3yNXR$Ve=PI7LGfDf;sAOEN%BBv3g}tdLov zP?=f+iZ+nzU>FoEpuz%LXj*{E%xHsB3mpXmFfufb)dLm2@z8oDIRoSelp3EwNiQ`I zQ~}0=i#$-}lap8y52|Xw#c4@Cyl}@}euCUm016*SO$lN_LaGQkq(Bv!j)I|qftf-L zq&7p#+9*bVvn)~r0$g=_<|d}620<%iXtp&lG1UR57hJ(iD3n2|4-|snBn)W;LJ|i` zlM@u~80j&;AT=*FMZs1fGZ&ok3ld8*bQIJIia|93I5#6DJ#e#72`S%0G%0E7rNA3H zppb-NP|SiFoRFA}2SqHzK6qw_wG}`yRg+w!kgNyt2Pn}OmlT0Y7%X)i$aGi&1evav zSsV{;(x9b01&}0KYY|k>gIlM_+1N^<8l=?99$W!n1QDcO1BDUZ5)?tR9#%< zW>G4kk`wAE{Do&Cv^AeulA(~7uaKGo>Ry2BXK)xmOI%3v8q*JGtnjbafr@?frqv{7ogYPkSt)KjVe literal 0 HcmV?d00001 diff --git a/crates/io/tests/psd32_export.rs b/crates/io/tests/psd32_export.rs index 9ff2b4113b..b4707fadb0 100644 --- a/crates/io/tests/psd32_export.rs +++ b/crates/io/tests/psd32_export.rs @@ -226,11 +226,11 @@ fn mt32_marks_the_merged_transparency() { let plane = |v: [f32; 2]| v.into_iter().flat_map(f32::to_be_bytes).collect::>(); file.image_data.data = [plane([0.0, 1.0]), plane([0.0, 1.0]), plane([0.0, 1.0]), plane([1.0, 0.0])].concat(); let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); - file.resources.retain(|r| r.id != 1045); - file.resources.push(photocraft_psd::ImageResource::new(1045, name)); + file.resources.retain(|r| !matches!(r.id, 1006 | 1045)); assert!(!file.merged_has_alpha(), "a positive layer count alone"); file.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"Mt32", Vec::new())); assert!(file.merged_has_alpha()); + file.resources.push(photocraft_psd::ImageResource::new(1045, name)); let merged = merged_composite(&file).unwrap(); assert_eq!(merged.iter().map(|p| p[3]).collect::>(), vec![1.0, 0.0]); let opened = import("transparent.psd", &file.to_bytes().unwrap()).unwrap(); diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index e14bc45aaf..fab262732b 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -354,7 +354,10 @@ impl PsdFile { /// `true` if the merged image carries a transparency channel: its first extra channel, when /// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`, /// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive - /// layer count (psd-tools transparentbg.psd). + /// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency + /// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit + /// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that + /// name with one name per extra channel marks it too. pub fn merged_has_alpha(&self) -> bool { let Some(color) = self.header.color_mode.color_channels() else { return false; @@ -364,11 +367,40 @@ impl PsdFile { } let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); match &self.layer_info { - Some(li) => li.merged_alpha || marked, + Some(li) => { + let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); + li.merged_alpha || marked || (in_block && self.names_merged_transparency(self.header.channels - color)) + } None => true, } } + /// `true` when the alpha channel names (resource 1045, else 1006) hold exactly `extra` names + /// and the first is "Transparency", Photoshop's name for the merged transparency. + fn names_merged_transparency(&self, extra: u16) -> bool { + const NAME: &str = "Transparency"; + let mut names = Vec::new(); + if let Some(r) = self.resource(1045) { + let mut at = 0usize; + while let Some(len) = r.data.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes) { + at = at.saturating_add(4); + let end = (len as usize).checked_mul(2).and_then(|n| at.checked_add(n)); + let Some(units) = end.and_then(|end| r.data.get(at..end)) else { break }; + let units: Vec = units.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + names.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); + at = at.saturating_add(units.len() * 2); + } + } else if let Some(r) = self.resource(1006) { + let mut at = 0usize; + while let Some(&len) = r.data.get(at) { + let Some(name) = r.data.get(at + 1..at + 1 + usize::from(len)) else { break }; + names.push(String::from_utf8_lossy(name).into_owned()); + at += 1 + usize::from(len); + } + } + names.len() == usize::from(extra) && names.first().is_some_and(|n| n == NAME) + } + /// Checks structural invariants that the writer relies on. pub fn validate(&self) -> Result<()> { self.header.validate()?; From 1338d78b454d2004e5f4cf88bcf0465f46a5ab9b Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:42:34 +0200 Subject: [PATCH 090/304] Transform: drop a fill layer's cached render when its frame changes (#6808) Free Transform and Warp moved a fill layer's linked masks (and so the frame its gradient is laid out in) but kept the cached render, which the compositor draws as-is while the Fill is unchanged. Drop the cache in apply_transform and warp_layer, as transform_layer_geometry and Perspective Crop already do. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/transform_cmds.rs | 54 +++++++++++++++++++++++++++++ crates/engine/src/warp_cmds.rs | 3 ++ 2 files changed, 57 insertions(+) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..e3725f4f3e 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -375,6 +375,9 @@ fn apply_transform( // With a selection only the selected pixels move, and so only the same region of a linked // mask (#205). Groups, type, shapes and smart objects move whole. let mask_sel = doc_sel.filter(|_| !moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model (as `transform_layer_geometry` does). + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -971,6 +974,57 @@ mod tests { assert!(!active_bounds(&s).is_empty()); } + /// A gradient fill layer whose mask (a selection when it was made) frames the gradient over + /// 10..40, with the cached render a PSD import gives it: exactly the pixels the fill renders. + fn cached_gradient_fill() -> Session { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.edit("cache", |doc, active| { + let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let LayerContent::Fill(f) = &l.content else { panic!("a fill layer") }; + let buf = photocraft_compose::render_fill_content(l, f, canvas, &[]); + let mut surface = Surface::new(fmt); + let vals: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); + surface.write_region(canvas, &vals); + l.fill_cache = Some(photocraft_doc::FillCache { fill: f.clone(), surface }); + Ok(()) + }) + .unwrap(); + s + } + + /// How far the composite is from the composite with the fill re-rendered from its model + /// (cache dropped): the largest channel difference. + fn off_from_fresh(s: &Session) -> f32 { + let st = s.active().unwrap(); + let mut fresh: Document = (*st.doc).clone(); + fresh.layer_mut(st.active_layer.unwrap()).unwrap().fill_cache = None; + let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).px, photocraft_compose::render(&fresh, fresh.bounds()).px); + a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max) + } + + #[test] + fn transforming_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the cache is the fill's own render: {off}"); + // The mask (and so the gradient's frame) moves 50 px right and doubles in height. + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 2, 50, 0]})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + + #[test] + fn warping_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 50})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + #[test] fn type_moves_with_the_move_command() { let mut s = session(); diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 5142b0a940..8e2ab3c4fd 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -121,6 +121,9 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, // A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform // and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along. let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model. + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { From 156b27d65b8c157535d744ce391ad7b465bd156b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:03:35 +0200 Subject: [PATCH 091/304] Object Selection: enclosed background gaps stay unselected, only object-like holes fill (#6556) --- crates/algo/src/segment/grabcut.rs | 8 +++-- crates/algo/src/segment/tests.rs | 58 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..e1c1d89de0 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u } let (fg, bg) = grabcut(&img, &mut trimap, 8)?; let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect(); - let low = super::clean_mask(&low, lw, lh, 0.1, 0.002); + let low = super::clean_mask(&low, lw, lh, 0.1, 0.0); + // Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg))) } @@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let mut seeds = vec![false; w * h]; seeds[sy * w + sx] = true; let low = super::keep_seeded(&low, &seeds, w, h); - let low = super::clean_mask(&low, w, h, 0.1, 0.01); + let low = super::clean_mask(&low, w, h, 0.1, 0.0); + // As in the rectangle mode: enclosed holes fill only when they look like the object. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); if !low.iter().any(|v| *v) { return None; } diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 743c3dc460..e060913c9b 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -912,3 +912,61 @@ fn select_subject_dump_pictures() { write("ring", &img, None); write("ring-selected", &img, reg.as_ref()); } + +/// A dark square on a light background with a square hole that shows the background colour. +fn square_with_hole(hole: usize) -> RgbImage { + let (w, h) = (400usize, 300usize); + let mut rng = Rng::new(11); + let (x0, y0) = (100usize, 50usize); + RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200; + let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole; + let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }) +} + +#[test] +fn object_select_keeps_small_background_hole_open() { + let img = square_with_hole(8); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn object_at_keeps_small_background_hole_open() { + let img = square_with_hole(16); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn hole_fill_without_specks_fills_object_coloured_holes_only() { + // The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does. + let (w, h) = (40usize, 40usize); + let mut rng = Rng::new(5); + let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y); + let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y); + let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y); + let img = RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }); + let mask: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect(); + // The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour. + let dark: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect(); + let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m); + let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::>(), 3, gmm::DEFAULT_REG).unwrap(); + let (fg, bg) = (fit(fs), fit(bs)); + let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05); + assert!(out[25 * w + 25], "a speck of the object's colour fills"); + assert!(!out[10 * w + 10], "a speck of the background's colour stays open"); +} From b0484eb72cbdf87d3edffafad68d79b755ad8259 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:24:29 +0800 Subject: [PATCH 092/304] Remove extra blank line at merged test boundary --- crates/text/src/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index aa19405ce6..f9206e7faa 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2086,7 +2086,6 @@ fn font_metadata_append_reorder_and_duplicate_indices() { } } - /// A word wider than its paragraph box wraps at the box edge instead of running past it (#6694); /// a normal break opportunity still wins, and a box that fits the word lays out exactly as before. #[test] From 921637322cc623303d40dd902aaa6da11ebac3a0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 01:30:16 +0200 Subject: [PATCH 093/304] Native format: refuse a .pcraft with out-of-range numbers when it is opened (#6862) A number past f32's range (e.g. opacity 1e39) decoded as infinity, so the document opened but could never be saved again; read_manifest now refuses it as corrupt, naming the field. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/format/src/store.rs | 4 ++++ crates/format/tests/corruption.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index d047668cb0..7da5dd6b53 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -163,6 +163,10 @@ pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result< let layers = take_array(&mut v, "/document/layers"); let mut m: Manifest = serde_json::from_value(v)?; m.document.layers = layers_from_json(layers)?; + // A number past f32's range decodes as infinity, which no save could write back (#6862). + if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) { + return Err(FormatError::corrupt(format!("`{path}` is out of range"))); + } Ok(m) } diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index d293abed1e..727b37cb70 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -94,6 +94,24 @@ fn bad_manifest_json() { assert!(matches!(load_from_bytes(&b), Err(FormatError::Json(_)))); } +/// A number past f32's range would load as infinity, and every later save would then fail +/// (#6862): it is refused when the file is opened, naming the value. +#[test] +fn out_of_range_number_refused_on_open() { + let b = rebuild(&sample(), |n, d| { + Some(if n == "manifest.json" { + let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap(); + v["document"]["layers"][0]["opacity"] = serde_json::json!(1e39); + serde_json::to_vec(&v).unwrap() + } else { + d + }) + }); + let Err(e) = load_from_bytes(&b) else { panic!("a manifest with opacity 1e39 opened") }; + assert!(matches!(e, FormatError::Corrupt(_)), "{e}"); + assert!(e.to_string().contains("document.layers[0].opacity"), "{e}"); +} + /// Hostile nesting fails with an error instead of overflowing the stack; nesting as deep as the /// deepest groups a bundle may hold (three JSON levels each) parses, and is then rejected for /// lacking a format version. From c9334c46619ae14762b95eae0ff70215b88280b3 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 19:39:39 -0400 Subject: [PATCH 094/304] Windows: a maximized custom-title-bar window lets an auto-hidden taskbar show (#4845) An auto-hidden taskbar reserves no work area, so the maximize guard left the caption-less window covering the whole monitor and the shell treated it as a full-screen app, never revealing the taskbar. On each monitor edge with an auto-hidden taskbar (found via Shell_TrayWnd / Shell_SecondaryTrayWnd through safe winsafe calls) the window now stops 1 px short of the monitor. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/window_frame.rs | 121 +++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/apps/photocraft/src/window_frame.rs b/apps/photocraft/src/window_frame.rs index e418049d07..56c9be1b6a 100644 --- a/apps/photocraft/src/window_frame.rs +++ b/apps/photocraft/src/window_frame.rs @@ -20,6 +20,11 @@ //! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work //! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move. //! +//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the +//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar +//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard +//! instead stops the window one pixel short of the monitor, as other custom-frame apps do. +//! //! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and //! winsafe can't wrap a raw handle without it, so the window is found by its handle instead. @@ -29,7 +34,20 @@ pub use platform::service; mod platform { use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; use photocraft_ui_egui::WindowFrameFn; - use winsafe::{HWND, HwndPlace, POINT, SIZE, co}; + use winsafe::prelude::Handle; + use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co}; + + /// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive. + type Rect = (i32, i32, i32, i32); + + /// A monitor edge. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Edge { + Left, + Top, + Right, + Bottom, + } /// A guard that keeps the window borderless each frame, or `None` when the app uses the /// system title bar (the window is decorated) or its handle can't be found. @@ -99,8 +117,11 @@ mod platform { return; }; let (m, w) = (mi.rcMonitor, mi.rcWork); - let Some((left, top, right, bottom)) = - clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) + let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)); + // The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window + // covers the whole monitor, which a trimmed maximize no longer does. + let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work) + .or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten()) else { return; }; @@ -153,6 +174,69 @@ mod platform { (corrected != rect).then_some(corrected) } + /// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app. + fn covers(rect: Rect, monitor: Rect) -> bool { + rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3 + } + + /// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary + /// monitor's `Shell_SecondaryTrayWnd`), shown or hidden. + fn taskbar_edges(monitor: Rect) -> Vec { + let mut edges = Vec::new(); + for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] { + let mut after: Option = None; + // One taskbar per monitor; the bound only guards against a misbehaving shell. + for _ in 0..64 { + let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else { + break; + }; + if let Ok(r) = tray.GetWindowRect() { + edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor)); + } + after = Some(tray); + } + } + edges + } + + /// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre, + /// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its + /// edge with a sliver left on screen, so its centre may lie outside the monitor. + fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option { + let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3)); + let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3)); + if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb { + return None; + } + // Doubled centre and distances, so nothing rounds. + let (cx, cy) = (tl + tr, tt + tb); + [(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)] + .into_iter() + .min_by_key(|&(_, distance)| distance) + .map(|(edge, _)| edge) + } + + /// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a + /// taskbar reserves no work area (it auto-hides), so the shell still reveals it there. + /// `None` when there is nothing to trim. + fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option { + if !covers(rect, monitor) { + return None; + } + let (mut l, mut t, mut r, mut b) = rect; + for edge in taskbars { + match edge { + Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?, + Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?, + Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?, + Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?, + _ => {} + } + } + let trimmed = (l, t, r, b); + (l < r && t < b && trimmed != rect).then_some(trimmed) + } + /// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes /// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap /// keep working, as they did with winit 0.27's undecorated windows. @@ -162,7 +246,7 @@ mod platform { #[cfg(test)] mod tests { - use super::{borderless, clamped_rect}; + use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge}; use winsafe::co; #[test] @@ -221,6 +305,35 @@ mod platform { assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged"); assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen"); } + + #[test] + fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() { + // #4845: an auto-hidden taskbar reserves no work area. + let monitor = (0, 0, 1920, 1080); + let rect = (-8, -8, 1928, 1088); + for (edge, expected) in + [(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))] + { + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected)); + assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed"); + } + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar"); + // A taskbar that reserves its work area is the clamp's job. + assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None); + assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen"); + } + + #[test] + fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() { + let monitor = (0, 0, 1920, 1080); + assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown"); + assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden"); + assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top)); + assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left)); + assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right)); + assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor"); + assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow"); + } } } From aaf86e2a9cc64223fbb8a192b3f1b49aa76d5ea5 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 16 Oct 2026 07:39:12 +0700 Subject: [PATCH 095/304] fix(psd): read the split ("quilt") warp of placed layers Photoshop writes a split custom warp as `quiltWarp` in the placed layer's SoLd/SoLE descriptor: a grid of Bezier patches (deformNumCols x deformNumRows control points, patch boundaries in quiltSliceX/Y). Only a single 4x4 `warp` mesh was read, so such smart objects rendered unwarped and in the wrong place. Read the quilt grid into a multi-patch BezierMesh; anything inconsistent is still refused. On two real mockups (a sneaker and a V-neck jersey), the mean difference from another PSD renderer over the design area drops from 14.4 to 0.66/255 and from 13.0 to 0.64/255. Refs #4398. --- crates/io/src/blocks.rs | 201 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 187 insertions(+), 14 deletions(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..c03d7a6b25 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -525,21 +525,24 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, ) } -/// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, -/// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` +/// The warp of a placed layer (`SoLd`/`SoLE` `quiltWarp` or `warp` descriptor): style, bend, +/// distortions, axis, bounds and, for `warpCustom`, the `customEnvelopeWarp` mesh: one 4 × 4 +/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`. Read-only: the +/// raw block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` /// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { if key != b"SoLd" && key != b"SoLE" { return None; } let d = data.get(8..).and_then(parse_prefix_versioned)?; - warp_of(get_desc(&d, "warp")?).flatten() + // A split (quilt) warp is stored under `quiltWarp`; `warp` then holds no usable mesh. + warp_of(get_desc(&d, "quiltWarp").or_else(|| get_desc(&d, "warp"))?).flatten() } -/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners) -/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it -/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4). +/// A `warp` or `quiltWarp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` +/// record's corners) as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves +/// nothing), `None` when it can't be read (an unknown style, missing bounds, a mesh that is +/// neither one 4 × 4 patch nor a consistent quilt grid). fn warp_of(w: &Descriptor) -> Option> { use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; @@ -561,16 +564,76 @@ fn warp_of(w: &Descriptor) -> Option> { _ => None, }; let (xs, ys) = (vals("Hrzn")?, vals("Vrtc")?); - if xs.len() != 16 || ys.len() != 16 { + if xs.len() != ys.len() { return None; } - let mesh = BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points: xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect() }; + let points: Vec<[f64; 2]> = xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect(); + let mesh = if points.len() == 16 && env.get("quiltSliceX").is_none() { + BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points } + } else { + quilt_mesh(w, env, points)? + }; warp.mesh = mesh.is_valid().then_some(mesh); warp.mesh.as_ref()?; } Some((!warp.is_identity()).then_some(warp)) } +/// The control-point grid of a split ("quilt") custom warp. `deformNumCols` × `deformNumRows` +/// control points, three per patch side plus one, in row-major order; `quiltSliceX` and +/// `quiltSliceY` are the patch boundaries in the mesh's own units, first and last at its edges, +/// which become the knots on the unit square. `None` for anything inconsistent. +fn quilt_mesh(w: &Descriptor, env: &Descriptor, points: Vec<[f64; 2]>) -> Option { + // Photoshop's split grid tops out far below this; it only bounds what a file can claim. + const MAX_SIDE: usize = 3 * 64 + 1; + let count = |k: &str| -> Option { + let n = num(w.get(k))?; + (n.is_finite() && n >= 4.0 && n <= MAX_SIDE as f64 && n.fract() == 0.0).then_some(n as usize) + }; + let (cols, rows) = (count("deformNumCols")?, count("deformNumRows")?); + if (cols - 1) % 3 != 0 || (rows - 1) % 3 != 0 || points.len() != cols.checked_mul(rows)? { + return None; + } + // Stored as an object array holding one unit-float list under the same key; a plain list + // of numbers is taken too. + let slices = |k: &str| -> Option> { + match env.get(k)? { + Value::ObjectArray(arr) => match arr.body.get(k)? { + Value::UnitFloats { values, .. } => Some(values.clone()), + Value::List(l) => l.iter().map(|v| num(Some(v))).collect(), + _ => None, + }, + Value::List(l) => l.iter().map(|v| num(Some(v))).collect(), + _ => None, + } + }; + let knots = |s: Vec, patches: usize| -> Option> { + let (first, last) = (*s.first()?, *s.last()?); + let span = last - first; + if s.len() != patches + 1 || !(span.is_finite() && span > 0.0) || !s.windows(2).all(|p| p[1] > p[0]) { + return None; + } + let n = s.len() - 1; + Some( + s.iter() + .enumerate() + .map(|(i, v)| { + if i == 0 { + 0.0 + } else if i == n { + 1.0 + } else { + (v - first) / span + } + }) + .collect(), + ) + }; + let us = knots(slices("quiltSliceX")?, (cols - 1) / 3)?; + let vs = knots(slices("quiltSliceY")?, (rows - 1) / 3)?; + Some(photocraft_geom::warp::BezierMesh { us, vs, points }) +} + /// Text content and transform from a `TySh` block (best effort). pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> { let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8]))); @@ -593,13 +656,12 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { } /// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a -/// single-patch custom warp's 16 control points, which may spill past the source, else the source -/// rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice and stretches the -/// placement. A degenerate or non-finite box falls back to the source rect. +/// custom warp's control points (one patch or a split grid), which may spill past the source, +/// else the source rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice +/// and stretches the placement. A degenerate or non-finite box falls back to the source rect. pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { let rect = [0.0, 0.0, w, h]; - let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.points.len() == 16) - else { + let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else { return rect; }; let inf = f64::INFINITY; @@ -1212,6 +1274,117 @@ mod more_tests { data } + /// `SoLd` data with a split (quilt) custom warp, as Photoshop writes it: a `quiltWarp` + /// descriptor with `deformNumCols` × `deformNumRows` control points and the patch boundaries + /// in `quiltSliceX` / `quiltSliceY`. With `plain_warp`, a `warpNone` `warp` key sits next to it. + fn sold_with_quilt(trnf: [f64; 8], cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]], plain_warp: bool) -> Vec { + use photocraft_psd::descriptor::ObjectArray; + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let bounds = || { + Descriptor::new("Rctn") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(25.0)) + .with("Rght", Value::Double(50.0)) + }; + let slices = |k: &str, v: &[f64]| { + let body = Descriptor::new("null").with(k, Value::UnitFloats { unit: *b"#Pxl", values: v.to_vec() }); + Value::ObjectArray(ObjectArray { prefix: v.len() as u32, body }) + }; + let mesh = Descriptor::new("null") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[1]).collect() }); + let envelope = Descriptor::new("customEnvelopeWarp") + .with("quiltSliceX", slices("quiltSliceX", sx)) + .with("quiltSliceY", slices("quiltSliceY", sy)) + .with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh })); + let quilt = Descriptor::new("quiltWarp") + .with("warpStyle", e("warpStyle", "warpCustom")) + .with("bounds", Value::Descriptor(bounds())) + .with("deformNumRows", Value::Integer(rows as i32)) + .with("deformNumCols", Value::Double(cols)) + .with("customEnvelopeWarp", Value::Descriptor(envelope)); + let mut d = Descriptor::new("null") + .with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1"))) + .with("Trnf", Value::List(trnf.map(Value::Double).to_vec())) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(50.0)).with("Hght", Value::Double(25.0)))); + if plain_warp { + d = d.with( + "warp", + Value::Descriptor(Descriptor::new("warp").with("warpStyle", e("warpStyle", "warpNone")).with("bounds", Value::Descriptor(bounds()))), + ); + } + d = d.with("quiltWarp", Value::Descriptor(quilt)); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data + } + + /// A 3 × 2 patch identity grid over the 50 × 25 source with one interior control point pulled. + fn quilt_points() -> Vec<[f64; 2]> { + let mut pts = photocraft_geom::warp::BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 3, 2).points; + if let Some(p) = pts.get_mut(10 * 3 + 4) { + p[1] += 3.0; + } + pts + } + + /// Photoshop's split warp (`quiltWarp`, mockups' usual warp) reads as a multi-patch mesh: the + /// patch boundaries become the knots, the control points stay in order, and the quilt wins over + /// the `warpNone` `warp` key written next to it. Before, any mesh but 4 × 4 was dropped and the + /// layer re-rendered flat. + #[test] + fn quilt_warp_is_read_as_a_multi_patch_mesh() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + // Photoshop pads the outer boundaries a little past the grid's edges. + let data = sold_with_quilt(quad, 10.0, 7.0, &[-0.5, 12.5, 37.5, 50.5], &[-0.5, 12.5, 25.5], &pts, true); + let warp = parse_placed_warp(b"SoLd", &data).expect("a quilt warp"); + let mesh = warp.mesh.as_ref().expect("its mesh"); + assert_eq!(mesh.points, pts); + assert_eq!(mesh.us.len(), 4); + assert_eq!(mesh.vs.len(), 3); + assert_eq!((mesh.us[0], mesh.us[3], mesh.vs[0], mesh.vs[2]), (0.0, 1.0, 0.0, 1.0)); + assert!((mesh.us[1] - 13.0 / 51.0).abs() < 1e-12 && (mesh.vs[1] - 13.0 / 26.0).abs() < 1e-12); + assert!(mesh.is_valid()); + } + + /// A quilt's control points frame the placed quad as a single patch's do (#3677): a grid + /// spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. + #[test] + fn quilt_warp_control_box_frames_the_quad() { + let mut pts = quilt_points(); + pts[0] = [-10.0, -5.0]; + if let Some(last) = pts.last_mut() { + *last = [60.0, 25.0]; + } + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let data = sold_with_quilt(quad, 10.0, 7.0, &[0.0, 12.5, 37.5, 50.0], &[0.0, 12.5, 25.0], &pts, false); + assert_eq!(parse_smart(b"SoLd", &data).1.m, [2.0, 0.0, 0.0, 2.0, 120.0, 210.0]); + } + + /// An inconsistent quilt reads as no warp rather than a wrong one, and never panics. + #[test] + fn inconsistent_quilt_warps_are_not_read() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + let (sx, sy) = ([0.0, 12.5, 37.5, 50.0], [0.0, 12.5, 25.0]); + let read = + |cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]]| parse_placed_warp(b"SoLd", &sold_with_quilt(quad, cols, rows, sx, sy, pts, false)); + assert!(read(10.0, 7.0, &sx, &sy, &pts).is_some()); + assert!(read(9.0, 7.0, &sx, &sy, &pts).is_none(), "not three points per patch side plus one"); + assert!(read(13.0, 7.0, &sx, &sy, &pts).is_none(), "more points claimed than stored"); + assert!(read(10.0, 7.0, &[0.0, 12.5, 50.0], &sy, &pts).is_none(), "one slice per patch boundary"); + assert!(read(10.0, 7.0, &[0.0, 37.5, 12.5, 50.0], &sy, &pts).is_none(), "slices must increase"); + assert!(read(10.0, 7.0, &[0.0, f64::NAN, 37.5, 50.0], &sy, &pts).is_none()); + assert!(read(1e9, 7.0, &sx, &sy, &pts).is_none(), "an absurd grid is refused before any arithmetic"); + assert!(read(10.0, 7.0, &sx, &sy, &pts[..pts.len() - 1]).is_none()); + let mut nan = pts.clone(); + nan[3] = [f64::NAN, 0.0]; + assert!(read(10.0, 7.0, &sx, &sy, &nan).is_none()); + } + /// Photoshop's `Trnf` is the image of the custom warp's control-point bounding box, not of the /// source rect: a mesh spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. #[test] From e31905358d9a25f1882cb97a575dd8dc91d7c5b7 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:32:44 +0800 Subject: [PATCH 096/304] Restore export preset field omitted by upstream preset loader --- crates/engine/src/presets/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), From 425e782ac2fd4d3f754d9015b6925f44aac4ce30 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 00:25:15 -0400 Subject: [PATCH 097/304] Pen: a finished path keeps its anchor squares on screen (#6884) Closing a path or pressing Enter with the Pen left only the 1 px outline, so the path looked gone and the anchors to edit were hidden. The Pen, Freeform Pen and Curvature Pen now draw the active paths' anchors as Path Selection does, as in Photoshop. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/vector_ui.rs | 43 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..205e47aaf9 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -978,6 +978,14 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a painter.circle_filled(handle, 3.0, accent); } +/// Does `tool` draw the anchor squares of the paths it can edit? Path Selection does, and so do +/// the Pen tools: as in Photoshop, a path finished with Enter or by closing it keeps its anchors +/// on screen, so it can still be edited (⌘/Ctrl-drag, Auto Add/Delete) (#6884, #4716). +pub(crate) fn shows_anchors(tool: Tool) -> bool { + // Add/Delete Anchor Point and Convert Point draw Direct Selection's overlay instead. + matches!(tool, Tool::PathSelection | Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen) +} + /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; @@ -1001,20 +1009,21 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - // Paths remain visible as outlines with any tool. Path Selection adds anchor squares; Direct - // Selection has its own overlay above. + // Paths remain visible as outlines with any tool. Path Selection and the Pen tools add + // anchor squares; Direct Selection has its own overlay above. + let anchors = shows_anchors(tool); if let Some(wp) = &doc.work_path { - draw_path(wp, tool == Tool::PathSelection); + draw_path(wp, anchors); } let st = app.session.active(); if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) && let LayerContent::Shape(sh) = &l.content { - draw_path(&sh.path, tool == Tool::PathSelection); + draw_path(&sh.path, anchors); } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { - draw_path(&p, tool == Tool::PathSelection); + draw_path(&p, anchors); } crate::corner_radius::draw(app, painter, &to_scr, accent); } @@ -2036,6 +2045,30 @@ mod tests { out.shapes.len() } + #[test] + fn pen_keeps_anchors_of_a_finished_path() { + // #6884: a path finished with Enter, or by closing it, lost its anchor squares while the + // Pen stayed active, so it looked gone and could not be edited. + let ctx = egui::Context::default(); + let mut app = app(); + app.ui.tool = Tool::Pen; + let knots = vec![[[10.0, 10.0]; 3], [[80.0, 10.0]; 3], [[80.0, 80.0]; 3]]; + app.ui.pen = Some(PenPath { knots, ..Default::default() }); + pen_commit(&mut app, true); + assert!(app.ui.pen.is_none()); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + let with_pen = overlay_shapes(&app, &ctx); + app.ui.tool = Tool::Brush; + let outline_only = overlay_shapes(&app, &ctx); + assert!(outline_only > 0, "the outline is drawn with any tool"); + // Each of the three anchors adds its square (fill and stroke). + assert!(with_pen >= outline_only + 3, "Pen draws the anchors: {with_pen} vs {outline_only}"); + for tool in [Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::PathSelection] { + assert!(shows_anchors(tool), "{tool:?}"); + } + assert!(!shows_anchors(Tool::Brush) && !shows_anchors(Tool::Move)); + } + #[test] fn show_target_path_and_extras_hide_the_path_outlines() { // #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the From 8f971cc79f1167f1ba4281a8ebe4bb41cf77d552 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 22:22:05 -0700 Subject: [PATCH 098/304] Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off The Mixer Brush could not be emptied. MixerState::clean dropped only the pickup well, paint.mixerBrush refilled the reservoir from the foreground before every stroke unless loadAfterStroke was false, and the app always sent the defaults. A stroke on a new layer over an image always carried the foreground color, and the options bar had no Load Brush or Clean Brush control. Cleaning now empties both wells, so a clean brush lays down only the color it picks up from the canvas. The new commands brush.mixer.load and brush.mixer.clean load and empty the brush. paint.mixerBrush reads loadAfterStroke and cleanAfterStroke from the brush and applies them after the stroke. Both are on by default, and a stroke with both on paints as before. A stroke whose color differs from the one recorded by the last stroke, load or clean loads that color first. A load or clean records the foreground, so choosing a new foreground loads the reservoir. The options bar has the Current Brush Load swatch, whose menu holds Load Brush and Clean Brush, and two toggles for the automatic Load and Clean. Brush Settings shows the same two options. The new strings have rows in every catalog. --- crates/engine/src/brush_cmds.rs | 48 ++++++++++--- crates/engine/src/brush_cmds/tests.rs | 95 +++++++++++++++++++++++++ crates/paint/src/mixer.rs | 81 ++++++++++++++++++++- crates/ui-egui/src/brush_panel_tests.rs | 60 +++++++++++++++- crates/ui-egui/src/brush_sections.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 5 ++ crates/ui-egui/src/i18n/de.tsv | 5 ++ crates/ui-egui/src/i18n/el.tsv | 5 ++ crates/ui-egui/src/i18n/es.tsv | 5 ++ crates/ui-egui/src/i18n/fr.tsv | 5 ++ crates/ui-egui/src/i18n/hu.tsv | 5 ++ crates/ui-egui/src/i18n/id.tsv | 5 ++ crates/ui-egui/src/i18n/it.tsv | 5 ++ crates/ui-egui/src/i18n/ja.tsv | 5 ++ crates/ui-egui/src/i18n/ko.tsv | 5 ++ crates/ui-egui/src/i18n/mod.rs | 15 +++- crates/ui-egui/src/i18n/nl.tsv | 5 ++ crates/ui-egui/src/i18n/pl.tsv | 5 ++ crates/ui-egui/src/i18n/pt-br.tsv | 5 ++ crates/ui-egui/src/i18n/ru.tsv | 5 ++ crates/ui-egui/src/i18n/sr.tsv | 5 ++ crates/ui-egui/src/i18n/tr.tsv | 5 ++ crates/ui-egui/src/i18n/uk.tsv | 5 ++ crates/ui-egui/src/i18n/vi.tsv | 5 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 ++ crates/ui-egui/src/panels.rs | 52 ++++++++++++++ docs/brush-parity.md | 2 +- docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 30 files changed, 443 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index f2c9e28b2c..580b1610d3 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -644,13 +644,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { mix: pct(p, "mix", bm.mix * 100.0), flow: pct(p, "flow", bm.flow * 100.0), sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers), + load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke), + clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke), }; let sample_all = m.sample_all_layers; - let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true)); + let paint = color(p.get("color"), s.tools.foreground); let mut state = s.tools.mixer.clone(); - if load_after || state.reservoir.is_none() { - state.load(color(p.get("color"), s.tools.foreground)); - } + state.begin_stroke(paint); let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Mixer Brush", |doc, _| { @@ -672,13 +672,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) })?; - if clean { - state.clean(); - } + state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke); s.tools.mixer = state; Ok(damage_json(s, dmg)) } +/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke +/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now +/// carries. +fn mixer_load(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.mixer.load"; + let paint = match p.get("color").filter(|v| !v.is_null()) { + None => Some(s.tools.foreground), + Some(Value::String(h)) => crate::commands::parse_hex(h), + Some(Value::Array(a)) => { + let c: Option> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect(); + match c.as_deref() { + Some(&[r, g, b]) => Some([r, g, b, 1.0]), + Some(&[r, g, b, a]) => Some([r, g, b, a]), + _ => None, + } + } + Some(_) => None, + }; + let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?; + s.tools.mixer.load(paint); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + +/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless +/// a different color is chosen first. Returns the paint the brush now carries. +fn mixer_clean(s: &mut Session, _: &Value) -> Result { + s.tools.mixer.clean(); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + /// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour /// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha /// channel or Quick Mask `target` is refused rather than recolouring the layer instead. @@ -1126,11 +1156,13 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##, has_paintable, mixer_brush, true ), + spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true), + spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true), spec!( "paint.colorReplacement", "Color Replacement", diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 5977603f94..965c02f5b8 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -763,6 +763,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() { assert!(rgba(&s, 190, 5)[3] > 0.95); } +/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all +/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off. +fn mixer_over_red() -> Session { + let mut s = session(200, 30); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false}); + s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap(); + s +} + +fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] { + s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap(); + rgba(s, 100, y) +} + +#[test] +fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({})).unwrap(); + let state = s.execute("brush.mixer.clean", json!({})).unwrap(); + assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}"); +} + +#[test] +fn the_automatic_clean_empties_the_reservoir() { + let mut s = mixer_over_red(); + // The brush was never loaded, so the first stroke takes the foreground. + assert!(mixer_row(&mut s, 5)[2] > 0.2); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}"); + // With the automatic Load on as well (the default), the brush is cleaned and then refilled + // after the stroke, so the next one starts with a full reservoir and no pickup. + s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap(); + mixer_row(&mut s, 25); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None)); +} + +#[test] +fn load_brush_after_a_clean_paints_the_foreground_again() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.clean", json!({})).unwrap(); + assert!(mixer_row(&mut s, 5)[2] < 0.01); + let state = s.execute("brush.mixer.load", json!({})).unwrap(); + assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0])); + assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue"); + // A color param loads that color instead of the foreground, and the next stroke paints it. + s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap(); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0)); + assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0])); + let c = mixer_row(&mut s, 25); + assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}"); +} + +#[test] +fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() { + let mut s = mixer_over_red(); + assert!(mixer_row(&mut s, 5)[2] > 0.2); + s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + let c = mixer_row(&mut s, 15); + assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}"); + // The automatic Clean emptied it again, and the foreground did not change. + let c = mixer_row(&mut s, 25); + assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); +} + +#[test] +fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap(); + let before = s.tools.mixer.clone(); + for bad in [ + json!({"color": 5}), + json!({"color": "#zz0000"}), + json!({"color": "#ff"}), + json!({"color": [2, 0, 0]}), + json!({"color": [0, 0]}), + json!({"color": [0, 0, 0, 0, 0]}), + json!({"color": {"r": 1}}), + json!({"color": ["a", 0, 0]}), + ] { + assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}"); + assert_eq!(s.tools.mixer, before, "{bad}"); + } + // Clean Brush takes no params, so anything extra is ignored, without a document too. + let mut empty = Session::new(); + empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap(); + empty.execute("brush.mixer.load", json!({"color": null})).unwrap(); + assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground)); +} + #[test] fn coalesced_set_brush_calls_journal_once_per_gesture() { let mut s = session(20, 20); diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index afb8092cd2..49abb127a1 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -26,11 +26,15 @@ pub struct MixerSettings { pub flow: f32, /// Pick up colour from all visible layers instead of the target layer only. pub sample_all_layers: bool, + /// Load the brush with the stroke color after each stroke (the automatic Load option). + pub load_after_stroke: bool, + /// Empty the brush after each stroke (the automatic Clean option). + pub clean_after_stroke: bool, } impl Default for MixerSettings { fn default() -> Self { - Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false } + Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true } } } @@ -44,18 +48,47 @@ pub struct MixerState { pub amount: f32, /// Colour picked up from the canvas (straight RGBA), None = clean. pub pickup: Option<[f32; 4]>, + /// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush + /// (the foreground). None = no stroke, load or clean yet. + pub color: Option<[f32; 4]>, } impl MixerState { - /// Load the reservoir with a colour (full). + /// Load Brush: fill the reservoir with a color (full). pub fn load(&mut self, color: [f32; 4]) { self.reservoir = Some(color); self.amount = 1.0; } - /// Clean the brush (drop the picked-up colour). + /// Clean Brush: empty the reservoir and the pickup well. pub fn clean(&mut self) { + self.reservoir = None; + self.amount = 0.0; self.pickup = None; } + /// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the + /// reservoir with it. + pub fn begin_stroke(&mut self, color: [f32; 4]) { + if self.color != Some(color) { + self.load(color); + self.color = Some(color); + } + } + /// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load + /// fills the reservoir again. + pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) { + if clean { + self.clean(); + } + if load { + self.load(color); + } + self.color = Some(color); + } + /// The reservoir color the next stroke in `color` starts with (the Current Brush Load + /// swatch); None = a clean brush. + pub fn next_load(&self, color: [f32; 4]) -> Option<[f32; 4]> { + if self.color != Some(color) { Some(color) } else { self.reservoir } + } } /// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself @@ -209,6 +242,48 @@ mod tests { assert!(st.pickup.is_none()); } + #[test] + fn a_clean_brush_paints_only_the_canvas_color_it_picks_up() { + let mut st = MixerState::default(); + st.load([0.0, 0.0, 1.0, 1.0]); + st.pickup = Some([0.0, 1.0, 0.0, 1.0]); + st.clean(); + assert_eq!((st.reservoir, st.amount, st.pickup), (None, 0.0, None)); + // On red, the clean wet brush smears red and lays down none of the blue it was loaded with. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]); + apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false); + let c = s.rgba(100, 10); + assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); + } + + #[test] + fn a_new_color_loads_the_brush_and_the_automatic_options_run_after_the_stroke() { + let (blue, green) = ([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]); + let mut st = MixerState::default(); + // A brush never loaded takes the stroke's color. + assert_eq!(st.next_load(blue), Some(blue)); + st.begin_stroke(blue); + assert_eq!((st.reservoir, st.amount), (Some(blue), 1.0)); + // Clean on, Load off: the next stroke in the same color starts empty. + st.end_stroke(blue, true, false); + assert_eq!(st.next_load(blue), None); + st.begin_stroke(blue); + assert_eq!((st.reservoir, st.pickup), (None, None)); + // Choosing another color loads it. + assert_eq!(st.next_load(green), Some(green)); + st.begin_stroke(green); + assert_eq!((st.reservoir, st.amount), (Some(green), 1.0)); + // Load on, Clean off: the reservoir is full again and the pickup stays. + st.amount = 0.1; + st.pickup = Some(blue); + st.end_stroke(green, false, true); + assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, Some(blue))); + // Both on: a fresh load with no pickup. + st.end_stroke(green, true, true); + assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None)); + } + #[test] fn dry_empty_brush_paints_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 670f442be2..f1e9e50a35 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -97,7 +97,15 @@ fn edit_section(i: usize, b: &mut BrushSettings) { b.transfer.flow = dynamic(0.25, Control::Fade, 0.3); b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1); b.transfer.mix = dynamic(0.45, Control::Fade, 0.2); - b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true }; + b.mixer = paint::MixerSettings { + wet: 0.9, + load: 0.15, + mix: 0.7, + flow: 0.8, + sample_all_layers: true, + load_after_stroke: false, + clean_after_stroke: false, + }; } 7 => { let p = &mut b.pose; @@ -488,6 +496,56 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() { assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true); } +#[test] +fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() { + use egui_kittest::kittest::Queryable; + let mut h = options_bar_harness(crate::state::Tool::MixerBrush); + // A red image, an empty layer above it, a blue foreground, and a brush that samples all layers. + for (id, p) in [ + ("file.new", json!({"width": 200, "height": 40, "background": "transparent"})), + ("edit.fill", json!({"color": "#ff0000"})), + ("layer.new.layer", json!({})), + ("tools.setColors", json!({"foreground": "#0000ff"})), + ("tools.setBrush", json!({"brush": {"size": 8, "pressureSize": false, "mixer": {"sampleAllLayers": true}}})), + ] { + h.state_mut().run(id, p).unwrap(); + } + h.run_steps(3); + for label in ["Current Brush Load", "Load the brush after each stroke", "Clean the brush after each stroke"] { + assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}"); + } + + // Turn the automatic Load off: one tools.setBrush call. + h.get_by_label("Load the brush after each stroke").click(); + h.run_steps(3); + assert!(!h.state().session.tools.brush.mixer.load_after_stroke); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!((id.as_str(), ¶ms["brush"]["mixer"]["loadAfterStroke"]), ("tools.setBrush", &json!(false))); + + // Current Brush Load › Clean Brush empties the brush. + h.get_by_label("Current Brush Load").click(); + h.run_steps(3); + h.get_by_label("Clean Brush").click(); + h.run_steps(3); + assert_eq!(last_journal(h.state()).map(|(id, _)| id), Some("brush.mixer.clean".to_string())); + assert_eq!(h.state().session.tools.mixer.reservoir, None); + + // The stroke the canvas commits on release lays down only the red it picks up. + crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE); + let d = h.state().session.active().unwrap(); + let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20); + assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}"); + // With the automatic Load off, the stroke did not refill the brush either. + assert_eq!(h.state().session.tools.mixer.reservoir, None); + + // Load Brush fills it with the foreground again. + h.get_by_label("Current Brush Load").click(); + h.run_steps(3); + h.get_by_label("Load Brush").click(); + h.run_steps(3); + assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0])); +} + #[test] fn drop_targets_and_actions_reorder_presets() { use crate::brushes_tab::{Action, apply, drop_target, group_key}; diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index e7d3a16f56..9e66f9b0a4 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -565,6 +565,8 @@ fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) { pct(ui, tl!("Mix"), &mut m.mix, 1.0); pct(ui, tl!("Flow"), &mut m.flow, 1.0); widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers"); + widgets::checkbox(ui, &mut m.load_after_stroke, tl!("Load the brush after each stroke")); + widgets::checkbox(ui, &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")); } /// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f50c0249b7..79ec121421 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2171,6 +2171,11 @@ Color Replacement Tool Nástroj Nahrazení barvy Color Replacement Nahrazení barvy Wet Vlhkost + Current Brush Load Aktuální náplň štětce + Load Brush Naplnit štětec + Clean Brush Vyčistit štětec + Load the brush after each stroke Naplnit štětec po každém tahu + Clean the brush after each stroke Vyčistit štětec po každém tahu Mix Míchání Wetness Jitter Kolísání vlhkosti Mix Jitter Kolísání míchání diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b1ea8977e2..19cf26c048 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2140,6 +2140,11 @@ Mixer Brush Tool Mischer-Pinsel-Werkzeug Color Replacement Tool Farbersatz-Werkzeug Wet Feucht + Current Brush Load Aktuelle Pinselladung + Load Brush Pinsel laden + Clean Brush Pinsel reinigen + Load the brush after each stroke Pinsel nach jedem Strich laden + Clean the brush after each stroke Pinsel nach jedem Strich reinigen Mix Mischen Wetness Jitter Feuchtigkeitsschwankung Mix Jitter Mischschwankung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8229806307..39f5a79207 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1984,6 +1984,11 @@ Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool) Color Replacement Αντικατάσταση χρώματος Wet Υγρασία + Current Brush Load Τρέχουσα φόρτωση πινέλου + Load Brush Φόρτωση πινέλου + Clean Brush Καθαρισμός πινέλου + Load the brush after each stroke Φόρτωση του πινέλου μετά από κάθε πινελιά + Clean the brush after each stroke Καθαρισμός του πινέλου μετά από κάθε πινελιά Load Φόρτωση Mix Ανάμειξη 8 bit 8 bit ανά κανάλι diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6f3d85002b..32771121d9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2158,6 +2158,11 @@ Mixer Brush Tool Herramienta Pincel mezclador Color Replacement Tool Herramienta Sustitución de color Wet Húmedo + Current Brush Load Carga actual del pincel + Load Brush Cargar pincel + Clean Brush Limpiar pincel + Load the brush after each stroke Cargar el pincel después de cada trazo + Clean the brush after each stroke Limpiar el pincel después de cada trazo Mix Mezcla Wetness Jitter Variación de humedad Mix Jitter Variación de mezcla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4a819b0e1b..956fbb1a2f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2118,6 +2118,11 @@ Apply the pen's tilt and rotation to the tip shape Appliquer l'inclinaison et la rotation du stylet à la forme de la pointe Mixer Brush Pinceau mélangeur Wet Humidité + Current Brush Load Charge actuelle du pinceau + Load Brush Charger le pinceau + Clean Brush Nettoyer le pinceau + Load the brush after each stroke Charger le pinceau après chaque trait + Clean the brush after each stroke Nettoyer le pinceau après chaque trait Mix Mélange Wetness Jitter Variation de l'humidité Mix Jitter Variation du mélange diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 86a6b30acb..3d33c44243 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1407,6 +1407,11 @@ Warp: Hajlítás: Weight Vastagság Wet Nedvesség + Current Brush Load Az ecset jelenlegi töltete + Load Brush Ecset feltöltése + Clean Brush Ecset tisztítása + Load the brush after each stroke Az ecset feltöltése minden ecsetvonás után + Clean the brush after each stroke Az ecset tisztítása minden ecsetvonás után Wet Edges Nedves szélek Wetness Jitter Nedvesség ingadozása Width Szélesség diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 49777913c1..81d950899c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape Terapkan kemiringan dan rotasi pena ke bentuk ujung Mixer Brush Kuas Pencampur Wet Basah + Current Brush Load Muatan Kuas Saat Ini + Load Brush Muat Kuas + Clean Brush Bersihkan Kuas + Load the brush after each stroke Muat kuas setelah setiap goresan + Clean the brush after each stroke Bersihkan kuas setelah setiap goresan Mix Campur Wetness Jitter Jitter Kebasahan Mix Jitter Jitter Campuran diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f1144c0fdd..79b3f232c5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2153,6 +2153,11 @@ Mixer Brush Tool Strumento Pennello miscelatore Color Replacement Tool Strumento Sostituzione colore Wet Bagnato + Current Brush Load Carico attuale del pennello + Load Brush Carica pennello + Clean Brush Pulisci pennello + Load the brush after each stroke Carica il pennello dopo ogni tratto + Clean the brush after each stroke Pulisci il pennello dopo ogni tratto Mix Miscela Wetness Jitter Variazione bagnato Mix Jitter Variazione miscela diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 351699ea89..9617f848a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2153,6 +2153,11 @@ Mixer Brush Tool 混合ブラシツール Color Replacement Tool 色の置き換えツール Wet ウェット + Current Brush Load 現在のブラシの絵の具 + Load Brush ブラシに補充 + Clean Brush ブラシを洗浄 + Load the brush after each stroke ストロークごとにブラシに補充 + Clean the brush after each stroke ストロークごとにブラシを洗浄 Mix 混合 Wetness Jitter ウェットのジッター Mix Jitter 混合のジッター diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3eaf143979..6e797e001e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape 펜의 기울기와 회전을 브러시 끝 모양에 적용 Mixer Brush 혼합 브러시 Wet 젖음 + Current Brush Load 현재 브러시 물감 + Load Brush 브러시 채우기 + Clean Brush 브러시 세척 + Load the brush after each stroke 획마다 브러시 채우기 + Clean the brush after each stroke 획마다 브러시 세척 Mix 혼합 Wetness Jitter 젖음 지터 Mix Jitter 혼합 지터 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..7476f8d513 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1257,7 +1257,20 @@ mod tests { #[test] fn mixer_brush_ui_strings_have_translations_in_every_registered_language() { - const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"]; + const STRINGS: &[&str] = &[ + "Mixer Brush", + "Mixer Brush Tool", + "Wet", + "Load", + "Mix", + "Flow", + "Sample All Layers", + "Current Brush Load", + "Load Brush", + "Clean Brush", + "Load the brush after each stroke", + "Clean the brush after each stroke", + ]; for lang in Lang::all() { for source in STRINGS { let translated = tr(lang, source); diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 09dd568009..f1fab787f1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2118,6 +2118,11 @@ Mixer Brush Tool Mengpenseelgereedschap Color Replacement Tool Kleurvervangingsgereedschap Wet Nat + Current Brush Load Huidige penseellading + Load Brush Penseel laden + Clean Brush Penseel reinigen + Load the brush after each stroke Penseel laden na elke streek + Clean the brush after each stroke Penseel reinigen na elke streek Mix Mengen Wetness Jitter Nattigheidsvariatie Mix Jitter Mengvariatie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c4827aa829..f5f525f080 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2137,6 +2137,11 @@ Mixer Brush Tool Narzędzie Pędzel mieszający Color Replacement Tool Narzędzie Zamiana koloru Wet Wilgotność + Current Brush Load Bieżący ładunek pędzla + Load Brush Napełnij pędzel + Clean Brush Wyczyść pędzel + Load the brush after each stroke Napełniaj pędzel po każdym pociągnięciu + Clean the brush after each stroke Czyść pędzel po każdym pociągnięciu Mix Mieszanie Wetness Jitter Rozrzut wilgotności Mix Jitter Rozrzut mieszania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f96542d642..1dc356f0cc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape Aplicar a inclinação e a rotação da caneta à forma da ponta Mixer Brush Pincel misturador Wet Molhado + Current Brush Load Carga atual do pincel + Load Brush Carregar pincel + Clean Brush Limpar pincel + Load the brush after each stroke Carregar o pincel após cada traço + Clean the brush after each stroke Limpar o pincel após cada traço Mix Mistura Wetness Jitter Variação da umidade Mix Jitter Variação da mistura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f49200b973..efd6907f4d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1023,6 +1023,11 @@ Mix Смешивание Auto Clean Brush Автоочистка кисти Wet Влага + Current Brush Load Текущая загрузка кисти + Load Brush Загрузить кисть + Clean Brush Очистить кисть + Load the brush after each stroke Загружать кисть после каждого штриха + Clean the brush after each stroke Очищать кисть после каждого штриха Rate Скорость Airbrush: keeps depositing paint while the pointer rests. Аэрограф: продолжает наносить краску, пока курсор неподвижен. Pulled String Mode Режим натянутой нити diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c207facb72..3ba821cd97 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2095,6 +2095,11 @@ Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha Mixer Brush Četkica za mešanje Wet Vlažnost + Current Brush Load Trenutno punjenje četkice + Load Brush Napuni četkicu + Clean Brush Očisti četkicu + Load the brush after each stroke Napuni četkicu posle svakog poteza + Clean the brush after each stroke Očisti četkicu posle svakog poteza Mix Mešanje Wetness Jitter Kolebanje vlažnosti Mix Jitter Kolebanje mešanja diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8aa0c3c58..9648c631ac 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2092,6 +2092,11 @@ Mixer Brush Tool Karıştırıcı Fırça Aracı Color Replacement Tool Renk Değiştirme Aracı Wet Islaklık + Current Brush Load Geçerli Fırça Yükü + Load Brush Fırçayı Yükle + Clean Brush Fırçayı Temizle + Load the brush after each stroke Her vuruştan sonra fırçayı yükle + Clean the brush after each stroke Her vuruştan sonra fırçayı temizle Mix Karışım Wetness Jitter Islaklık Titreme Mix Jitter Karıştırma Titreşimi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 48fb60343c..6246957cc7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1000,6 +1000,11 @@ Mix Змішування Auto Clean Brush Автоматично очищувати пензель Wet Вологість + Current Brush Load Поточне завантаження пензля + Load Brush Завантажити пензель + Clean Brush Очистити пензель + Load the brush after each stroke Завантажувати пензель після кожного штриха + Clean the brush after each stroke Очищати пензель після кожного штриха Rate Частота Airbrush: keeps depositing paint while the pointer rests. Аерограф: продовжує наносити фарбу, коли вказівник нерухомий. Pulled String Mode Режим натягнутої нитки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f33ef15011..38585fa2cc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1296,6 +1296,11 @@ Mixer Brush Cọ trộn Mixer Brush Tool Công cụ Cọ trộn Wet Ướt + Current Brush Load Lượng sơn hiện tại của cọ + Load Brush Nạp sơn cho cọ + Clean Brush Làm sạch cọ + Load the brush after each stroke Nạp sơn cho cọ sau mỗi nét vẽ + Clean the brush after each stroke Làm sạch cọ sau mỗi nét vẽ Load Tải Mix Trộn Flow Lưu lượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 75eacc994d..570aada058 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2219,6 +2219,11 @@ cameraRaw Blending 混合 Apply the pen's tilt and rotation to the tip shape 将笔的倾斜和旋转应用于笔尖形状 Mixer Brush 混合器画笔 Wet 湿润 + Current Brush Load 当前画笔载入 + Load Brush 载入画笔 + Clean Brush 清理画笔 + Load the brush after each stroke 每次描边后载入画笔 + Clean the brush after each stroke 每次描边后清理画笔 Mix 混合 Wetness Jitter 湿润度抖动 Mix Jitter 混合抖动 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 95a11dc593..4e6d901201 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2158,6 +2158,11 @@ Color Replacement Tool 顏色取代工具 Color Replacement 顏色取代 Wet 濕度 + Current Brush Load 目前筆刷載入 + Load Brush 載入筆刷 + Clean Brush 清理筆刷 + Load the brush after each stroke 每次描邊後載入筆刷 + Clean the brush after each stroke 每次描邊後清理筆刷 Mix 混合 Wetness Jitter 濕度快速變換 Mix Jitter 混合快速變換 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6fc8e2d8f4..6de63cdff6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1078,6 +1078,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); + mixer_load_swatch(app, ui); + let m = &mut b.mixer; + for (icon, on, tip) in [ + ("brush", &mut m.load_after_stroke, tl!("Load the brush after each stroke")), + ("brush-cleaning", &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")), + ] { + if icons::button(ui, icon, 24.0, *on, tip).clicked() { + *on = !*on; + } + } + widgets::vline(ui, 22.0); for (label, value) in [ (tl!("Wet"), &mut b.mixer.wet), (tl!("Load"), &mut b.mixer.load), @@ -3906,6 +3917,47 @@ fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings }); } +/// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an +/// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush. +fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::hover()); + let resp = ui.interact(r, ui.id().with("mixer-brush-load"), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, t.radius_sm, t.hover); + } + let chip = Rect::from_min_size(pos2(r.left() + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); + let p = ui.painter(); + match app.session.tools.mixer.next_load(app.session.tools.foreground) { + Some(c) => { + p.rect_filled(chip, 2.0, c32(c)); + } + None => { + p.rect_filled(chip, 2.0, t.field); + p.line_segment([chip.left_bottom(), chip.right_top()], Stroke::new(1.0, t.text_faint)); + } + } + p.rect_stroke(chip, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Current Brush Load"))); + let resp = resp.on_hover_text(tl!("Current Brush Load")); + let mut run = None; + egui::Popup::menu(&resp).show(|ui| { + if ui.button(tl!("Load Brush")).clicked() { + run = Some("brush.mixer.load"); + } + if ui.button(tl!("Clean Brush")).clicked() { + run = Some("brush.mixer.clean"); + } + }); + if let Some(id) = run + && let Err(e) = app.run(id, json!({})) + { + app.ui.status = e; + app.ui.status_error = true; + } +} + /// Options-bar brush chip: a click shows or hides Photoshop's Brush Preset picker below it (size, /// hardness, the preset library), the one a right-click on the canvas opens. fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, state: &mut crate::state::UiState) { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 05697f9e6a..5c75a3e55f 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -91,7 +91,7 @@ tablets against Photoshop side by side. | Tool | Status | Notes | |---|---|---| | Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 | -| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) | +| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) | | Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) | | Pattern Stamp (aligned, impressionist) | done | | | History Brush | done | Art History Brush missing | diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..e8ae8961c2 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | -| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | +| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | | TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..efe2bb159e 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -17,7 +17,7 @@ id = "TOOL-213-2" target = "Mixer Brush tool on the toolbar" status = "done" issue = 213 -note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests" +note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests" [[item]] id = "TOOL-213-3" From dd57638d9ecd6c88edfcfcd8d07af7f2f21960fc Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sat, 17 Oct 2026 10:11:57 +0100 Subject: [PATCH 099/304] fix(psd): make packbits::decode_into crate-private and overflow-safe decode_into computed out.len() + expected unchecked. With a non-empty buffer and an expected length near usize::MAX it panicked in debug builds and, in release, wrapped and returned Ok having decoded nothing. Compute the target with checked_add and report LimitExceeded, and test each packet as len > target - out.len() so the bound checks cannot overflow either. The only callers are decode and decode_rle in this module, and the crate README documents packbits::{encode, decode} as the public API, so decode_into becomes pub(crate). Co-Authored-By: Claude Opus 5.5 --- crates/psd/src/compression.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index aab785dff4..68782464ad 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -111,9 +111,10 @@ pub mod packbits { /// /// Errors if the input is exhausted early or a packet would overflow the /// row. Trailing input after the row is complete is ignored except for - /// `-128` no-op bytes (which are always permitted). - pub fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { - let target = out.len() + expected; + /// `-128` no-op bytes (which are always permitted). Errors, rather than + /// overflowing, when `out.len() + expected` does not fit in `usize`. + pub(crate) fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { + let target = out.len().checked_add(expected).ok_or(PsdError::LimitExceeded("PackBits output length overflows usize"))?; let mut i = 0; while out.len() < target { let Some(&h) = src.get(i) else { @@ -126,7 +127,7 @@ pub mod packbits { let Some(lit) = src.get(i..i + len) else { return Err(PsdError::Decompress("PackBits literal truncated".into())); }; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits literal overflows row".into())); } out.extend_from_slice(lit); @@ -138,7 +139,7 @@ pub mod packbits { return Err(PsdError::Decompress("PackBits run truncated".into())); }; i += 1; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits run overflows row".into())); } out.extend(std::iter::repeat_n(b, len)); @@ -687,6 +688,20 @@ mod tests { assert!(packbits::decode(&[3, 1, 2, 3, 4], 3).is_err()); // literal overflows } + /// `out.len() + expected` past `usize::MAX` used to panic in debug builds and, in release, + /// wrap and return `Ok` having decoded nothing. + #[test] + fn packbits_decode_into_output_length_overflow_is_an_error() { + let mut out = vec![7u8]; + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX, &mut out); + assert!(matches!(r, Err(PsdError::LimitExceeded(_))), "{r:?}"); + assert_eq!(out, [7]); + // The largest `expected` that fits still decodes up to the input, then reports it short. + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX - 1, &mut out); + assert!(matches!(r, Err(PsdError::Decompress(_))), "{r:?}"); + assert_eq!(out, [7, 0xaa]); + } + #[test] fn packbits_zero_expected_ignores_input() { assert_eq!(packbits::decode(&[0xff, 1], 0).unwrap(), Vec::::new()); From 158dcc673197132bfd0404d0c2c5c7b05cc30111 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:30:14 +0000 Subject: [PATCH 100/304] Fix engine compile error: load export presets from presets JSON Fixes #6911 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 (cherry picked from commit bb7a5f23091fb4d23c02d253ea78b20f10f142fb) --- crates/engine/src/presets/mod.rs | 1 + crates/engine/src/presets/tests.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index b7c0a3ede2..1cde39abe5 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -774,3 +774,21 @@ fn a_damaged_preset_set_keeps_the_other_sets() { assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len()); assert_eq!(next.presets.documents, s.presets.documents); } + +// ------------------------------------------------------------------ export presets + +#[test] +fn export_presets_survive_a_presets_json_round_trip() { + let mut s = Session::new(); + s.execute("file.export.presets.save", json!({"name": "Web", "settings": {"format": "jpg", "quality": 70}})).unwrap(); + assert_eq!(s.presets.export_presets.len(), 1); + + let saved = s.presets.to_json(&s); + assert_eq!(saved["export_presets"].as_array().map(Vec::len), Some(1)); + + let mut restored = Session::new(); + restored.load_presets_json(saved); + assert_eq!(restored.presets.export_presets.len(), 1); + assert_eq!(restored.presets.export_presets[0].name, "Web"); + assert_eq!(restored.presets.export_presets[0].settings.format, "jpg"); +} From fd2c10bba851ae94fa72a50ebb235e1ec2052a59 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:12:17 +0200 Subject: [PATCH 101/304] Artboards to Files: paths, notes, slices and marks move with the board (#7010) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/artboard_cmds.rs | 5 ++++- crates/engine/src/artboard_cmds/tests.rs | 27 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index c3e3e079f7..b612c7c604 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -9,7 +9,7 @@ use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Artboard, ArtboardBackground, Document, Layer, LayerContent, LayerId, Size}; -use photocraft_geom::Rect; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -504,6 +504,9 @@ pub fn artboard_document(doc: &Document, id: LayerId) -> Result one.layer_comps.clear(); one.last_applied_comp = None; one.last_document_state = None; + // Paths, notes, slices and Count/Ruler marks move with the board. This also shifts + // `one.guides`, but they are rebuilt below from the source's, so nothing moves twice. + crate::canvas_geom::transform_doc_marks(&mut one, &Affine::translate(f64::from(dx), f64::from(dy))); let shift = |v: &[f64], o: i32, len: u32| v.iter().map(|g| g - f64::from(o)).filter(|g| *g > 0.0 && *g < f64::from(len)).collect::>(); one.guides.vertical = shift(&doc.guides.vertical, r.x0, r.width()); one.guides.horizontal = shift(&doc.guides.horizontal, r.y0, r.height()); diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index c7695f228e..f3053e21fd 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -331,6 +331,33 @@ fn export_rejects_a_board_too_far_from_the_origin() { assert_eq!(one.artboards()[0].2.rect, Rect::new(0, 0, 50, 50)); } +/// Artboards to Files moves paths, notes, slices and Count/Ruler marks with the board, like its +/// layers and guides, so they land on the same pixels in the exported file. +#[test] +fn artboard_export_moves_paths_notes_slices_and_marks_with_the_board() { + use photocraft_doc::{CountGroup, NamedPath, Note, Path, Slice, Subpath}; + let mut s = session(8); + s.execute("layer.new.artboard", json!({"x": 200, "y": 100, "width": 50, "height": 50})).unwrap(); + s.edit("marks", |d, _| { + d.paths.push(NamedPath { name: "P".into(), path: Path::new(vec![Subpath::polygon(&[(210.0, 110.0), (240.0, 110.0), (240.0, 140.0)])]), psd_raw: None }); + d.work_path = Some(Path::new(vec![Subpath::polygon(&[(205.0, 105.0), (215.0, 105.0), (215.0, 115.0)])])); + d.notes.push(Note { position: [220.0, 120.0], popup: [220.0, 120.0, 260.0, 160.0], ..Default::default() }); + d.slices.list.push(Slice { id: 1, rect: Rect::new(210, 110, 230, 130), ..Default::default() }); + d.measurement.count_groups.push(CountGroup { points: vec![[225.0, 125.0]], ..Default::default() }); + Ok(()) + }) + .unwrap(); + let one = artboard_document(doc(&s), doc(&s).artboards()[0].0).unwrap().unwrap(); + let k = &one.paths[0].path.subpaths[0].knots[0].anchor; + assert_eq!((k.x, k.y), (10.0, 10.0)); + let w = &one.work_path.as_ref().unwrap().subpaths[0].knots[0].anchor; + assert_eq!((w.x, w.y), (5.0, 5.0)); + assert_eq!(one.notes[0].position, [20.0, 20.0]); + assert_eq!(one.notes[0].popup, [20.0, 20.0, 60.0, 60.0]); + assert_eq!(one.slices.list[0].rect, Rect::new(10, 10, 30, 30)); + assert_eq!(one.measurement.count_groups[0].points, vec![[25.0, 25.0]]); +} + /// #1531: a board 40×40 at (10, 10) holding a red square at (10..30)², the artboard active. fn board_with_square(s: &mut Session) -> LayerId { add_square(s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); From 7ebaa94a821a51254b22561f685828dd33635992 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 15:44:43 +0200 Subject: [PATCH 102/304] Render Video: GIF frame delay follows timelines under 1 fps (#7045) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/video_cmds.rs | 4 +++- crates/engine/src/video_cmds/tests.rs | 33 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 6fb9ab4574..a4ed4f840f 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -424,7 +424,9 @@ pub fn render_video_with( if frames > crate::timeline_cmds::MAX_DURATION { return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); } - let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); + // Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are + // valid timelines, so only an unusable loaded rate falls back to 30. + let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); let fmt = base.pixel_format(); let bounds = base.bounds(); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index c16c7f8e39..d9d8e451e1 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -151,6 +151,39 @@ fn a_fully_transparent_animated_gif_frame_encodes() { assert!(f.indices.iter().all(|&i| Some(i) == f.transparent)); } +/// The GIF frame delays (centiseconds, from each Graphic Control Extension) of a Render Video. +fn rendered_gif_delays(s: &Session) -> Vec { + let mut gif = Vec::new(); + render_video_with( + s, + &json!({"format": "gif"}), + |_, bytes| { + gif = bytes.to_vec(); + Ok(()) + }, + |_, _| true, + ) + .unwrap(); + gif.windows(6).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| u16::from_le_bytes([w[4], w[5]])).collect() +} + +#[test] +fn animated_gif_delay_follows_timelines_under_one_fps() { + // 0.5 fps is a valid timeline rate; its frames last 2 s, not the 1 s a 1 fps floor gave. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 2, "fps": 0.5})).unwrap(); + assert_eq!(rendered_gif_delays(&s), [200, 200]); + // A loaded timeline with an unusable rate falls back to 30 fps (3 cs), as with no timeline. + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(2, f32::NAN)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + assert_eq!(rendered_gif_delays(&s), [3, 3]); +} + #[test] fn inspect_reports_video_layer() { let dir = tmpdir("vinsp"); From fcd337e1fc7bd0a154c05e859a1395c3450f99f8 Mon Sep 17 00:00:00 2001 From: ebosido444-ai <316167330+ebosido444-ai@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:51 +0300 Subject: [PATCH 103/304] perf(text): rasterize a type layer's pixels on all cores, bit for bit the same A type render spends most of its time after the glyphs are drawn: composing each colour's coverage over the premultiplied accumulator, dividing it back to straight colour, and converting the tile bytes. All three are per pixel - no pixel reads a slot another wrote - so they ran one after another on one core for no reason. Measured on a 3.75 MP paragraph (4000 characters, 14 pt at 300 dpi, 1851 glyphs), release, Windows 11, i5-10300H: rasterize 98.3-103.9 ms before, 60.3-61.8 ms after (1.6-1.7x). Whole-render stage profile before: ink rect 4.1, draw 18.6-22.9, prefix sum 7.0-8.1, composite 22.6-33.4, unpremultiply 11.2-14.0, write_region 23.8, prune 3.1 ms. The glyph drawing, the winding prefix sum and the ink-rect pass stay on one thread: the first two carry a running accumulator across pixels, and the band split that would keep them bit-identical costs a second glyph traversal per band. Native only (rayon, as the other crates): the web build keeps the serial loops. --- Cargo.lock | 2 + crates/raster/Cargo.toml | 4 ++ crates/raster/src/lib.rs | 88 +++++++++++++++++++++------------ crates/text/Cargo.toml | 2 + crates/text/src/render.rs | 47 +++++++++++++----- crates/text/src/tests.rs | 34 ++++++++++++- docs/text-raster-performance.md | 60 ++++++++++++++++++++++ 7 files changed, 193 insertions(+), 44 deletions(-) create mode 100644 docs/text-raster-performance.md diff --git a/Cargo.lock b/Cargo.lock index 0167dea3b1..b0afa75133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4944,6 +4944,7 @@ dependencies = [ "photocraft-color", "photocraft-geom", "proptest", + "rayon", "serde", ] @@ -4996,6 +4997,7 @@ dependencies = [ "photocraft-geom", "photocraft-psd", "photocraft-raster", + "rayon", "skrifa", ] diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 956d1c9545..41b188a94f 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -12,5 +12,9 @@ photocraft-geom = { workspace = true } photocraft-color = { workspace = true } serde = { workspace = true } +# Native only: converting a region's tiles is independent per tile, so it runs on all cores. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 20b809764a..883c244aa5 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -166,6 +166,55 @@ impl PartialEq for Surface { } } +/// Converts the `data` span covering one tile into that tile's bytes. +fn write_tile(t: &mut Tile, tc: TileCoord, r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + let tr = tc.rect().intersect(&r); + // Convert contiguous channel samples a row at a time. Dispatching on depth here removes the + // per-pixel channel loop and lets the compiler vectorise conversion. + let span = tr.width() as usize * n; + for y in tr.y0..tr.y1 { + let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; + let src = &data[o..o + span]; + let bytes = sample.bytes(); + let dst = &mut t.data[base * bytes..(base + span) * bytes]; + match sample { + SampleType::U8 => { + for (d, v) in dst.iter_mut().zip(src) { + *d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + SampleType::U16 => { + for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) { + let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; + d.copy_from_slice(&q.to_ne_bytes()); + } + } + SampleType::F32 => { + for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) { + d.copy_from_slice(&v.to_ne_bytes()); + } + } + } + } +} + +/// Converts the tiles of a region write. A tile holds no state another tile reads, so the tiles +/// are converted on all cores; each writes only its own bytes, in the same arithmetic. +#[cfg(not(target_arch = "wasm32"))] +fn convert_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + use rayon::prelude::*; + tiles.par_iter_mut().for_each(|(tc, t)| write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample)); +} + +/// Serial counterpart, for the web build (there are no threads to spread tiles over). +#[cfg(target_arch = "wasm32")] +fn convert_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + for (tc, t) in tiles.iter_mut() { + write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample); + } +} + impl Surface { /// Reads a region into a newly allocated buffer, returning an error instead of aborting if /// the requested pixel buffer cannot be represented or reserved. @@ -579,38 +628,13 @@ impl Surface { let w = r.width() as usize; assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); let sample = self.format.sample; - for tc in r.tiles() { - let tr = tc.rect().intersect(&r); - let t = self.tile_mut(tc); - // Convert contiguous channel samples a row at a time. Dispatching on depth here - // removes the per-pixel channel loop and lets the compiler vectorise conversion. - let span = tr.width() as usize * n; - for y in tr.y0..tr.y1 { - let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; - let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - let src = &data[o..o + span]; - let bytes = sample.bytes(); - let dst = &mut t.data[base * bytes..(base + span) * bytes]; - match sample { - SampleType::U8 => { - for (d, v) in dst.iter_mut().zip(src) { - *d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - } - } - SampleType::U16 => { - for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) { - let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; - d.copy_from_slice(&q.to_ne_bytes()); - } - } - SampleType::F32 => { - for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) { - d.copy_from_slice(&v.to_ne_bytes()); - } - } - } - } - } + // Each tile's own span of `data` is converted on its own: the tiles are disjoint, so + // converting them in any order (and on any thread) writes exactly the same bytes. + // `take_tiles` reserves the whole result before removing anything, and gives the missing + // tiles as one shared default-filled tile, copied per tile by `Arc::make_mut` below. + let mut tiles = self.take_tiles(r); + convert_tiles(&mut tiles, r, data, w, n, sample); + self.put_tiles(tiles); } /// Take the tiles covering `r` out of the surface (missing ones filled with the default diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 985f627e90..0c251d1639 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -25,8 +25,10 @@ flate2 = { workspace = true } # line breaking. Native only: the CJK dictionary alone is 3.8 MB of wasm, and the web build must # stay under Cloudflare's 25 MiB per-file cap (packaging/web/package.sh). The web build falls back # to the generic Unicode rules for those scripts. +# Native only: the glyph rasteriser composites and converts its pixel buffers on all cores. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] } +rayon = { workspace = true } [build-dependencies] # Deflates the bundled fonts (build.rs); `flate2` above inflates them. diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index af29dd10d6..5cf6baea12 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -12,6 +12,9 @@ use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; use crate::warp::{Placement, Warp}; +#[cfg(not(target_arch = "wasm32"))] +use rayon::prelude::*; + /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; /// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look). @@ -276,35 +279,57 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); - for (i, &cv) in cov.iter().enumerate() { - let cv = if antialias == AntiAlias::None { if cv >= 0.5 { 1.0 } else { 0.0 } } else { cv }; + composite(&mut acc, &cov, w, stride, &comps, a, antialias); + } + // Unpremultiply. + unpremultiply(&mut acc, n, stride, format.sample); + surface.write_region(rect, &acc); + surface.prune(); + Rendered { surface, rect } +} + +/// Composites one colour's coverage over the premultiplied accumulator, resolving `AntiAlias::None` +/// to 0/1 first. A pixel's maths reads and writes only its own slots, so the rows are done in any +/// order — one per thread — for exactly the same bytes. +fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) { + let n = comps.len(); + let row = |acc: &mut [f32], cov: &[f32]| { + for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) { + let cv = if antialias == AntiAlias::None { if *cv >= 0.5 { 1.0 } else { 0.0 } } else { *cv }; let s = cv * a; if s <= 0.0 { continue; } - let px = &mut acc[i * stride..(i + 1) * stride]; let keep = 1.0 - s; for j in 0..n { px[j] = comps[j] * s + px[j] * keep; } px[n] = s + px[n] * keep; } - } - // Unpremultiply. - for px in acc.chunks_exact_mut(stride) { + }; + #[cfg(not(target_arch = "wasm32"))] + acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c)); + #[cfg(target_arch = "wasm32")] + acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c)); +} + +/// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone. +fn unpremultiply(acc: &mut [f32], n: usize, stride: usize, sample: SampleType) { + let one = |px: &mut [f32]| { let a = px[n]; if a > 0.0 { for v in &mut px[..n] { let straight = *v / a; // Float text can carry HDR and signed colours. Keep integer output bounded // and retain the old handling of non-finite intermediate values. - *v = if format.sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; + *v = if sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; } } - } - surface.write_region(rect, &acc); - surface.prune(); - Rendered { surface, rect } + }; + #[cfg(not(target_arch = "wasm32"))] + acc.par_chunks_exact_mut(stride).for_each(one); + #[cfg(target_arch = "wasm32")] + acc.chunks_exact_mut(stride).for_each(one); } /// One element of a glyph outline in document space (see [`outlines`]). diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 894ecf4e4f..c055e1881d 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1,6 +1,6 @@ use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; +use photocraft_doc::text::{AntiAlias, Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; use photocraft_geom::Affine; use crate::{TextEngine, fonts}; @@ -13,6 +13,38 @@ fn styled(text: &str, style: CharStyle) -> TextLayer { TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } +/// The rasteriser's pixel stages — composing one colour's coverage, unpremultiplying it, and +/// converting the tiles — are per pixel: no pixel reads a slot another pixel wrote. Spreading them +/// over threads therefore has to leave every byte as it was, which a one-thread pool checks. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn parallel_raster_matches_a_single_thread() { + let one = rayon::ThreadPoolBuilder::new().num_threads(1).build().expect("one-thread pool"); + let mut e = TextEngine::new(); + let boxed = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 90.0 }; + let paragraph = TextLayer { + text: "A paragraph box that wraps over several lines and covers many tiles, so the whole raster is exercised.".into(), + font_family: "Inter".into(), + size_pt: 18.0, + shape: boxed, + ..Default::default() + }; + let no_aa = TextLayer { text: "No AA edge".into(), font_family: "Inter".into(), size_pt: 30.0, antialias: AntiAlias::None, ..Default::default() }; + let cases: Vec<(TextLayer, f32, PixelFormat)> = vec![ + (point("Parallel", 48.0), 72.0, PixelFormat::RGBA8), + (styled("Bold italic", CharStyle { size_pt: 40.0, faux_bold: true, faux_italic: true, ..Default::default() }), 300.0, PixelFormat::RGBA8), + (no_aa, 150.0, PixelFormat::RGBA16), + (styled("Wide gamut", CharStyle { size_pt: 36.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }), 96.0, PixelFormat::RGBA32F), + (with_para(paragraph, ParagraphStyle::default()), 144.0, PixelFormat::RGBA8), + ]; + for (layer, dpi, format) in cases { + let (_, spread) = e.render(&layer, dpi, format); + let (_, single) = one.install(|| e.render(&layer, dpi, format)); + assert_eq!(spread.rect, single.rect, "{}", layer.text); + assert_eq!(spread.surface, single.surface, "{}", layer.text); + } +} + #[test] fn raster_preserves_hdr_and_signed_float_text_colors() { let mut e = TextEngine::new(); diff --git a/docs/text-raster-performance.md b/docs/text-raster-performance.md new file mode 100644 index 0000000000..87ccdf849f --- /dev/null +++ b/docs/text-raster-performance.md @@ -0,0 +1,60 @@ +# Text rasterization: what a type render costs + +Measured 2026-10-17 on Windows 11, `i5-10300H` (4 cores / 8 threads, 16 GB), release build, +`rustc 1.98.1`, with a probe that renders one paragraph layer through +`photocraft_text::render::rasterize_warped` and times each stage of it. + +Document: 300 dpi, a 2400 × 1600 px paragraph box holding 4000 characters at 14 pt — the worst +case a type edit has (a whole page of text at print resolution; 1851 laid-out glyphs, 2329 × 1610 px +of ink = 3.75 MP). + +| Stage | ms | What it does | +|---|---:|---| +| ink rectangle (`ink_rect_warped`) | 4.1 | its own pass over every glyph, to size the surface | +| draw (`draw_pass` into `Coverage`) | 18.6–22.9 | glyph outlines, curve flattening (0.03 px tolerance), winding scanlines | +| prefix sum (`Coverage::finish`) | 7.0–8.1 | resolves the winding accumulator to coverage 0…1, one running sum over 3.75 M pixels | +| composite | 22.6–33.4 | each colour's coverage over the premultiplied accumulator | +| unpremultiply | 11.2–14.0 | divides the accumulator back to straight colour | +| `Surface::write_region` | 23.8 | converts to the document format and fills the tiles | +| `Surface::prune` | 3.1 | drops tiles that came back the layer's default | +| **total** | **86–104** | | + +So the glyph geometry — what one would expect to dominate — is under a quarter of it. The rest is +scalar work over the pixel buffers, and every one of those passes is per pixel: a pixel's maths +reads and writes only its own slots. + +## What runs in parallel now + +`composite`, `unpremultiply` and the tile conversion of `write_region` run on all cores (rayon, +native only; the web build keeps the serial loops, since wasm has no threads). Nothing about them +is order-dependent, so each pixel still sees the same operations on the same inputs — the pixels +are identical, not merely close. + +| | before | after | +|---|---:|---:| +| the 3.75 MP case above | 98.3–103.9 ms | 60.3–61.8 ms | + +`crates/text/src/tests.rs::parallel_raster_matches_a_single_thread` renders the shapes the pipeline +can take (plain, faux bold italic, no antialias, wide gamut, a wrapping paragraph box; 8/16/32-bit) +on a one-thread pool and on the default pool and compares the surfaces; `crates/raster`'s existing +region proptests compare `write_region` against its serial reference implementation, tile for tile. + +## What stays on one thread, and why + +- **draw** (19–23 ms): the winding accumulator carries across scanlines and rows — a pixel's value + depends on the rows above it, and one scanline can spill into the next row's first pixel. A band + split can be exact (bands keep absolute coordinates, carry the spill in band order, and are + assembled before the prefix sum), but each band re-traverses every glyph — the traversal is not + free, so the win is bounded by the band count. Worth doing when the draw matters more than it does + now. +- **prefix sum** (7–8 ms): a running f32 sum over the whole image. Splitting it per row needs the + row's starting sum, which is that same sequential chain — the only exactly-known starting sums are + the ones the chain computes. +- **ink rectangle** (4.1 ms): a second full traversal into a bounds sink. Per-glyph bounds reduce by + min/max, which is associative and exact, so this is a candidate for the same treatment as the + stages above (a glyph-range split, not a band split). + +## Related + +Byte-identical output matters here beyond taste: text pixels reach a PSD's saved cache and the +round-trip corpora. `cargo xtask test-corpus` and the Photoshop oracle tests are the gate. From 3bca0123b04f46f3092b348005b34dac2e9274a7 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:12:29 +0200 Subject: [PATCH 104/304] Type: the blink-phase test finds the caret by its strokes arrow_keys_reset_caret_blink_phase (#5512) looked for a 1.5-wide line, the old caret. The caret is now CARET_STROKES (a 3 pt white halo and a 1 pt black line), so the test matches those. --- crates/ui-egui/src/type_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 88119f917f..44c0c9b033 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2732,7 +2732,7 @@ mod tests { draw_overlay(app, &painter, &xf); }); out.textures_delta.clear(); - out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { stroke, .. } if (stroke.width - 1.5).abs() < 1e-4)) + out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { stroke, .. } if CARET_STROKES.contains(stroke))) }; // Before resetting, the caret is in the off phase at t = 10.5 s (10500 / 530 = 19, odd phase) From 8c1244981911217896ad29e5b8bba290470c86b7 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 10:43:09 -0600 Subject: [PATCH 105/304] Save: keep the replaced file's metadata (#6603, #7073, #7094, #7095, #7096) Saving writes a temporary file and renames it over the target, so everything attached to the old file went with the file that was unlinked. Several of those are security decisions, so a save quietly undid them: a download lost its Mark of the Web, an explicit ACL fell back to whatever the folder hands down. New crates/format/src/atomic/meta.rs, reached from atomic_write through two AtomicFs methods (copy_permissions became copy_metadata; replace_existing is new and defaults to rename, so the failure-injecting test file systems are unchanged): - Extended attributes (#6603): flistxattr/fgetxattr/fsetxattr through safe rustix, per attribute, best effort. macOS Finder tags, com.apple.quarantine (the Mark of the Web's macOS counterpart) and Linux user.* all ride along. Attributes the system owns refuse to be written and are skipped; com.apple.decmpfs is skipped deliberately, since it describes storage the new bytes do not have. - macOS ACL (#7073): a new macOS-only exacl dependency (MIT). fcopyfile(COPYFILE_ACL) is an unsafe fn and fclonefileat(CLONE_ACL) is APFS-only and would mean writing every save as clone-then-truncate; the reasoning is in docs/decisions.md. A file with no ACL is left alone. - Windows (#7094, #7096): ReplaceFileW through winsafe's kernel feature (MIT, no dependencies, a safe wrapper; the workspace forbids writing the FFI call here). One call keeps the destination's DACL, its named streams including Zone.Identifier, and its creation time. - Automation write path (#7095): AuthorizedWorkspace::write copies the replaced file's attributes through the two cap-std descriptors, never a path, so a request path still cannot escape the write root. Permissions there remain #4963; ReplaceFileW is path-based and so is not used on that path. The order inside copy_metadata matters: attributes and ACL first, permission bits last. Writing an attribute needs the destination open for writing, so copying a 0444 mode onto the temporary file first locks you out of your own temporary file. Covered by a regression test. Atomicity is not weakened. The metadata step runs on the temporary file before the swap, never touches bytes, and never fails a save. Windows is the one place the swap itself changed, and a backup file name is passed because it is a correctness requirement, not an option: ReplaceFileW without one documents ERROR_UNABLE_TO_MOVE_REPLACEMENT as leaving the replaced file deleted and the replacement under its temporary name, which is a failed save that loses the user's document. With a backup name the same failure leaves both files alone. ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, the one error that does move things, is unwound by hand; every other error falls back to the plain rename, i.e. to the previous behaviour. THE WINDOWS PATHS ARE UNVERIFIED ON WINDOWS. They were written and reviewed on a Mac. The #[cfg(windows)] tests -- Zone.Identifier, a second named stream, icacls output, creation time -- have never executed. They do compile and pass clippy -D warnings against x86_64-pc-windows-msvc, which is as far as a Mac gets. CI's Windows job is the first real run, and #7094 in particular (Mark of the Web surviving a replace) is the whole point of the change and is the part no one has observed working. Tested on macOS: a real chmod +a entry and a real Finder tag both survive a save (checked with ls -le and xattr, and as unit tests), plus a 64 KiB attribute value, a read-only file, the mode, and the automation path. Still open: Linux POSIX ACLs are not carried over (system.posix_acl_access is not reported by listxattr, so it needs its own step; not part of these issues), and macOS creation time is still reset by a save (that half of #6603 needs setattrlist, which has no safe wrapper here). --- Cargo.lock | 41 ++-- crates/automation/Cargo.toml | 4 + crates/automation/src/workspace.rs | 47 +++- crates/format/Cargo.toml | 11 + crates/format/src/atomic.rs | 43 +++- crates/format/src/atomic/meta.rs | 273 ++++++++++++++++++++++ crates/format/src/atomic/meta/tests.rs | 305 +++++++++++++++++++++++++ crates/format/src/atomic/tests.rs | 4 +- docs/decisions.md | 27 +++ 9 files changed, 728 insertions(+), 27 deletions(-) create mode 100644 crates/format/src/atomic/meta.rs create mode 100644 crates/format/src/atomic/meta/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0167dea3b1..2a5c18084d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ "maybe-owned", "rustix 1.1.5", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1891,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1929,6 +1929,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exacl" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae" +dependencies = [ + "bitflags 2.13.2", + "log", + "scopeguard", + "uuid", +] + [[package]] name = "exr" version = "1.74.2" @@ -3028,7 +3040,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3690,7 +3702,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4436,7 +4448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4693,6 +4705,7 @@ dependencies = [ "photocraft-geom", "photocraft-io", "rmcp", + "rustix 1.1.5", "schemars", "serde", "serde_json", @@ -4818,6 +4831,7 @@ version = "0.9.0" dependencies = [ "blake3", "crc32fast", + "exacl", "flate2", "libc", "photocraft-codecs", @@ -4831,6 +4845,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "winsafe", ] [[package]] @@ -5469,7 +5484,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5988,7 +6003,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6055,7 +6070,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6693,7 +6708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6920,10 +6935,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7323,7 +7338,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8083,7 +8098,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index fb718e5668..f5e88b3591 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -30,3 +30,7 @@ cap-std = "4" [dev-dependencies] rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } + +# Reading back an extended attribute the write path should have preserved (#7095). +[target.'cfg(unix)'.dev-dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index f73a702385..77f728ce04 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -74,6 +74,13 @@ impl AuthorizedWorkspace { /// as [`photocraft_format::atomic_write`], through the directory capability). On failure /// the previous file is untouched and the temporary file is removed. /// + /// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` — + /// onto the file that takes its place (#7095), through the two open descriptors rather than + /// their paths, so nothing here reaches outside the write root to finish a write. The rest + /// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission + /// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this + /// path may not use. + /// /// The parent directory must already exist. `cap-std` performs path /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. @@ -85,10 +92,20 @@ impl AuthorizedWorkspace { let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); options.write(true).create_new(true); - let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { + let written = root.dir.open_with(&tmp, &options).and_then(|file| { + let mut file = file.into_std(); file.write_all(bytes)?; + #[cfg(unix)] + { + use std::os::fd::AsFd; + // 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 _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd()); + } + } // Falls back to a plain fsync on shares that refuse a full flush (#1336). - photocraft_format::atomic::sync_file(&file.into_std()) + photocraft_format::atomic::sync_file(&file) }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { @@ -572,6 +589,32 @@ mod tests { assert!(temp_files(&inside).is_empty()); } + /// #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(unix)] + #[test] + fn write_keeps_the_replaced_files_extended_attributes() { + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + let (inside, _, workspace) = roots("xattr"); + let path = inside.join("doc.psd"); + workspace.write("doc.psd", b"original").unwrap(); + if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() { + return; // a file system without extended attributes: nothing to assert + } + + workspace.write("doc.psd", b"replacement, longer").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer"); + let mut buf = [0u8; 64]; + let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped"); + assert_eq!(&buf[..len], b"tagged"); + assert!(temp_files(&inside).is_empty()); + } + #[cfg(unix)] #[test] fn write_into_read_only_folder_keeps_the_original() { diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 113d9ba026..345ff73caf 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -34,5 +34,16 @@ rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } [target.'cfg(target_vendor = "apple")'.dependencies] libc = "0.2" +# Replacing a file keeps its ACL (#7073). `fcopyfile(COPYFILE_ACL)` is an `unsafe fn` and +# `fclonefileat(CLONE_ACL)` is APFS-only; exacl is a safe wrapper over acl_get_file/acl_set_file. +[target.'cfg(target_os = "macos")'.dependencies] +exacl = "0.13" + +# `ReplaceFileW` is how Windows replaces a file without dropping its DACL, named streams +# (Zone.Identifier, the Mark of the Web) and creation time (#7094, #7096). winsafe's `kernel` +# feature is a dependency-free safe wrapper; the workspace forbids writing the FFI call here. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "0.0.29", features = ["kernel"] } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index bf39fe2761..e635b88257 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -21,8 +21,15 @@ //! this module is never reached there; on `wasm32` every call returns the `std` "unsupported" //! error. //! +//! Replacing a file also carries its metadata across — permission bits, extended attributes, +//! the "downloaded from the internet" mark and the access-control list — so that saving a +//! document does not quietly undo a security decision someone made about the file. That part is +//! [`meta`], and it is best effort: it never fails a save and never touches the bytes. +//! //! [`atomic_write_with`] takes an [`AtomicFs`] so tests can inject failures at each step. +pub mod meta; + use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -44,9 +51,16 @@ pub trait AtomicFs { fn remove(&self, path: &Path) -> io::Result<()>; /// Flush a directory entry change (Unix); a no-op elsewhere. fn sync_dir(&self, dir: &Path) -> io::Result<()>; - /// Copy the destination's permissions onto the temporary file, so replacing a file keeps its - /// mode. Best effort. - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Copy the metadata a replacement should keep — mode, extended attributes, ACL — from the + /// file about to be replaced onto the temporary file. Best effort; see [`meta`]. + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Replace the **existing** file `to` with `from`. Everywhere but Windows this is + /// [`rename`](Self::rename); on Windows it keeps the destination's DACL, named streams and + /// creation time ([`meta::replace_existing`]). The default is `rename`, which is what the + /// failure-injecting test file systems want. + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + self.rename(from, to) + } } /// The real file system. @@ -87,9 +101,11 @@ impl AtomicFs for RealFs { Ok(()) } } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - let perms = std::fs::metadata(from)?.permissions(); - std::fs::set_permissions(to, perms) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::copy_metadata(from, to) + } + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::replace_existing(from, to) } } @@ -190,12 +206,19 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & } let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?; - // Keep the existing file's mode (e.g. 0600) instead of the process default. - if target.exists() { - let _ = fs.copy_permissions(&target, &tmp); + // Keep what the file being replaced carried: its mode (e.g. 0600) instead of the process + // default, its extended attributes, and its ACL. Best effort, and before the swap, so the + // file the user ends up with never passes through a more permissive state. + let replacing = target.exists(); + if replacing { + let _ = fs.copy_metadata(&target, &tmp); } - if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) { + // Replacing an existing file goes through `replace_existing`, which on Windows is the one + // call that can keep the destination's DACL and named streams; creating a new one is a + // plain rename, with nothing to preserve. + let swap = || if replacing { fs.replace_existing(&tmp, &target) } else { fs.rename(&tmp, &target) }; + if let Err(e) = retry_rename(retry, swap) { let _ = fs.remove(&tmp); return Err(rename_error(e, path)); } diff --git a/crates/format/src/atomic/meta.rs b/crates/format/src/atomic/meta.rs new file mode 100644 index 0000000000..6c7cfbc21e --- /dev/null +++ b/crates/format/src/atomic/meta.rs @@ -0,0 +1,273 @@ +//! What a replacement keeps from the file it replaces. +//! +//! Saving writes a new file and renames it over the old one, so the user gets a file with the +//! right bytes and none of the old file's history: everything the operating system and other +//! programs had attached to the file that was just unlinked is gone. Some of it is a security +//! decision — *this came from the internet*, *only these people may read it* — so a save that +//! drops it quietly weakens the user's protections (#6603, #7073, #7094, #7095, #7096). +//! +//! | | macOS | Linux | Windows | +//! |---|---|---|---| +//! | permission bits | [`copy_metadata`] | [`copy_metadata`] | [`copy_metadata`] (read-only flag) | +//! | extended attributes (Finder tags, `user.*`) | `copy_xattrs` | `copy_xattrs` | — | +//! | "downloaded from the internet" | `com.apple.quarantine`, an xattr | — | `Zone.Identifier`, a named stream kept by [`replace_existing`] | +//! | access-control list | `copy_acl` (`exacl`) | not carried over (see below) | DACL, kept by [`replace_existing`] | +//! | creation time | not carried over (#6603) | — | kept by [`replace_existing`] | +//! +//! Everything here except [`replace_existing`] is **best effort and never touches the file's +//! bytes**: a save that cannot carry a piece of metadata across is still a save, because a +//! document the user can open matters more than a Finder tag. [`replace_existing`] *is* the +//! replacement step, so it holds the atomicity guarantee instead: it never leaves the +//! destination missing, and it falls back to a plain rename rather than fail a save. +//! +//! Not carried over, and why: Linux POSIX ACLs live in `system.posix_acl_access`, which +//! `listxattr` does not report, so copying the listed attributes misses them; they would need +//! their own step. macOS creation time needs `setattrlist`, which has no safe wrapper here. + +use std::io; +use std::path::Path; + +#[cfg(unix)] +use std::os::fd::BorrowedFd; + +/// Largest attribute-name list [`copy_xattrs`] will read. Names are short; a file with a +/// megabyte of them is not something a save should spend memory on. +#[cfg(unix)] +const MAX_XATTR_NAMES: usize = 1 << 20; + +/// Largest single attribute value [`copy_xattrs`] will carry over. Finder tags and `user.*` +/// attributes are tens of bytes; a resource fork can be large, and past this it is not worth +/// holding a copy of it in memory during a save. +#[cfg(unix)] +const MAX_XATTR_VALUE: usize = 16 << 20; + +/// First read size for an attribute list or value; grown on `ERANGE`. +#[cfg(unix)] +const XATTR_CHUNK: usize = 4 << 10; + +/// Copy, best effort, everything [`crate::atomic::atomic_write`] should carry from the file it +/// is about to replace (`from`) onto the temporary file that will replace it (`to`). +/// +/// Errors are the caller's to ignore: each piece is attempted on its own, and the first failure +/// is returned only so a caller that wants to log one can. +pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + // The permission bits go on last. Writing an attribute needs the destination open for + // writing, and replacing a read-only (0444) file would copy that mode onto the temporary + // file and then be unable to touch it again. + #[cfg(unix)] + let attributes = copy_xattrs_at(from, to); + #[cfg(not(unix))] + let attributes = Ok(()); + + #[cfg(target_os = "macos")] + let acl = copy_acl(from, to); + #[cfg(not(target_os = "macos"))] + let acl = Ok(()); + + // A save over a 0600 file must not widen it to whatever the process default is. + let permissions = std::fs::metadata(from).and_then(|m| std::fs::set_permissions(to, m.permissions())); + + attributes.and(acl).and(permissions) +} + +/// [`copy_metadata`]'s extended-attribute step, by path: open both files and hand the +/// descriptors to [`copy_xattrs`]. +#[cfg(unix)] +fn copy_xattrs_at(from: &Path, to: &Path) -> io::Result<()> { + use std::os::fd::AsFd; + let old = std::fs::File::open(from)?; + // Writing an attribute needs write access to the file, not only to its directory. + let new = std::fs::OpenOptions::new().write(true).open(to)?; + copy_xattrs(old.as_fd(), new.as_fd()) +} + +/// Copy every extended attribute of `from` onto `to`, best effort, through the two open files +/// rather than their paths. +/// +/// Descriptors, not paths, because the automation write path holds capability-checked handles +/// and must never reach back out to an ambient path to finish a write (#7095). The same reason +/// makes this the right shape for [`copy_metadata`] too. +/// +/// An attribute that cannot be read or cannot be set is skipped: `com.apple.provenance` and +/// `com.apple.rootless` on macOS and `security.*` on Linux belong to the system and refuse to +/// be written by an ordinary process, and failing a document save over one would be absurd. +/// `Err` means the attribute *list* could not be read, so nothing was copied. +#[cfg(unix)] +pub fn copy_xattrs(from: BorrowedFd<'_>, to: BorrowedFd<'_>) -> io::Result<()> { + use rustix::fs::{XattrFlags, fsetxattr}; + + for name in list_xattrs(from)?.split(|b| *b == 0) { + if name.is_empty() || skip_xattr(name) { + continue; + } + let Some(value) = read_xattr(from, name) else { continue }; + let _ = fsetxattr(to, name, &value, XattrFlags::empty()); + } + Ok(()) +} + +/// Attributes that describe how the *old* file's bytes are stored rather than anything about +/// the document, and so must never be pinned onto a file holding different bytes. +#[cfg(unix)] +fn skip_xattr(name: &[u8]) -> bool { + // `com.apple.decmpfs` marks a file whose data lives compressed inside the attribute itself. + // The kernel hides it from a plain `listxattr` and refuses to set it, but a file claiming + // compressed storage it does not have would read as garbage, so never try. + name == b"com.apple.decmpfs" +} + +/// The NUL-separated attribute names of `fd`. +#[cfg(unix)] +fn list_xattrs(fd: BorrowedFd<'_>) -> io::Result> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::flistxattr(fd, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Ok(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_NAMES => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_NAMES), 0); + } + Err(e) => return Err(e.into()), + } + } +} + +/// The value of one attribute, or `None` if it cannot be read or is larger than +/// [`MAX_XATTR_VALUE`]. A missing value is normal: another process may remove an attribute +/// between the listing and the read. +#[cfg(unix)] +fn read_xattr(fd: BorrowedFd<'_>, name: &[u8]) -> Option> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::fgetxattr(fd, name, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Some(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_VALUE => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_VALUE), 0); + } + Err(_) => return None, + } + } +} + +/// Copy an explicit macOS access-control list (`chmod +a`) from `from` onto `to` (#7073). +/// +/// `fcopyfile(COPYFILE_ACL)` is the obvious call and is an `unsafe fn`, which this workspace +/// forbids; `fclonefileat` with `CLONE_ACL` is safe but only works on APFS and would mean +/// writing every save as a clone-then-truncate. `exacl` is a small, safe wrapper over +/// `acl_get_file`/`acl_set_file` that works on every file system that has ACLs at all. +/// +/// An old file with no ACL leaves `to` alone: the new file may have picked up the folder's +/// inherited entries, and taking access away is a worse surprise than leaving it as the folder +/// says it should be. +#[cfg(target_os = "macos")] +fn copy_acl(from: &Path, to: &Path) -> io::Result<()> { + let entries = exacl::getfacl(from, None)?; + if entries.is_empty() { + return Ok(()); + } + exacl::setfacl(&[to], &entries, None) +} + +/// Replace the **existing** file `to` with `from`, keeping what the destination owns rather than +/// its bytes. +/// +/// Everywhere but Windows this is exactly [`std::fs::rename`], and the metadata is carried by +/// [`copy_metadata`] onto `from` beforehand. On Windows it is `ReplaceFileW`, which preserves +/// the replaced file's DACL (#7096), its named streams — `Zone.Identifier`, the Mark of the Web +/// (#7094) — its creation time, object identifier, compression and encryption, in the one call +/// Microsoft documents for the save-through-a-temporary-file pattern. None of those have a safe +/// path-based API to copy by hand. +pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { + #[cfg(windows)] + { + windows_replace(from, to) + } + #[cfg(not(windows))] + { + std::fs::rename(from, to) + } +} + +/// `ReplaceFileW`, with the plain rename as the fallback. See [`replace_existing`]. +/// +/// **A backup name is not optional.** `ReplaceFile` without one deletes the replaced file before +/// the final rename, and documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT`: "the replaced file no +/// longer exists and the replacement file exists under its original name" — the user's document +/// gone, its replacement left under a hidden temporary name. With a backup name the same failure +/// leaves both files where they were. So this always passes one, and deletes it on the way out. +/// +/// The failures and what each leaves behind (from the `ReplaceFileW` reference): +/// +/// - `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` (1177) is the only one that moves anything: the +/// replacement keeps its name but has already inherited the destination's streams and +/// attributes, and the destination's old content is at the backup name. Finishing the rename +/// by hand completes the save *with* its metadata; if even that fails, the backup goes back to +/// the destination so the user's file is where they left it. +/// - Every other error leaves the replaced and replacement files under their own names, so a +/// plain rename still saves the document — without the metadata, which is where this started. +#[cfg(windows)] +fn windows_replace(from: &Path, to: &Path) -> io::Result<()> { + use winsafe::co::REPLACEFILE; + + /// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`: the destination now lives under the backup name. + const MOVE_REPLACEMENT_2: i32 = 1177; + + let backup = backup_path(to); + let paths = (to.to_str(), from.to_str(), backup.as_deref().and_then(Path::to_str)); + // `ReplaceFileW` takes `&str`; a path that is not valid Unicode, or one past MAX_PATH, is + // the plain rename's job. + let (Some(replaced), Some(replacement), Some(backup_str)) = paths else { + return std::fs::rename(from, to); + }; + + let flags = REPLACEFILE::IGNORE_MERGE_ERRORS | REPLACEFILE::IGNORE_ACL_ERRORS; + match winsafe::ReplaceFile(replaced, replacement, Some(backup_str), flags) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match std::fs::rename(from, to) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(rename_failed) => { + if let Some(backup) = backup.as_deref() { + let _ = std::fs::rename(backup, to); + } + Err(rename_failed) + } + }, + Err(_) => { + remove_backup(backup.as_deref()); + std::fs::rename(from, to) + } + } +} + +/// A unique name beside `to` for `ReplaceFile` to park the replaced file under. It shares the +/// temporary-file suffix, so the save's own cleanup and the tests' leftover check cover it. +#[cfg(windows)] +fn backup_path(to: &Path) -> Option { + let name = to.file_name()?.to_string_lossy().into_owned(); + let dir = match to.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + Some(dir.join(super::temp_name(&name))) +} + +#[cfg(windows)] +fn remove_backup(backup: Option<&Path>) { + if let Some(backup) = backup { + let _ = std::fs::remove_file(backup); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs new file mode 100644 index 0000000000..8bf5689039 --- /dev/null +++ b/crates/format/src/atomic/meta/tests.rs @@ -0,0 +1,305 @@ +//! What a save keeps from the file it replaces. +//! +//! The Unix tests run for real. The Windows ones exercise `ReplaceFileW` through +//! [`crate::atomic::atomic_write`] and can only run on Windows, so they are `#[cfg(windows)]` +//! and are covered by the Windows CI job, not by a developer on a Mac. + +use crate::atomic::atomic_write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct TempDir(PathBuf); +impl TempDir { + fn new(tag: &str) -> Self { + static N: AtomicU64 = AtomicU64::new(0); + let d = std::env::temp_dir().join(format!("pc-meta-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Self(d) + } + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// No temporary or backup file is left beside the saved document. +fn assert_no_temp(dir: &Path) { + let leftovers: Vec<_> = + std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect(); + assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}"); +} + +// ---------------------------------------------------------------- extended attributes (Unix) + +#[cfg(unix)] +mod xattrs { + use super::*; + + /// macOS wants a reverse-DNS name; Linux only lets an unprivileged process write `user.*`. + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + fn set(path: &Path, name: &str, value: &[u8]) -> bool { + rustix::fs::setxattr(path, name, value, rustix::fs::XattrFlags::empty()).is_ok() + } + + fn get(path: &Path, name: &str) -> Option> { + let mut buf = vec![0u8; 1 << 20]; + let len = rustix::fs::getxattr(path, name, buf.as_mut_slice()).ok()?; + buf.truncate(len); + Some(buf) + } + + /// #6603: the attributes of the replaced file — a Finder tag, a `user.*` attribute — are on + /// the file the user is left with. + #[test] + fn replacing_a_file_keeps_its_extended_attributes() { + let d = TempDir::new("xattr"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + // tmpfs and some CI file systems have no xattr support; nothing to assert. + return; + } + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "the replaced file's attribute was dropped"); + assert_no_temp(&d.0); + } + + /// A large value still comes across (and is not silently truncated). + #[test] + fn a_large_attribute_value_survives() { + let d = TempDir::new("xattr-big"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + // Comfortably past the first read buffer, so the grow-on-ERANGE path runs. + let value = vec![0xABu8; 64 << 10]; + if !set(&p, TAG, &value) { + return; + } + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(get(&p, TAG), Some(value)); + } + + /// Copying the mode must not get in the way of copying the attributes: a read-only file is + /// still writable to us *before* its mode lands on the temporary file, and only then. + #[test] + fn a_read_only_file_keeps_its_attributes_and_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("xattr-ro"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + return; + } + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap(); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "a read-only file lost its attribute"); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o444); + } + + /// Creating a file that was not there sets no attributes and does not fail. + #[test] + fn creating_a_new_file_needs_no_source() { + let d = TempDir::new("xattr-new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert!(get(&p, TAG).is_none()); + } + + /// The descriptor-based copy is what the automation write path uses (#7095): it works on two + /// open files and never looks at a path. + #[test] + fn copy_xattrs_works_on_descriptors() { + use std::os::fd::AsFd; + let d = TempDir::new("xattr-fd"); + let (from, to) = (d.join("from"), d.join("to")); + std::fs::write(&from, b"a").unwrap(); + std::fs::write(&to, b"b").unwrap(); + if !set(&from, TAG, b"carried") { + return; + } + + let old = std::fs::File::open(&from).unwrap(); + let new = std::fs::OpenOptions::new().write(true).open(&to).unwrap(); + super::super::copy_xattrs(old.as_fd(), new.as_fd()).unwrap(); + + assert_eq!(get(&to, TAG).as_deref(), Some(&b"carried"[..])); + assert_eq!(std::fs::read(&to).unwrap(), b"b", "copying attributes must not touch the bytes"); + } +} + +// --------------------------------------------------------------------------- mode (Unix) + +/// The mode was already kept before any of this; it still is. +#[cfg(unix)] +#[test] +fn replacing_a_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("mode"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); +} + +// ----------------------------------------------------------------------------- ACL (macOS) + +#[cfg(target_os = "macos")] +mod acl { + use super::*; + + /// #7073: an ACL entry added with `chmod +a` is still there after a save. + #[test] + fn replacing_a_file_keeps_its_acl() { + let d = TempDir::new("acl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // The same thing `chmod +a` does: one explicit entry for the user running the test, so + // no second account has to exist. + let Ok(user) = std::env::var("USER") else { return }; + let entries = [exacl::AclEntry::allow_user(&user, exacl::Perm::READ | exacl::Perm::WRITE, None)]; + if exacl::setfacl(&[&p], &entries, None).is_err() { + // A file system without ACL support (a RAM disk, some CI volumes). + return; + } + let before = exacl::getfacl(&p, None).unwrap(); + assert!(!before.is_empty(), "the test could not set an ACL to begin with"); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(exacl::getfacl(&p, None).unwrap(), before, "the replaced file's ACL was dropped"); + assert_no_temp(&d.0); + } + + /// A file with no ACL keeps having no ACL, rather than gaining an empty one. + #[test] + fn a_file_without_an_acl_is_left_alone() { + let d = TempDir::new("acl-none"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = exacl::getfacl(&p, None).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(exacl::getfacl(&p, None).unwrap(), before); + } +} + +// --------------------------------------------------------------- named streams and ACL (Windows) + +/// Unverified on a Mac: these run only on Windows (CI's Windows job). +#[cfg(windows)] +mod windows { + use super::*; + + /// Open a named stream of `path`, e.g. `Zone.Identifier`. + fn stream(path: &Path, name: &str) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(":"); + s.push(name); + PathBuf::from(s) + } + + /// #7094: a file marked as downloaded from the internet is still marked after a save, so the + /// shell and other applications keep warning about it. + #[test] + fn replacing_a_file_keeps_its_mark_of_the_web() { + let d = TempDir::new("motw"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let motw = b"[ZoneTransfer]\r\nZoneId=3\r\n"; + std::fs::write(stream(&p, "Zone.Identifier"), motw).unwrap(); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(std::fs::read(stream(&p, "Zone.Identifier")).unwrap(), motw, "the Mark of the Web was dropped"); + assert_no_temp(&d.0); + } + + /// Any other named stream rides along too, because `ReplaceFileW` keeps the lot. + #[test] + fn replacing_a_file_keeps_other_named_streams() { + let d = TempDir::new("ads"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::write(stream(&p, "photocraft.test"), b"side data").unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::read(stream(&p, "photocraft.test")).unwrap(), b"side data"); + } + + /// #7096: an explicit ACL entry survives, instead of the file falling back to whatever the + /// folder hands down. Compared as the string `icacls` prints, which is what a user checks. + #[test] + fn replacing_a_file_keeps_its_explicit_acl() { + let d = TempDir::new("dacl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // Grant the current user an entry that inheritance would not have produced. + let user = std::env::var("USERNAME").unwrap_or_default(); + if user.is_empty() { + return; + } + let granted = std::process::Command::new("icacls").arg(&p).arg("/grant").arg(format!("{user}:(RX)")).output(); + if !granted.map(|o| o.status.success()).unwrap_or(false) { + return; + } + let before = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + + atomic_write(&p, b"second, longer").unwrap(); + + let after = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + assert_eq!(String::from_utf8_lossy(&after), String::from_utf8_lossy(&before), "the explicit ACL was dropped"); + assert_no_temp(&d.0); + } + + /// `ReplaceFileW` also keeps the creation time, which the rename used to reset (#6603). + #[test] + fn replacing_a_file_keeps_its_creation_time() { + let d = TempDir::new("born"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = std::fs::metadata(&p).unwrap().created().unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(50)); + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), before); + } + + /// Creating a file where there was none still works: there is nothing to replace, so the + /// write never reaches `ReplaceFileW`. + #[test] + fn creating_a_new_file_still_works() { + let d = TempDir::new("new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert_no_temp(&d.0); + } +} diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 8e71bc7ce3..8bf89512c2 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -69,8 +69,8 @@ impl AtomicFs for Faulty { fn sync_dir(&self, dir: &Path) -> io::Result<()> { RealFs.sync_dir(dir) } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - RealFs.copy_permissions(from, to) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) } } diff --git a/docs/decisions.md b/docs/decisions.md index 15519ecf74..ac44d14500 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,3 +5,30 @@ Smart Object instances carry a document-scoped contents ID separately from their layer ID. Normal duplicates preserve it; New Smart Object via Copy creates a new one. Contents edits update the matching instances together, retaining their individual placement and filters. Copies into another document remap contents IDs as a batch, preserving sharing within the copied layers without joining existing destination objects. Native files persist this ID. Legacy embedded objects without an ID remain independent: identical file bytes do not prove that two objects were intended to share edits. Legacy nonempty linked paths and PSD source identifiers retain their explicit sharing relationship. PSD export keys embedded sources by contents identity as well as bytes, so independent equal-content objects stay independent. + +## 2026-10-17: keeping a replaced file's ACL and named streams (#7073, #7094, #7096) + +Saving replaces a file by renaming a new one over it, which drops everything the old file +carried. Carrying the security-relevant parts across needs platform calls that have no safe API +in `std`, and the workspace forbids `unsafe`, so each platform took a dependency rather than an +exception. + +**macOS ACLs: `exacl`, not `fclonefileat`.** The two routes that avoid `unsafe` are a macOS-only +`exacl` dependency (a safe wrapper over `acl_get_file`/`acl_set_file`) and `fclonefileat` with +the undocumented-in-`rustix` `CLONE_ACL` bit. The clone keeps the ACL, but only on APFS, and it +means writing every save as clone-then-truncate-then-write with a fallback for every other file +system — restructuring the one write path the whole app's durability rests on, to preserve an +ACL. `exacl` is a best-effort step beside the existing permission copy, it works wherever ACLs +do, and a failure cannot affect the bytes. + +**Windows: `ReplaceFileW` via `winsafe`, with a backup name.** Copying the `Zone.Identifier` +stream by hand with `std` would cover the Mark of the Web but not the DACL, and enumerating every +stream needs `FindFirstStreamW`. `ReplaceFileW` is the call Microsoft documents for the +save-through-a-temporary-file pattern and preserves the DACL, named streams, creation time and +object identifier in one step; `winsafe`'s `kernel` feature wraps it safely and pulls in nothing. + +The backup-file argument is a correctness requirement, not an option: `ReplaceFileW` without one +documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT` as leaving the replaced file deleted and the +replacement under its temporary name — a failed save that loses the user's document. With a +backup name the same failure leaves both files alone, and the one failure that does move things +(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) is unwound in `atomic/meta.rs`. From 4b660fcffa6ce3e0ecef86507eddeac2bbd967d9 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:18:21 -0400 Subject: [PATCH 106/304] =?UTF-8?q?perf(camera-raw):=20Shadows/Highlights?= =?UTF-8?q?=201.5=C3=97=20faster,=20bit=20for=20bit=20the=20same?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast local Laplacian filter upsampled every remapped level in full for every reference (up to 96), but each pixel reads that level for at most two references. `llf` now samples it only where the weight is nonzero, with `up_at` (same terms, same order as `up`; row taps found once per row, `mirror` only near the edges). `quantile` selects its element instead of sorting the whole work copy (5 sorts per render, serial). 36 MP dialog, all cores: Shadows 252 -> 168 ms, Highlights 275 -> 184 ms, all 14 sliders 627 -> 411 ms. Output hashes match main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw --- crates/algo/src/camera_raw/local_tone.rs | 69 +++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs index e1305c5d49..1dbf58421f 100644 --- a/crates/algo/src/camera_raw/local_tone.rs +++ b/crates/algo/src/camera_raw/local_tone.rs @@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane { Plane { w, h, v: out } } +/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the +/// zero-stuffed row is all zeros). +fn up_rows(p: &Plane, h: usize, y: usize) -> [Option; 5] { + let lh = p.h.min(h.div_ceil(2)); + std::array::from_fn(|k| { + let m = mirror(y as isize + k as isize - 2, h); + (m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2) + }) +} + +/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`. +/// It adds the same terms in the same order as `up`, so it gives the same bits. +fn up_at(p: &Plane, w: usize, rows: &[Option; 5], x: usize) -> f32 { + let lw = p.w.min(w.div_ceil(2)); + let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() }; + if x >= 2 && x + 2 < w { + // Away from the edges no tap mirrors. + (0..5) + .filter_map(|k| { + let i = x + k - 2; + (i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2)) + }) + .sum() + } else { + (0..5) + .filter_map(|k| { + let m = mirror(x as isize + k as isize - 2, w); + (m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2)) + }) + .sum() + } +} + /// Levels down to about 2 pixels on the short side. fn depth(w: usize, h: usize) -> usize { let mut s = w.min(h); @@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { }); let gr = gaussian(m, n); for l in 0..n - 1 { - let coarse = up(&gr[l + 1], gr[l].w, gr[l].h); - let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w); + let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]); rows(&mut lap[l], lw, |y, row| { + let taps = up_rows(coarse, lh, y); for (x, o) in row.iter_mut().enumerate() { let i = y * lw + x; let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0); if wgt > 0.0 { - *o += wgt * (fine[i] - coarse.v[i]); + *o += wgt * (fine[i] - up_at(coarse, lw, &taps, x)); } } }); @@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { /// The `q` quantile (0 … 1) of `v`. fn quantile(v: &[f32], q: f32) -> f32 { let mut s = v.to_vec(); - s.sort_by(f32::total_cmp); - s[((s.len() - 1) as f32 * q).round() as usize] + // Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a + // fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element. + let i = ((s.len() - 1) as f32 * q).round() as usize; + *s.select_nth_unstable_by(i, f32::total_cmp).1 } /// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across. @@ -444,11 +479,33 @@ mod tests { assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}"); } for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] { - assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + let full = up(&p, uw, uh); + assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + // The filter samples `up` one pixel at a time; every sample has the same bits. + for y in 0..uh { + let taps = up_rows(&p, uh, y); + for x in 0..uw { + assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}"); + } + } } } } + #[test] + fn quantile_is_the_sorted_element() { + let p = noise_plane(301, 211, 9); + let mut v = p.v.clone(); + // Ties and signed zeros, which `total_cmp` orders by their bits. + v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]); + let mut sorted = v.clone(); + sorted.sort_by(f32::total_cmp); + for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] { + let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize]; + assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}"); + } + } + fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> { (0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect() } From 193f495e5a0fe20fd1b125d29bc30561faa14f8f Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:44:29 +0200 Subject: [PATCH 107/304] Perspective Crop and Rotate Arbitrary keep the Background opaque (#7138) Both filled the Background's uncovered area with the background colour including its alpha (tools.setColors accepts one), leaving see-through pixels on a Background. They now use alpha 1, as crop_doc does. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 3 ++- crates/engine/src/mode_cmds/tests.rs | 9 +++++++++ crates/engine/src/perspective_crop_cmds.rs | 15 ++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index fe22a192c7..9c51731a2c 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -75,7 +75,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { if deg.rem_euclid(360.0).abs() < 1e-9 { return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)})); } - let bg = s.tools.background; + // The Background stays opaque, whatever the background colour's alpha. + let bg = [s.tools.background[0], s.tools.background[1], s.tools.background[2], 1.0]; // Color Sampler points turn with the canvas (the same map the edit applies to the geometry). { let old = s.active().ok_or(EngineError::NoDocument)?.doc.size; diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index d6d020699e..fad300e27d 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -81,6 +81,15 @@ fn rotate_arbitrary_other_modes() { } } +#[test] +fn rotate_arbitrary_keeps_the_background_opaque_with_a_translucent_background_colour() { + let mut s = session(40, 20, 8, "rgb"); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": 30})).unwrap(); + let corner = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0], "the uncovered corner is opaque background colour"); +} + #[test] fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() { let mut s = Session::new(); diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 8acaf42ce2..776b2b4d07 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -188,7 +188,8 @@ fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32), interp: Interp, bg: [f32; 4]) -> Result<()> { let (w, ht) = size; let clip = Rect::from_xywh(0, 0, w, ht); - let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg); + // The Background stays opaque, whatever the background colour's alpha. + let bg = photocraft_raster::from_rgba(&doc.pixel_format(), [bg[0], bg[1], bg[2], 1.0]); let background_id = doc.background().map(|l| l.id); warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?; for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { @@ -298,6 +299,18 @@ mod tests { } } + /// Corners past the canvas: the uncovered Background is the background colour, opaque even + /// when that colour has alpha (the Background stays opaque). + #[test] + fn uncovered_background_is_opaque() { + let q = [[-20.0, -20.0], [180.0, -20.0], [180.0, 140.0], [-20.0, 140.0]]; + let mut s = skewed_ramp(SampleType::U8, &q, 100.0, 80.0); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute(CMD, json!({"corners": q, "width": 100, "height": 80})).unwrap(); + let corner = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]); + } + /// Bad quads and params are refused with an error, and the document is untouched. #[test] fn perspective_crop_rejects_bad_quads() { From 2cf274f991aa147846f714c1efb7829fb688a07a Mon Sep 17 00:00:00 2001 From: Derek Gliwa <2328690+dgliwa@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:38:11 -0500 Subject: [PATCH 108/304] Brushes: accept .abr files up to 1 GiB Import Brushes refused .abr files over 512 MiB: the engine's preset file cap and the .abr decoder's sample budget were both 512 MiB. Raise both to 1 GiB for brush files only; gradient, swatch, action and image-tip imports keep the 512 MiB cap. A compile-time assert keeps the reader and decoder caps equal. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/preset_import_cmds.rs | 35 ++++++++++++++++++++++--- crates/io/examples/gen_abr_library.rs | 2 +- crates/psd/src/abr.rs | 2 +- crates/psd/src/patterns.rs | 2 +- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 8da6cee8a0..2dcac20dfa 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -27,12 +27,19 @@ fn always(_: &Session) -> std::result::Result<(), String> { /// Largest accepted file (Photoshop's biggest bundled sets are well under this). pub const MAX_FILE_BYTES: u64 = 512 << 20; +/// Largest accepted `.abr` file: brush libraries of large sampled tips outgrow [`MAX_FILE_BYTES`]. +pub const MAX_ABR_FILE_BYTES: u64 = 1 << 30; /// File bytes from `data` (base64) or `path`, plus a default group name. pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, String)> { + file_bytes_within(p, cmd, what, MAX_FILE_BYTES) +} + +/// [`file_bytes`] with a size cap of `max` bytes. +fn file_bytes_within(p: &Value, cmd: &str, what: &str, max: u64) -> Result<(Vec, String)> { if let Some(d) = p.get("data") { let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?; - if s.len() as u64 > MAX_FILE_BYTES / 3 * 4 + 4 { + if s.len() as u64 > max / 3 * 4 + 4 { return Err(bad(cmd, "`data` is too large")); } let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?; @@ -41,7 +48,7 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, S let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?; let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len(); - if len > MAX_FILE_BYTES { + if len > max { return Err(bad(cmd, format!("{path}: file too large ({len} bytes)"))); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; @@ -72,7 +79,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { move |ctx| { ctx.progress(0.0, "Importing brushes"); let p = ¶ms; - let (bytes, stem) = file_bytes(p, cmd, ".abr file")?; + let (bytes, stem) = file_bytes_within(p, cmd, ".abr file", MAX_ABR_FILE_BYTES)?; let group = match p.get("group") { Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(), None if !stem.is_empty() => stem, @@ -526,6 +533,28 @@ mod tests { assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing"); } + #[test] + fn abr_files_get_a_larger_size_cap() { + // The decoder must not refuse what the reader accepts. + const _: () = assert!(MAX_ABR_FILE_BYTES == 1 << 30 && photocraft_psd::abr::MAX_TOTAL_BYTES as u64 == MAX_ABR_FILE_BYTES); + let dir = std::env::temp_dir().join(format!("pc-abr-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cap.abr"); + std::fs::write(&path, [0u8; 10]).unwrap(); + let p = json!({"path": path.to_string_lossy()}); + assert!(file_bytes_within(&p, "t", "f", 10).is_ok()); + assert!(file_bytes_within(&p, "t", "f", 9).is_err(), "a file over the cap is refused before reading"); + // A sparse file between the two caps: refused as a .grd, which is checked before reading. + let big = dir.join("big.grd"); + std::fs::File::create(&big).unwrap().set_len(MAX_FILE_BYTES + 1).unwrap(); + let err = Session::new().execute("gradient.presets.importGrd", json!({"path": big.to_string_lossy()})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); + let data = json!({"data": "A".repeat(16)}); + assert!(file_bytes_within(&data, "t", "f", 12).is_ok()); + assert!(file_bytes_within(&data, "t", "f", 8).is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn unique_names() { let taken = vec!["A".to_string(), "a 2".to_string()]; diff --git a/crates/io/examples/gen_abr_library.rs b/crates/io/examples/gen_abr_library.rs index 2736e49d06..a72b5ed995 100644 --- a/crates/io/examples/gen_abr_library.rs +++ b/crates/io/examples/gen_abr_library.rs @@ -10,7 +10,7 @@ //! //! Options (defaults in brackets): `--brushes N` [600], `--edge PX` largest tip edge [2000], //! `--min-edge PX` smallest tip edge [800], `--depth 8|16` [16], `--per-file N` presets per file [100], -//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 512 MB decoded cap. +//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 1 GiB decoded cap. #![allow(clippy::unwrap_used, clippy::expect_used)] use photocraft_paint::procedural::*; diff --git a/crates/psd/src/abr.rs b/crates/psd/src/abr.rs index c7cf3eb3cc..025aad294f 100644 --- a/crates/psd/src/abr.rs +++ b/crates/psd/src/abr.rs @@ -44,7 +44,7 @@ pub const MAX_EDGE: u32 = 8192; /// Most brushes read from one file. pub const MAX_BRUSHES: usize = 20_000; /// Cap on decoded sample bytes over the whole file. -pub const MAX_TOTAL_BYTES: usize = 512 << 20; +pub const MAX_TOTAL_BYTES: usize = 1 << 30; /// A sampled tip: one grayscale plane where the maximum value is full paint. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 39132df9b0..7f845f525e 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -29,7 +29,7 @@ const MAX_EDGE: u32 = 30_000; /// Budget for zero-padded channel planes summed over every pattern of one parse (a block, a `.pat` /// file, an `.abr`). Photoshop writes every channel at the pattern rectangle, so a smaller channel /// rectangle is only padding; without this cap a few hundred bytes (1x1 channels, a huge pattern -/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`. +/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20; // A PackBits run packet emits at most 128 bytes from a 2-byte packet. const MAX_RLE_EXPANSION: u64 = 64; From 86f6199b01471fa2bf8834db43e14a3d4705ef49 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 16:53:11 -0400 Subject: [PATCH 109/304] Print: load Windows.Data.Pdf.dll before using it; fix the error hint (#5884) Review follow-up on #6395: - Check that the DLL actually loads (winsafe `LoadLibrary`, no unsafe) rather than that the file exists: a DLL that is present but cannot load still aborts at the delay-load stub. - The hint no longer says to clear Print Settings; that doesn't take a page off the PDF path. It now points to Save as PDF, or printing an RGB image without page text or labels. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/Cargo.toml | 5 ++++ crates/engine/src/print_cmds.rs | 35 ++++++++++++++++----------- crates/engine/src/print_cmds/tests.rs | 26 ++++++++++++++++---- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 138ca3d814..99799dc20f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4810,6 +4810,7 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "wat", + "winsafe", ] [[package]] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 741d927a3f..e881dca4cf 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,11 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# `file.print` loads Windows.Data.Pdf.dll before calling into it (#5884): the safe binding +# ui-egui and craft-print already use, pinned to the same version. +[target.'cfg(target_os = "windows")'.dependencies] +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } + [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 88ed6c7883..72af145782 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -437,24 +437,31 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { } /// Windows prints a PDF through Windows.Data.Pdf.dll, which the apps delay-load (see their -/// `build.rs`) so a Windows image without it still starts. Calling into it when it is missing -/// would abort the process, so check that it is installed first (#5884). +/// `build.rs`) so a Windows image without it still starts. Calling into it when it cannot load +/// would abort the process, so check that it loads first (#5884). fn require_windows_pdf() -> Result<()> { - if !cfg!(windows) { - return Ok(()); - } - let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows")); - if windows_pdf_installed(std::path::Path::new(&root)) { - Ok(()) - } else { - Err(other( - "printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is not installed. Save as PDF and print it from another app, or clear Print Settings to print directly.", - )) + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows")); + if !windows_pdf_loads(&windows_pdf_dll(std::path::Path::new(&root))) { + return Err(other( + "printing this page needs the Windows PDF component (Windows.Data.Pdf.dll), which is missing or cannot load. Save as PDF and print it from another app, or print an RGB image without page text or labels to print directly.", + )); + } } + Ok(()) } -fn windows_pdf_installed(system_root: &std::path::Path) -> bool { - system_root.join("System32").join("Windows.Data.Pdf.dll").is_file() +#[cfg(target_os = "windows")] +fn windows_pdf_dll(system_root: &std::path::Path) -> std::path::PathBuf { + system_root.join("System32").join("Windows.Data.Pdf.dll") +} + +/// Loads the DLL rather than checking the file exists: one that is present but cannot load (a +/// dependency stripped too) would still abort at the delay-load stub. +#[cfg(target_os = "windows")] +fn windows_pdf_loads(dll: &std::path::Path) -> bool { + dll.to_str().is_some_and(|p| winsafe::HINSTANCE::LoadLibrary(p).is_ok()) } /// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 677fdec6ba..431b83343a 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -435,12 +435,28 @@ fn printers_lists_the_spooler_destinations_without_a_document() { } } +#[cfg(target_os = "windows")] #[test] -fn windows_pdf_component_is_detected_by_its_dll() { - // #5884: a Windows image without Windows.Data.Pdf.dll reports an error instead of aborting. +fn windows_pdf_component_is_detected_by_loading_its_dll() { + // #5884: a Windows image without a loadable Windows.Data.Pdf.dll reports an error instead of + // aborting at the delay-load stub. let dir = tempfile::tempdir().unwrap(); - assert!(!windows_pdf_installed(dir.path())); + let dll = windows_pdf_dll(dir.path()); + assert!(!windows_pdf_loads(&dll)); + // Present but not loadable counts as missing. std::fs::create_dir(dir.path().join("System32")).unwrap(); - std::fs::write(dir.path().join("System32").join("Windows.Data.Pdf.dll"), b"MZ").unwrap(); - assert!(windows_pdf_installed(dir.path())); + std::fs::write(&dll, b"MZ").unwrap(); + assert!(!windows_pdf_loads(&dll)); + let root = std::env::var_os("SystemRoot").unwrap(); + let system = windows_pdf_dll(std::path::Path::new(&root)); + if system.is_file() { + assert!(windows_pdf_loads(&system)); + assert!(require_windows_pdf().is_ok()); + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn the_windows_pdf_check_is_a_no_op_elsewhere() { + assert!(require_windows_pdf().is_ok()); } From 3c9c93ed8cb6221697c752679d877705f91f0059 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:18:36 -0700 Subject: [PATCH 110/304] GPU startup: Auto on Windows leaves Vulkan out when an Intel adapter is present (#4) With the `auto` backend on Windows, eframe created its wgpu instance with Vulkan, and creating it, listing its adapters and asking each adapter about the window's surface loaded and ran the Intel Vulkan driver (igvk64.dll) before the adapter selector could pick Intel's DX12 adapter. When the driver crashed there, the first launch died, and only the next one moved to DX12 through the startup marker. `configure` now lists the DX12 adapters first, with a DX12-only instance, when the plan is `auto` on Windows without WGPU_BACKEND. If one of them is an Intel adapter, the instance is created with DX12 and GL only (GL stays the last resort), so no Vulkan driver is loaded. On a system with an Intel and a discrete GPU, the discrete GPU then runs on DX12 instead of Vulkan. WGPU_BACKEND, --safe-gpu, a chosen backend and the crash fallback are unchanged, and System Info still shows the Intel DirectX 12 note. The decision is `windows_auto_backends`, tested with vendor lists. --- apps/photocraft/src/gpu_startup.rs | 85 +++++++++++++++++++++++++++--- apps/photocraft/src/services.rs | 2 +- docs/development.md | 2 +- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index e79b4bafac..5e8abbd013 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -12,8 +12,10 @@ //! - macOS: Metal → CPU //! //! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance, -//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still -//! overrides everything, and `--safe-gpu` forces the CPU path for one launch. +//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an +//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan +//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path +//! for one launch. //! //! "CPU" composites on the CPU and draws the window with a software adapter where the platform //! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present @@ -204,6 +206,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option { }) } +/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor +/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An +/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's +/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]' +/// choice. +pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec) -> Option { + let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows; + (auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL) +} + +/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver) +/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]). +fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::DX12, + flags: desc.flags, + backend_options: desc.backend_options.clone(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect() +} + /// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the /// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC /// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader @@ -265,13 +289,14 @@ pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option bool { +/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of +/// the instance). +pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool { let Some(c) = adapters.get(chosen) else { return false }; os == Os::Windows && c.vendor == INTEL && c.backend == wgpu::Backend::Dx12 - && adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan) + && (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)) } /// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler @@ -298,6 +323,11 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel let env = std::env::var("WGPU_DX12_COMPILER").ok(); create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent)); } + let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor)); + if let Some(b) = without_vulkan { + create.instance_descriptor.backends = b; + } + let vulkan_left_out = without_vulkan.is_some(); let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)); if select { let backend = plan.backend; @@ -319,7 +349,7 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel *note.lock().unwrap_or_else(PoisonError::into_inner) = Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into()); } - if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) { + if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) { *note.lock().unwrap_or_else(PoisonError::into_inner) = Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into()); } @@ -613,13 +643,13 @@ mod tests { let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)]; let i = pick(&intel, Auto, Os::Windows).unwrap(); assert_eq!(intel[i].backend, B::Dx12); - assert!(intel_dx12_applied(&intel, i, Os::Windows)); + assert!(intel_dx12_applied(&intel, i, Os::Windows, false)); // Elsewhere (and for other vendors) Vulkan stays first. assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan); let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; let i = pick(&nv, Auto, Os::Windows).unwrap(); assert_eq!(nv[i].backend, B::Vulkan); - assert!(!intel_dx12_applied(&nv, i, Os::Windows)); + assert!(!intel_dx12_applied(&nv, i, Os::Windows, false)); // Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan. let hybrid = [intel[0], intel[1], nv[1]]; assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2)); @@ -630,6 +660,45 @@ mod tests { assert_eq!(pick(&[], Auto, Os::Windows), None); } + /// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before + /// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on + /// Windows creates the instance without Vulkan. + #[test] + fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() { + use wgpu::{Backend as B, DeviceType as T}; + const NVIDIA: u32 = 0x10de; + const AMD: u32 = 0x1002; + const WARP: u32 = 0x1414; + let auto = plan(Auto, None, None, false, Os::Windows); + let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL); + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl); + // Intel iGPU + discrete GPU: the discrete GPU is then used on DX12. + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl); + // No Intel adapter: egui's default backends, Vulkan included. + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None); + assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None); + assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None); + // `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms + // don't list the DX12 adapters at all. + let not_listed = || -> Vec { panic!("DX12 adapters listed") }; + for (p, os) in [ + (plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows), + (plan(Auto, None, None, true, Os::Windows), Os::Windows), + (plan(Vulkan, None, None, false, Os::Windows), Os::Windows), + (plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows), + (plan(Auto, None, None, false, Os::Other), Os::Other), + (plan(Auto, None, None, false, Os::Mac), Os::Mac), + ] { + assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}"); + } + // Without Vulkan adapters to compare with, System Info still names the Intel default. + let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)]; + let i = pick(&listed, Auto, Os::Windows).unwrap(); + assert_eq!(i, 0); + assert!(intel_dx12_applied(&listed, i, Os::Windows, true)); + assert!(!intel_dx12_applied(&listed, i, Os::Windows, false)); + } + #[test] fn cpu_prefers_software_adapters() { use wgpu::{Backend as B, DeviceType as T}; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4e7a01553..d6365020d8 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -244,7 +244,7 @@ fn path_of(file: &rfd::FileHandle) -> String { } /// Runs `future` to completion on this thread, sleeping until it is woken. -fn block_on(future: impl Future) -> T { +pub(crate) fn block_on(future: impl Future) -> T { struct Unpark(std::thread::Thread); impl std::task::Wake for Unpark { fn wake(self: Arc) { diff --git a/docs/development.md b/docs/development.md index 6891e88a9c..a5b7980378 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,7 @@ On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.l ## Graphics startup and device loss -`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. +`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12: when DXGI lists an Intel adapter, the app creates its wgpu instance with DX12 and GL only, so the first start doesn't load the Intel Vulkan driver, and a discrete GPU beside the Intel one also runs on DX12. Help › System Info shows the adapter, backend, driver and fallback state. On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712). From f8cd3e79829ee224151cb70088211d2e5f526473 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 17:08:37 -0600 Subject: [PATCH 111/304] chore: clear the rustc 1.96 clippy lint that fails CI Unrelated to this branch's change; main already fails clippy --all-targets -- -D warnings without it, which is what failed the Windows integration job here. One clippy::nonminimal_bool site in canvas.rs, taking clippy's own rewrite: the negation stays outside, so the is_finite guards and their NaN behaviour are unchanged. A sweep of all 30 workspace members found no other site. --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 914d395acc..71bb439afa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4354,7 +4354,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 { From 204c76ce36ab5e7ddf6d406074f099d3759fd8ff Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 20:03:18 -0400 Subject: [PATCH 112/304] fix(psd): an edited type layer keeps its Txt2 object instead of a bare stub (#6968) When a type layer's text changed, build_txt2 replaced the layer's object in Photoshop's own Txt2 block with a bare `<< /0 << /0 text /6 runs >> >>`: no paragraph runs (/5), no view (/1, which names the object's frame) and none of the style keys. Photoshop 27.10 quits while opening such a file (layer_effects.psd after type.edit), as it refused stripped objects in #2374. An edited object now starts from the file's object at its slot: the text is replaced, the paragraph runs are remade one per paragraph of the new text, and every other run array of the model (style runs, ...) is cut or extended to the new length before the auto-kern modes are applied. The view and other keys are kept; only the old text's glyph pen positions (/21) are dropped. An object whose run arrays aren't shaped as Photoshop writes them still falls back to a fresh one. Test: text edited_text_keeps_the_files_txt2_object. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/psd.rs | 87 ++++++++++++++++++++++++++++++++++++++-- crates/text/src/tests.rs | 50 +++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 8c502c75af..41d65ff887 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -964,8 +964,10 @@ pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool { /// unchanged — keeps everything the file held beyond the two regenerated keys (its model's text /// and style runs): Photoshop stores glyph pen positions under `/21 /1` there, and a save with /// no text edit must not drop them. Its own style runs keep every style key too: only their -/// auto-kern mode is set (split where a mode changes inside a run). A changed text invalidates -/// those extras along with the old runs, so they are dropped rather than left stale. Everything +/// auto-kern mode is set (split where a mode changes inside a run). An edited object — the +/// file's object at that slot, its text changed — keeps the object too, with its text replaced +/// and its run arrays refitted to the new text (#6968: a bare stub crashed Photoshop); only its +/// glyph pen positions, which belong to the old text, are dropped. Everything /// else the file's block held — the other keys beside the text objects, objects no layer names — /// is kept, and a block whose content comes out unchanged is returned byte for byte (#2374: /// Photoshop refused files whose `Txt2` an unedited save had stripped). @@ -981,9 +983,15 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec let modes = txt2_mode_runs(layer); let at = *index as usize; let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); - let mut object = kept.cloned().unwrap_or_else(E::dict); + // An edited object (#6968): the file's object at this slot with its text changed. It keeps + // the file's structure — paragraph runs, the view naming its frame, the rest — with every + // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in + // Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file. + let edited = kept.is_none().then(|| prev_objects.get(at).and_then(|o| edited_object(o, &text))).flatten(); + let reuse = kept.is_some() || edited.is_some(); + let mut object = kept.cloned().or(edited).unwrap_or_else(E::dict); let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict); - let file_runs = kept.and_then(|_| model.path(&["6", "0"]).and_then(E::as_array)).and_then(|runs| with_modes(runs, &modes)); + let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes)); let runs = file_runs.unwrap_or_else(|| { modes .iter() @@ -1028,6 +1036,77 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec } } +/// The file's `Txt2` text object `object` (`<< /0 model /1 view … >>`) for the changed engine +/// text `text` (`\r` breaks, trailing `\r`): the model's text replaced, its paragraph runs (`/5`) +/// one per paragraph of the new text (each keeping the style of the file's paragraph at that +/// position, else its last), and every other run array of the model (style runs `/6`, …) cut or +/// extended at its end to the new length. The view and the other keys are kept, except the glyph +/// pen positions (`/21`), which belong to the old text. `None` when the object or one of its run +/// arrays isn't shaped as Photoshop writes it: the caller writes a fresh object instead. +fn edited_object(object: &E, text: &str) -> Option { + let mut object = object.clone(); + let E::Dict(items) = &mut object else { return None }; + items.retain(|(k, _)| k != "21"); + let model = object.get_mut("0").filter(|m| matches!(m, E::Dict(_)))?; + let total = utf16_len(text) as usize; + let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); + let E::Dict(entries) = model else { return None }; + for (key, value) in entries.iter_mut() { + if key.as_str() == "0" { + *value = E::String(text.to_string()); + continue; + } + let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else { + continue; + }; + let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs)? } else { runs_to_length(runs, total)? }; + value.set("0", E::Array(fitted)); + } + Some(object) +} + +/// `runs` (`<< … /1 length >>`) remade with the lengths `lens`: run i keeps the content of the +/// file's run i, else of its last one. `None` for an empty `runs`. +fn runs_per(runs: &[E], lens: &[usize]) -> Option> { + let last = runs.last()?; + lens.iter() + .enumerate() + .map(|(i, &len)| { + let mut r = runs.get(i).unwrap_or(last).clone(); + r.set("1", E::Int(i64::try_from(len).ok()?)); + Some(r) + }) + .collect() +} + +/// `runs` (`<< … /1 length >>`) covering `total` UTF-16 units: the leading runs as they were, cut +/// where `total` ends, the last kept run extended when `total` is longer. Empty runs are dropped. +/// `None` when `runs` is empty or a length isn't a count. +fn runs_to_length(runs: &[E], total: usize) -> Option> { + let mut out: Vec<(E, usize)> = Vec::new(); + let mut left = total; + for r in runs { + let len = r.get("1").and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?; + let take = len.min(left); + if take > 0 { + out.push((r.clone(), take)); + left -= take; + } + } + if left > 0 { + match out.last_mut() { + Some(last) => last.1 = last.1.checked_add(left)?, + None => out.push((runs.last()?.clone(), left)), + } + } + out.into_iter() + .map(|(mut r, len)| { + r.set("1", E::Int(i64::try_from(len).ok()?)); + Some(r) + }) + .collect() +} + /// The file's own `Txt2` style runs `runs` with the auto-kern modes `modes` (`(mode, UTF-16 /// length)`, see [`txt2_mode_runs`]) set at `/0 /0 /6 /11`, keeping every other style key. A /// run a mode change falls inside is split there. `None` when the runs don't cover the text or diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 894ecf4e4f..d2aaa78a45 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1097,6 +1097,56 @@ fn edited_txt2_keeps_the_files_styles_and_objects() { assert_eq!(modes, vec![(1, Kerning::Optical), (2, Kerning::Metrics)]); } +/// #6968: editing the text of a Photoshop type layer keeps its `Txt2` object — paragraph runs, +/// the view naming its frame, the model's other run arrays and its style keys — refitted to the +/// new text. It was replaced by a bare `<< /0 << /0 text /6 runs >> >>` stub, and Photoshop quit +/// while opening the file. +#[test] +fn edited_text_keeps_the_files_txt2_object() { + use crate::engine_data::Value as E; + let run = |extra: &str, len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 {extra} >> >> >> /1 {len} >> "); + // "AB\rCD\r": two paragraphs, two style runs, a kern run, a view and pen positions. + let prev = format!( + "\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}{}] >> /7 << /0 [ << /0 << >> /1 6 >> ] >> >> /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>", + run("", 3), + run("", 3), + run("/11 1", 2), + run("/11 1 /53 (red)", 4) + ); + // Every character above is below U+0100: one raw byte each (the BOM `FE FF` included). + let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); + let before = crate::psd::parse_txt2(&prev).unwrap(); + let before_object = before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].clone(); + assert_eq!(before_object.path(&["0", "0"]).and_then(E::as_str), Some("AB\rCD\r")); + let lens = |o: &E, key: &str| -> Vec { + o.path(&["0", key, "0"]).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() + }; + for (text, paragraphs, styles) in + [("Bevex\nCD", vec![6, 3], vec![2, 7]), ("AB\nCD\nEFG", vec![3, 3, 4], vec![2, 8]), ("X", vec![2], vec![2]), ("AB\nCZ", vec![3, 3], vec![2, 4])] + { + let t = runs_of(text, &[(text.len(), CharStyle::default())]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + for key in [&["0"][..], &["98"], &["1", "0"], &["1", "2"]] { + assert_eq!(out.path(key), before.path(key), "{text:?}: /{} survives", key.join(" /")); + } + let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; + let engine = format!("{}\r", text.replace('\n', "\r")); + let total = engine.encode_utf16().count() as i64; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some(engine.as_str())); + assert_eq!(object.get("1"), before_object.get("1"), "{text:?}: the view (its frame) is kept"); + assert!(object.get("21").is_none(), "{text:?}: pen positions of the old text are dropped"); + assert_eq!(lens(object, "5"), paragraphs, "{text:?}: one paragraph run per paragraph"); + assert_eq!(lens(object, "6"), styles, "{text:?}: style runs cover the new text"); + assert_eq!(lens(object, "7"), vec![total], "{text:?}: other run arrays cover the new text"); + for r in object.path(&["0", "5", "0"]).and_then(E::as_array).unwrap() { + assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: paragraph style survives"); + } + for r in object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap() { + assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(12.0), "{text:?}: the font size survives"); + } + } +} + /// The file's style runs are only reused when they cover the text: runs of another length (or /// hostile lengths) fall back to generated runs, without panicking. #[test] From 61db2b2bba6719b4857665801e42e09f778095e9 Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sat, 17 Oct 2026 18:00:15 -0700 Subject: [PATCH 113/304] Group all selected layers from the Layers panel footer --- crates/ui-egui/src/new_group_button_tests.rs | 43 ++++++++++++++++++-- crates/ui-egui/src/panels.rs | 5 +-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/new_group_button_tests.rs b/crates/ui-egui/src/new_group_button_tests.rs index e236ad74ec..529972a418 100644 --- a/crates/ui-egui/src/new_group_button_tests.rs +++ b/crates/ui-egui/src/new_group_button_tests.rs @@ -1,5 +1,5 @@ -//! #2561: the Layers panel New Group button. Like Photoshop a plain click adds an empty group -//! and Shift-click groups the selected layers. +//! The Layers panel New Group button groups multiple selected layers on a plain click. +//! Shift-click also groups a single selected layer; otherwise it creates an empty group. use egui::Modifiers; use egui_kittest::kittest::Queryable; @@ -42,8 +42,42 @@ fn shift_click_groups_the_selected_layers_in_order() { } #[test] -fn plain_click_still_adds_an_empty_group() { +fn plain_click_groups_all_selected_layers_in_order_and_undoes_in_one_step() { + for active in ["A", "B"] { + let (mut h, a, b) = two_selected(); + let id = if active == "A" { a } else { b }; + h.state_mut().session.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); + h.run_steps(4); + h.get_by_label("Create a new group").click(); + h.run_steps(4); + let groups = groups(&h); + assert_eq!(groups.len(), 1); + let group = &groups[0]; + let children: Vec = group.children().unwrap().iter().map(|l| l.id).collect(); + assert_eq!(children, vec![a, b]); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 2, "only the background and group remain at the top level"); + assert_eq!(st.selected_layers(), vec![group.id]); + + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 3); + assert_eq!(st.doc.layers[1].id, a); + assert_eq!(st.doc.layers[2].id, b); + assert_eq!(st.active_layer, Some(id)); + + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + let st = h.state().session.active().unwrap(); + let children: Vec = st.doc.layer(group.id).unwrap().children().unwrap().iter().map(|l| l.id).collect(); + assert_eq!(children, vec![a, b]); + } +} + +#[test] +fn plain_click_with_one_selected_layer_still_adds_an_empty_group() { let (mut h, a, b) = two_selected(); + h.state_mut().session.execute("layer.select", json!({"layer": a.0})).unwrap(); + h.run_steps(4); h.get_by_label("Create a new group").click(); h.run_steps(4); let groups = groups(&h); @@ -51,5 +85,6 @@ fn plain_click_still_adds_an_empty_group() { assert!(groups[0].children().unwrap().is_empty()); let doc = &h.state().session.active().unwrap().doc; let top: Vec = doc.layers.iter().map(|l| l.id).collect(); - assert_eq!(top[1..3], [a, b], "the selected layers stay where they were"); + assert_eq!(top[1], a, "the selected layer stays where it was"); + assert_eq!(top[3], b, "the unselected layer stays above the new group"); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6fc8e2d8f4..7dbe3ab843 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2521,10 +2521,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); if group.clicked() { - // Like Photoshop a plain click adds an empty group; Shift-click groups the selected - // layers, the same as Layer › Group Layers (#2561). + // Multiple selected layers share one group. Shift also groups a single selection. let shift = ui.input(|i| i.modifiers.shift); - let cmd = if shift && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" }; + let cmd = if (shift || selection.len() > 1) && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" }; actions.push((cmd.into(), json!({}))); } actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers")); From 0f12ac151fa2e6eaedf3c322feb4eb0d49cff905 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:19:41 +0300 Subject: [PATCH 114/304] fix(engine): refuse a Content-Aware Fill with nothing to sample instead of filling black (#6532) --- crates/engine/src/edit_menu_cmds.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index da9a4d0482..8abc4bfd28 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -719,6 +719,13 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & let cancel = || ctx.cancelled(); let plan = content_aware_plan_with(&doc, id, &p, cmd, &photocraft_raster::Interrupt::cancel_only(&cancel))?; let CafPlan { layer, rect, fmt, img, layer_px, cover, hole, source, opts, output, .. } = plan; + // Patches come only from the sampling area outside the fill area. With none (a + // Rectangular `margin` of 0, a Custom area inside the selection or off the canvas) + // the fill would be black (#6532), so refuse instead. The workspace's preview builds + // the same plan and may show an empty area while the Sampling Brush adds to it. + if !source.iter().any(|s| *s) { + return Err(bad(cmd, "nothing to sample from: the sampling area has no pixels outside the fill area")); + } let (w, h) = (rect.width() as usize, rect.height() as usize); let n = fmt.channels(); // The fill changes only the fill area: keep the layer's pixels and the coverage over From 36d6b669d40d1f86118c6e92831f4cd7dc73f5f8 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:19:41 +0300 Subject: [PATCH 115/304] test(engine): Content-Aware Fill with an empty sampling area is refused and changes nothing (#6532) --- crates/engine/src/edit_menu_cmds/tests.rs | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index bc274795a8..dc32b7c8f5 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -105,10 +105,15 @@ fn blob_session(depth: u32) -> Session { fn content_aware_fill_huge_area_neither_panics_nor_wraps() { let mut s = blob_session(8); // `[1e30, 0, 1e30, 10]` overflowed the i32 additions in the area parser (a debug-build - // panic, a garbage sampling window in release); the additions saturate now, so this is - // simply a whole-canvas window. - let r = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap(); - assert!(r["filled"].as_u64().unwrap() > 100); + // panic, a garbage sampling window in release); the additions saturate now, so the area lies + // wholly right of the canvas: nothing to sample, which is an error, not a black fill (#6532). + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap_err(); + assert!(err.to_string().contains("nothing to sample from"), "{err}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &st.doc), "the document changed"); + assert_eq!(st.history.past_len(), past, "a history step was recorded"); // A malformed area names the problem instead of silently falling back. for area in [json!([0.0, 0.0, null, 10.0]), json!([0.0, 0.0, 10.0]), json!("nope")] { let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": area})).unwrap_err(); @@ -312,6 +317,23 @@ fn content_aware_fill_takes_the_workspace_edits() { assert!(r["filled"].as_u64().unwrap() > 2800); } +/// #6532: a sampling area with no pixels outside the fill area is refused instead of filling it +/// black, and leaves the document untouched; any margin past the selection fills. +#[test] +fn content_aware_fill_with_nothing_to_sample_is_refused() { + let mut s = blob_session(8); + let before = px(&s, 30, 22); + let revision = s.active().unwrap().revision; + for p in [json!({"sampling": "rectangular", "margin": 0}), json!({"sampling": "custom", "area": [28, 18, 4, 4]})] { + let e = s.execute("edit.contentAwareFill", p.clone()).unwrap_err().to_string(); + assert!(e.contains("nothing to sample from"), "{p}: {e}"); + assert_eq!(px(&s, 30, 22), before, "{p}: nothing was filled"); + assert_eq!(s.active().unwrap().revision, revision, "{p}: no history step"); + } + s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "margin": 1})).unwrap(); + assert_ne!(px(&s, 30, 22), before, "the red blob is filled from around the selection"); +} + #[test] fn content_aware_fill_outputs_and_sampling() { let mut s = blob_session(8); From 98f4c9e1b860c8c0a3c51cf2aacf6f768023f1c7 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 08:54:20 -0400 Subject: [PATCH 116/304] test(psd): write the NUL in psd16_merged_transparency.rs as an escape The test file held a literal NUL byte, so git treated it as binary. Co-Authored-By: Claude Opus 5.5 --- crates/io/tests/psd16_merged_transparency.rs | Bin 3511 -> 3512 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/psd16_merged_transparency.rs index 3f04970626e7f21e776f84241be0ee3a46d66a99..02d6c0e6b3f05a4843ace4dc829d3d9fa9f311b2 100644 GIT binary patch delta 15 Wcmdlky+eA#98RVfgUxd}rC0zi(FFhi delta 14 VcmdlXy Date: Fri, 16 Oct 2026 14:04:13 +0000 Subject: [PATCH 117/304] Integration: #6035 move the new tests above existing tests' doc comments --- crates/engine/src/image_cmds.rs | 11 ++++++----- crates/ui-egui/src/sizing.rs | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 5f37a2eaa8..69fb1b17c1 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -944,11 +944,7 @@ mod tests { } } - /// The canvas border stays opaque after Image Size (it used to fade into transparency, so a - /// Background or a 200 % export got a translucent frame), and a full selection stays full. - /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands - /// without a budget. It is refused before any allocation and the document is untouched; a - /// change of resolution only, and an ordinary resize, still work. + /// #6019: Image Size on an Indexed Color document makes no colours the colour table doesn't have. #[test] fn image_size_resamples_indexed_color_with_nearest_neighbor() { let mut s = Session::new(); @@ -970,6 +966,11 @@ mod tests { } } + /// The canvas border stays opaque after Image Size (it used to fade into transparency, so a + /// Background or a 200 % export got a translucent frame), and a full selection stays full. + /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands + /// without a budget. It is refused before any allocation and the document is untouched; a + /// change of resolution only, and an ordinary resize, still work. #[test] fn image_size_refuses_a_raster_past_the_budget() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index c3ec7a199b..d0e0cfdec4 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -525,8 +525,7 @@ mod tests { assert!(app.ui.dialogs.iter().any(|x| x.id == id), "reset leaves the dialog open"); } - /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens - /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). + /// #6019: on an Indexed Color document the dialog opens on, and offers only, Automatic and Nearest Neighbor. #[test] fn indexed_color_resamples_with_nearest_neighbor_only() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -555,6 +554,8 @@ mod tests { } } + /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens + /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). #[test] fn resample_follows_the_interpolation_preference() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From fac74f83e8dd35b084e47d39c55d2dfb8db388d9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:05:02 +0000 Subject: [PATCH 118/304] Integration: #6414 an unconfirmed save releases the unsaved-changes prompt --- crates/ui-egui/src/discard_ui.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 3dbf33c905..fe75cadc3e 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -214,7 +214,8 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { } match saved { // A Save Document script may have edited the document after it was written: still dirty. - Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()), + // A write the platform can't confirm (a web download) leaves it dirty too, and counts. + Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()), // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { @@ -227,12 +228,12 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { /// A completed save may release the close prompt. Choosing a path only starts a layered TIFF /// save; TIFF Options calls this after the write, and `jobs_ui` when a background save ends. -/// Copies and failed writes leave the doc dirty. +/// Copies and failed writes leave the doc dirty; so does any write when it is unconfirmed. pub(crate) fn saved_document(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) { // Still the prompt that asked (quitting may have replaced it meanwhile). if !app.save_options_open() && app.discard.as_ref().and_then(|p| p.docs.first()) == Some(&doc) - && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()) + && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()) { advance(app, ctx); } @@ -924,6 +925,30 @@ mod tests { assert!(h.state().session.documents().is_empty(), "closed once saved"); } + /// #6414: on the web a save stays dirty (the download isn't confirmed), and answering Save + /// still closes the tab after one write. + #[test] + fn saving_from_the_prompt_closes_after_an_unconfirmed_write() { + use std::{cell::RefCell, rc::Rc}; + let written: Rc>> = Rc::default(); + let w = written.clone(); + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + app.session.active_mut().unwrap().path = Some("/pics/kept.psd".into()); + app.services.write_unconfirmed = true; + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })); + let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows); + h.key_press(Key::Y); + h.run_steps(3); + assert_eq!(*written.borrow(), ["/pics/kept.psd"]); + assert!(h.state().discard.is_none()); + assert!(h.state().session.documents().is_empty(), "closed after one write"); + } + #[path = "tiff_tests.rs"] mod tiff_tests; From 0b167ca2599957b794baadec9b947146a6a114e4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 12:16:37 +0000 Subject: [PATCH 119/304] Integration: #6476's uppercase token test writes its file owner-only (#5459 on main) --- crates/automation/src/security.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 14aadbca53..89a760b32a 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -404,7 +404,7 @@ mod tests { fn an_uppercase_token_file_authenticates_its_exact_contents() { let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap())); let contents = generate_token().unwrap().to_ascii_uppercase(); - std::fs::write(&path, format!("{contents}\n")).unwrap(); + create_token_file(&path, &contents).unwrap(); let expected = server_token(None, Some(&path)); std::fs::remove_file(&path).unwrap(); let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap()); From 5a01650652c2d24ee49810272a03d9c2a05db786 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 18 Oct 2026 13:21:01 +0000 Subject: [PATCH 120/304] Integration: cargo fmt --- crates/io/src/svg.rs | 13 ++----------- crates/ui-egui/src/discard_ui.rs | 6 +++++- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 3d66f45e94..8afefb733d 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -230,17 +230,8 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, let fmt = doc.pixel_format(); let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale); let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH)); - let mut conv = Conv { - fmt, - canvas: doc.bounds(), - global, - max_group_depth, - warnings, - noted: Vec::new(), - counts: [0; 4], - pixel_budget, - retained_pixel_bytes: 0, - }; + let mut conv = + Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4], pixel_budget, retained_pixel_bytes: 0 }; if count_nodes(tree.root()) > MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index fe75cadc3e..82e4497d92 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -215,7 +215,11 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { match saved { // A Save Document script may have edited the document after it was written: still dirty. // A write the platform can't confirm (a web download) leaves it dirty too, and counts. - Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()), + Ok(_) => { + !app.save_options_open() + && !app.saving() + && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()) + } // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { From 5b087699be62c70b71556fa49056a27d69216410 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 18 Oct 2026 10:29:07 -0400 Subject: [PATCH 121/304] Tests: fix the flaky file-lock tests and the occasional portal_save hang (#7418) The rotation and instance-log tests in logging.rs, and two app_dirs migration-lock tests, drop a flock and check straight away that it is free. A child that another test spawns at that moment holds a copy of every open descriptor until its exec, the lock included, so the check sometimes saw it still held (about 6 failures in 15 runs of the app's test binary). Tests that spawn children (the portal mock's dbus-daemon, the display-profile helper test) now hold CHILD_SPAWNS for reading while they spawn, and the lock tests hold it for writing. The portal mock made its message stream inside its thread; a stream only gets messages that arrive after it exists, so a SaveFile sent before the thread got going was lost and the test waited forever (about 1 run in 25). The stream is now made before the thread starts. Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/app_dirs.rs | 2 ++ apps/photocraft/src/logging.rs | 3 +++ apps/photocraft/src/main.rs | 13 +++++++++++++ apps/photocraft/src/monitor_profile.rs | 1 + apps/photocraft/src/portal_save/tests.rs | 7 ++++++- 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 5775e254df..39d90f07bb 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -784,6 +784,7 @@ mod tests { /// now-existing folder and the original is put back, so both autosaves stay (#6412 review). #[test] fn a_launch_that_times_out_never_writes_where_a_move_removes() { + let _spawns = crate::no_child_spawns(); let root = temp("recovery-timeout"); let legacy = root.join("roaming").join("Recovery"); autosave(&legacy, "old"); @@ -866,6 +867,7 @@ mod tests { /// once the lock is released, the move happens. #[test] fn a_move_waits_for_another_process_holding_the_lock() { + let _spawns = crate::no_child_spawns(); let root = temp("recovery-lock"); let legacy = root.join("roaming").join("Recovery"); autosave(&legacy, "a"); diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 33b7df2462..5c8f0697dd 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -420,6 +420,7 @@ mod tests { #[test] fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let _spawns = crate::no_child_spawns(); let dir = temp_dir("rotate"); for run in 1..=4 { let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate"); @@ -438,6 +439,7 @@ mod tests { /// `.2.log`, and a fourth deleted it. #[test] fn a_second_start_leaves_the_running_instance_log_alone() { + let _spawns = crate::no_child_spawns(); let dir = temp_dir("second"); let mut first = rotate(&dir).expect("first start"); assert_eq!(first.path, dir.join(LOG_FILE)); @@ -461,6 +463,7 @@ mod tests { #[test] fn instance_logs_are_pruned_only_when_no_one_holds_them() { + let _spawns = crate::no_child_spawns(); let dir = temp_dir("prune"); let first = rotate(&dir).expect("first start"); // Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 05355e55bf..826aba03ab 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -767,6 +767,19 @@ fn main() -> eframe::Result { result } +/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock +/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file, +/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held +/// for a moment. +#[cfg(test)] +pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(()); + +/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile. +#[cfg(test)] +pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> { + CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { #[test] diff --git a/apps/photocraft/src/monitor_profile.rs b/apps/photocraft/src/monitor_profile.rs index 8e33869696..c210e096ec 100644 --- a/apps/photocraft/src/monitor_profile.rs +++ b/apps/photocraft/src/monitor_profile.rs @@ -495,6 +495,7 @@ mod tests { #[cfg(unix)] #[test] fn a_hung_helper_is_stopped_and_big_replies_dont_block() { + let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); let t0 = std::time::Instant::now(); let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err(); assert!(e.contains("took longer than"), "{e}"); diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs index ed44a10d9c..5a839b6025 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -36,17 +36,22 @@ fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std: /// once the user is done with it (here after 300 ms). fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { use std::io::BufRead; + let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); let mut daemon = std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?; + drop(spawns); let mut address = String::new(); std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; let address = address.trim().to_string(); let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap(); let seen: Seen = Arc::default(); let log = seen.clone(); + // A stream only gets the messages that arrive after it exists: made in the thread, it could + // miss a `SaveFile` sent before the thread got going, and the call would wait forever. + let messages = zbus::blocking::MessageIterator::from(&server); std::thread::spawn(move || { let mut script = script.into_iter(); - for msg in zbus::blocking::MessageIterator::from(&server) { + for msg in messages { let Ok(msg) = msg else { break }; let header = msg.header(); if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { From 6e460aab8a3cedd6c80211e29ebcfd412edbcbee Mon Sep 17 00:00:00 2001 From: driftee <48539371+driftee@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:38:54 +0800 Subject: [PATCH 122/304] fix(transform): isolate nested preview siblings (#7280) --- crates/ui-egui/src/transform_tool.rs | 35 ++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d46ef189bc..d14046db9d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -792,13 +792,11 @@ fn preview_image( (None, Some(s)) if layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) => Some(s), _ => { let mut solo = doc.clone(); - for l in solo.layers.iter_mut() { - if l.id != id && doc.layer(id).is_some_and(|_| !contains(l, id)) { - l.visible = false; - } else { - // Its clipping base is hidden here, which would clip it away entirely (#5239). - l.clipped = false; - } + show_only(&mut solo.layers, &[id]); + // Its clipping base is hidden here, which would clip it away entirely (#5239). + // Clear the target itself, not only the top-level group that contains it. + if let Some(l) = solo.layer_mut(id) { + l.clipped = false; } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { l.effects.enabled = false; @@ -4747,6 +4745,29 @@ mod tests { assert!(pv.texture.size()[0] as f64 >= t.rect[2] - t.rect[0]); } + #[test] + fn nested_group_preview_excludes_its_siblings() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let target_pixels = doc.layers.pop().unwrap(); + let mut sibling = Layer::raster("sibling", PixelFormat::RGBA8); + sibling.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(12, 12, 20, 20), &[0.0, 0.0, 1.0, 1.0]); + let target = Layer::group("target", vec![target_pixels]); + let target_id = target.id; + doc.layers.push(Layer::group("parent", vec![target, sibling])); + + let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!( + image.pixels[8 * image.size[0] + 8], + Color32::from_rgb(255, 0, 0), + "a sibling in the parent group must not be baked into the moving texture" + ); + } + /// The textured meshes `draw_content` paints for the moving pixels (not the box or handles). fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec { let ctx = gpu_ctx(); From 2c1c6f6693ca91eaa43b9fd1b04f9d2f319bb48a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:38:59 +0200 Subject: [PATCH 123/304] Open Photoshop guides stored in a JPEG's APP13 resources (#7300) (#7348) --- crates/codecs/src/codecs/jpeg.rs | 5 ++++- crates/io/src/flat.rs | 5 +++++ crates/io/src/psd_import.rs | 2 +- crates/io/tests/flat.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index 9c37628872..a300de4bd1 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -33,6 +33,8 @@ pub(crate) struct JpegMeta { pub exif: Option>, pub xmp: Option, pub dpi: Option<(f32, f32)>, + /// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header. + pub photoshop: Option>, pub adobe_transform: Option, /// (width, height, components) from the first SOFn. pub frame: Option<(u32, u32, u8)>, @@ -108,6 +110,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { .or_else(|| m.xmp.as_deref().and_then(xmp_resolution)) .or_else(|| m.exif.as_deref().and_then(exif_resolution)) .or(jfif_dpi); + m.photoshop = Some(photoshop).filter(|p| !p.is_empty()); if !icc_parts.is_empty() { icc_parts.sort_by_key(|p| p.0); let total = icc_parts[0].1 as usize; @@ -226,7 +229,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?; img.icc = meta.icc; - img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() }; + img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() }; if end == DataEnd::Truncated { img.warnings.push(DecodeWarning::Truncated { format: F }); } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 674c605610..773e692729 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -241,6 +241,11 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result Vec { let mut b = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec(); From e04f52266731181e5b4504aef93e099e229ed142 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:39:04 +0200 Subject: [PATCH 124/304] Centre stacked ruler digits and the Properties W/H/X/Y labels (#7314) (#7349) --- crates/ui-egui/src/doc_props_ui.rs | 43 +++++++++++++++++++--- crates/ui-egui/src/layer_props_ui.rs | 53 ++++++++++++++++++++++------ crates/ui-egui/src/props_layout.rs | 11 ++++++ crates/ui-egui/src/rulers.rs | 27 ++++++++++++-- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index fbdbc9a955..6a959c38d7 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -6,6 +6,7 @@ use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType}; use serde_json::{Value, json}; +use crate::props_layout::stacked_label; use crate::theme::Tokens; use crate::{PhotocraftApp, icons, widgets}; @@ -151,19 +152,19 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); ui.vertical(|ui| { ui.horizontal(|ui| { - field_label(ui, "W", 18.0); + stacked_label(ui, "W", &["W", "H"], 18.0, 4.0); if let Some(v) = committed_field(ui, "w", w as f32, lu) { run.extend(canvas_resize_params((w, h), Some(v), None, linked).map(|p| ("image.canvasSize".to_string(), p))); } - field_label(ui, "X", 22.0); + stacked_label(ui, "X", &["X", "Y"], 22.0, 4.0); dim_field(ui, "0 px"); }); ui.horizontal(|ui| { - field_label(ui, "H", 18.0); + stacked_label(ui, "H", &["W", "H"], 18.0, 4.0); if let Some(v) = committed_field(ui, "h", h as f32, lu) { run.extend(canvas_resize_params((w, h), None, Some(v), linked).map(|p| ("image.canvasSize".to_string(), p))); } - field_label(ui, "Y", 22.0); + stacked_label(ui, "Y", &["X", "Y"], 22.0, 4.0); dim_field(ui, "0 px"); }); }); @@ -342,6 +343,40 @@ mod tests { assert_eq!(st.history.entries().len(), undo, "no history step"); } + /// Horizontal centre of every single-letter label the panel drew, by letter. + fn letter_centres(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec<(String, f32)> { + h.output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.galley.text().chars().count() == 1) + .map(|t| (t.galley.text().to_string(), t.pos.x + t.galley.size().x * 0.5)) + .collect() + } + + /// #7314: the Canvas section's W over H (and X over Y) share a centre; a narrow "H" used to sit + /// flush right under the wider "W". + #[test] + fn canvas_size_labels_are_centred_on_each_other() { + use egui_kittest::Harness; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + properties(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let centres = letter_centres(&h); + let at = |l: &str| centres.iter().find(|(s, _)| s == l).map(|(_, x)| *x).unwrap_or_else(|| panic!("no {l} label in {centres:?}")); + assert!((at("W") - at("H")).abs() < 0.01, "W and H: {centres:?}"); + assert!((at("X") - at("Y")).abs() < 0.01, "X and Y: {centres:?}"); + } + #[test] fn document_dropdowns_map_to_live_mode_commands() { for id in MODES.iter().map(|m| m.2).chain(DEPTHS.iter().map(|d| d.2)) { diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index c2ea848c5e..57335a897b 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -6,7 +6,7 @@ use egui::{Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent}; use serde_json::{Value, json}; -use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section}; +use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section, stacked_label}; use crate::theme::{ROW_GAP, Tokens}; use crate::{PhotocraftApp, widgets}; @@ -33,11 +33,10 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option, h: Option, Some(json!({"layer": layer, "rect": b, "quad": quad})) } -/// A number field that reports a value once committed (drag released, Enter, focus lost). -fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32, u: LenUnit, extent: f64) -> Option { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover()); - ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, tl!(label), egui::FontId::proportional(12.0), t.text_dim); +/// A number field that reports a value once committed (drag released, Enter, focus lost). `label` is its own +/// label and the one stacked with it in the same column ("W" over "X"), which share a centre. +fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), current: f32, width: f32, u: LenUnit, extent: f64) -> Option { + stacked_label(ui, tl!(label.0), &[tl!(label.0), tl!(label.1)], LABEL_W, 2.0); let key = ui.make_persistent_id(("layer-props-field", id)); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(f64::from(current), extent)); let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width); @@ -137,24 +136,24 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "w", "W", b.width() as f32, w, u, dw) { + if let Some(v) = field(ui, "w", ("W", "X"), b.width() as f32, w, u, dw) { run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p))); } if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() { ui.data_mut(|d| d.insert_temp(link_key, !linked)); } - if let Some(v) = field(ui, "h", "H", b.height() as f32, w, u, dh) { + if let Some(v) = field(ui, "h", ("H", "Y"), b.height() as f32, w, u, dh) { run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p))); } }); ui.add_space(ROW_GAP); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "x", "X", b.x0 as f32, w, u, dw) { + if let Some(v) = field(ui, "x", ("X", "W"), b.x0 as f32, w, u, dw) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0}))); } ui.add_space(LINK_W + LABEL_GAP); - if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w, u, dh) { + if let Some(v) = field(ui, "y", ("Y", "H"), b.y0 as f32, w, u, dh) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32}))); } }); @@ -281,6 +280,40 @@ mod tests { } } + /// #7314: in the Transform section W sits over X and H over Y, and each pair shares a centre. + #[test] + fn transform_labels_are_centred_on_each_other() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let centres: Vec<(String, f32)> = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.galley.text().chars().count() == 1) + .map(|t| (t.galley.text().to_string(), t.pos.x + t.galley.size().x * 0.5)) + .collect(); + let at = |l: &str| centres.iter().find(|(s, _)| s == l).map(|(_, x)| *x).unwrap_or_else(|| panic!("no {l} label in {centres:?}")); + assert!((at("W") - at("X")).abs() < 0.01, "W and X: {centres:?}"); + assert!((at("H") - at("Y")).abs() < 0.01, "H and Y: {centres:?}"); + } + #[test] fn transform_fields_show_and_take_the_ruler_unit() { use photocraft_engine::prefs::Unit; diff --git a/crates/ui-egui/src/props_layout.rs b/crates/ui-egui/src/props_layout.rs index 674ff4dba2..ea67b3714d 100644 --- a/crates/ui-egui/src/props_layout.rs +++ b/crates/ui-egui/src/props_layout.rs @@ -17,6 +17,17 @@ pub const LABEL_GAP: f32 = 4.0; /// Horizontal gap between two label+field columns. pub const COL_GAP: f32 = 8.0; +/// A one-letter field label ("W", "H", "X", "Y") in a `w`-wide column, ending `pad` before its field. The letters +/// stacked in one column share a centre (#7314): the widest of `column` stays flush right, and the narrower ones +/// are centred on it rather than pushed against its right edge. +pub fn stacked_label(ui: &mut egui::Ui, s: &str, column: &[&str], w: f32, pad: f32) { + let t = Tokens::get(ui.ctx()); + let font = egui::FontId::proportional(12.0); + let widest = column.iter().map(|c| ui.painter().layout_no_wrap((*c).to_owned(), font.clone(), t.text_dim).size().x).fold(0.0, f32::max); + let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + ui.painter().text(pos2(r.right() - pad - widest / 2.0, r.center().y), egui::Align2::CENTER_CENTER, s, font, t.text_dim); +} + /// Icon for a layer kind (the same glyphs as the Layers panel's kind filter). pub fn kind_icon(content: &LayerContent) -> &'static str { match content { diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 61ea25d96f..995f04a5bb 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -393,9 +393,10 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let c = at(v); if vertical { p.line_segment([pos2(left.right() - RULER, c), pos2(left.right(), c)], tick); - // Labels stacked, as in Photoshop. + // Labels stacked, as in Photoshop, each character centred in the ruler's width + // (a narrow "1" used to hug the left edge while a "0" filled the strip). for (i, ch) in ruler_label(v, step, whole).chars().enumerate() { - p.text(pos2(left.left() + 3.0, c + 2.0 + i as f32 * 8.5), Align2::LEFT_TOP, ch, font.clone(), t.text_dim); + p.text(pos2(left.left() + RULER * 0.5, c + 2.0 + i as f32 * 8.5), Align2::CENTER_TOP, ch, font.clone(), t.text_dim); } } else { p.line_segment([pos2(c, top.bottom() - RULER), pos2(c, top.bottom())], tick); @@ -534,6 +535,28 @@ mod tests { assert!(failures.is_empty(), "ruler drawing overlaps the origin corner: {failures:?}"); } + /// #7314: the stacked characters of the left ruler's labels are centred on the strip, so a + /// narrow "1" lines up with a wide "0". + #[test] + fn left_ruler_label_characters_are_centred() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + let (mut h, full, _) = ruler_harness(app); + h.run_steps(2); + let centres: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.pos.x < full.min.x + RULER && t.pos.y > full.min.y + RULER) + .map(|t| t.pos.x + t.galley.size().x * 0.5) + .collect(); + assert!(centres.len() > 2, "the left ruler draws labels: {centres:?}"); + for c in ¢res { + assert!((c - (full.min.x + RULER * 0.5)).abs() < 0.01, "character centre {c} in {centres:?}"); + } + } + #[test] fn shift_snaps_the_zero_point_to_ruler_ticks_from_the_current_origin() { assert_eq!(snap_px_to_tick(47.0, 0.0, 10.0), 50.0); From c8c651c413cda3b3ed55557b840d711024980d16 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:39:09 +0200 Subject: [PATCH 125/304] Esc leaves full screen with the Crop tool showing its default frame (#7315) (#7350) --- crates/ui-egui/src/shortcuts.rs | 5 ++++- crates/ui-egui/src/view_cmds/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 99613d5a26..bfa1904c4f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -627,7 +627,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } return; } - if pressed(Key::Escape) { + // In a full screen mode, the Crop tool's untouched default frame (Straighten off) has nothing to cancel: cancelling only + // makes the next one, so Esc goes on to leave full screen (#7315). + let untouched = app.ui.view.screen_mode != "standard" && app.ui.polygon.is_empty() && app.crop.default_frame && !crate::crop_straighten::mode(app); + if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); crate::crop_ui::cancel(app); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 1abe863558..9361e51254 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -500,6 +500,29 @@ fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); } +#[test] +fn esc_leaves_full_screen_with_the_crop_tool_showing_its_default_frame() { + let (mut app, ctx) = app_with(1); + app.ui.tool = crate::Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); + for mode in ["fullScreen", "fullScreenWithMenuBar"] { + menu(&mut app, &ctx, &format!("view.screenMode.{mode}"), json!({})).unwrap(); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard", "{mode}"); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "{mode}: the default frame stays"); + } + // With Straighten armed, Esc drops it first and the mode stays. + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + app.crop.straighten.armed = true; + press_esc(&mut app, &ctx); + assert!(!app.crop.straighten.armed); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + crate::crop_ui::ensure_frame(&mut app); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); +} + #[test] fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { let (mut app, ctx) = app_with(1); From 5f648126c7f84ee432d874abd61cba42ff15b537 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:15 +0200 Subject: [PATCH 126/304] Crop and Straighten Photos keeps Duotone inks and is disabled for Indexed Color (#7354) (#7355) The copies were made with Document::new and kept the mode but not the duotone inks, so a Duotone scan came out as plain gray. Copy them, and grey the command out for Indexed Color documents, as Photoshop does. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/photo_cmds.rs | 60 +++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 09fd474aad..72851b3679 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -859,6 +859,7 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { let mut nd = Document::new(format!("{} copy {}", doc.name, k + 1), Size::new(pw, ph), doc.mode, doc.depth); nd.icc_profile = doc.icc_profile.clone(); nd.resolution_dpi = doc.resolution_dpi; + nd.duotone = doc.duotone.clone(); let mut bg = Layer::new("Background", LayerContent::Raster(px)); bg.locks.transparency = true; bg.locks.position = true; @@ -871,8 +872,13 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { // ---------- specs ---------- -fn has_doc(s: &Session) -> std::result::Result<(), String> { - s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +/// Photoshop greys Crop and Straighten Photos out for Indexed Color documents. +fn not_indexed(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.doc.mode == ColorMode::Indexed { + return Err("not available for Indexed Color documents".into()); + } + Ok(()) } fn always(_: &Session) -> std::result::Result<(), String> { @@ -907,7 +913,7 @@ pub fn specs() -> Vec { menu: &["File", "Automate"], shortcut: None, params: "{} → one new document per photo found on the scan", - enabled: has_doc, + enabled: not_indexed, run: crop_and_straighten, journal: true, }, @@ -1106,6 +1112,54 @@ mod tests { if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } } + /// A light scan with one dark tilted print on it. + fn scan_with_one_photo() -> Document { + let (w, h) = (300usize, 220usize); + let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h]; + let (s, co) = 5.0f64.to_radians().sin_cos(); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f64 + 0.5 - 150.0, y as f64 + 0.5 - 110.0); + let (u, v) = (co * dx + s * dy, -s * dx + co * dy); + if u.abs() < 80.0 && v.abs() < 55.0 { + px[y * w + x] = [0.2 + (u / 160.0) as f32 * 0.3, 0.25, 0.3, 1.0]; + } + } + } + doc_from("scan", &px, w, h, SampleType::U8) + } + + #[test] + fn crop_and_straighten_keeps_duotone_inks() { + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute( + "image.mode.duotone", + json!({"type": "duotone", "inks": [{"name": "Black", "color": [0, 0, 0]}, {"name": "Orange", "color": [255, 128, 0]}]}), + ) + .unwrap(); + let src = s.documents()[0].doc.duotone.clone(); + assert!(src.is_some()); + let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap(); + assert_eq!(r["photos"].as_array().unwrap().len(), 1, "{r}"); + let d = &s.documents()[1].doc; + assert_eq!(d.mode, ColorMode::Duotone); + assert_eq!(d.duotone, src); + } + + #[test] + fn crop_and_straighten_disabled_for_indexed() { + // Photoshop greys the command out for Indexed Color documents. + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + assert_eq!(s.documents()[0].doc.mode, ColorMode::Indexed); + assert!(!s.is_enabled("file.automate.cropAndStraightenPhotos")); + assert!(s.execute("file.automate.cropAndStraightenPhotos", json!({})).is_err()); + assert_eq!(s.documents().len(), 1); + } + #[test] fn crop_and_straighten_makes_documents() { let (w, h) = (500usize, 360usize); From 06d4d31dd4e77886279577daaefc5659fecb8302 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:19 +0200 Subject: [PATCH 127/304] Tool Presets: + saves only the current tool's options (#7356) (#7357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button in Window › Tool Presets stored the whole ToolOptions struct, so choosing the preset later wrote every other tool's options back (e.g. Crop's Delete Cropped Pixels). Reset Tool's per-tool field lists become `option_keys`, which the + button now uses to store only the tool's own options. Brush/Pencil Clear, the Eraser's mode, the Remove tool's options and Content-Aware Move's Transform On Drop join their tools' lists, and a test checks that every option belongs to a tool. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/preset_panels.rs | 36 +++++++++++++++++-- crates/ui-egui/src/tool_reset.rs | 54 ++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 8411c5af63..1cd542dbf3 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1025,6 +1025,15 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { } } +/// The `tool.presets.new` params for the + button: `tool`'s own options only, so choosing the +/// preset later leaves the other tools' settings alone. +fn new_tool_preset_params(app: &PhotocraftApp, tool: Tool) -> Value { + let all = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); + let own: serde_json::Map = + crate::tool_reset::option_keys(tool).iter().filter_map(|k| Some(((*k).to_string(), all.get(*k)?.clone()))).collect(); + json!({"name": tool.label(), "tool": tool_id(tool), "options": {"toolOptions": own}}) +} + pub fn tool_presets_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let only = app.ui.presets_ui.current_tool_only; @@ -1093,9 +1102,7 @@ pub fn tool_presets_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { action = Some(("tool.presets.edit".into(), json!({"action": "delete", "preset": n}))); } if crate::icons::button(ui, "plus", 24.0, false, tl!("Create new tool preset")).clicked() { - let label = cur.label().to_string(); - let opts = json!({"toolOptions": serde_json::to_value(&app.ui.tool_options).unwrap_or_default()}); - action = Some(("tool.presets.new".into(), json!({"name": label, "tool": tool_id(cur), "options": opts}))); + action = Some(("tool.presets.new".into(), new_tool_preset_params(app, cur))); } }); }); @@ -1262,6 +1269,7 @@ pub fn clone_sample_point(app: &PhotocraftApp, at: Option<[f64; 2]>) -> Option<[ mod tests { use super::*; use crate::menus::invoke; + use crate::state::ToolOptions; fn app() -> (PhotocraftApp, egui::Context) { let ctx = egui::Context::default(); @@ -1311,6 +1319,28 @@ mod tests { assert_eq!(Tool::from_name(&tool_id(Tool::CustomShape)), Some(Tool::CustomShape)); } + /// #7356: a preset made with + keeps only its own tool's options, so choosing it doesn't + /// reset the Crop tool's Delete Cropped Pixels or the Move tool's Auto-Select. + #[test] + fn new_tool_preset_stores_only_its_tools_options() { + let (mut app, _) = app(); + app.ui.tool = Tool::RectMarquee; + app.ui.tool_options.feather = 5.0; + let p = new_tool_preset_params(&app, Tool::RectMarquee); + let r = app.run("tool.presets.new", p).unwrap(); + let name = r["name"].as_str().unwrap().to_string(); + let stored = &app.session.presets.tool_presets.iter().find(|t| t.name == name).unwrap().options["toolOptions"]; + assert_eq!(stored["feather"], json!(5.0)); + assert!(stored.as_object().unwrap().keys().all(|k| !k.starts_with("crop_") && !k.starts_with("move_")), "{stored}"); + app.ui.tool_options.feather = 0.0; + app.ui.tool_options.crop_delete = !ToolOptions::default().crop_delete; + app.ui.tool_options.move_auto_select = !ToolOptions::default().move_auto_select; + select_tool_preset(&mut app, &name); + assert_eq!(app.ui.tool_options.feather, 5.0); + assert_eq!(app.ui.tool_options.crop_delete, !ToolOptions::default().crop_delete); + assert_eq!(app.ui.tool_options.move_auto_select, !ToolOptions::default().move_auto_select); + } + #[test] fn custom_shape_tool_places_the_picked_shape() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 27e9622ab3..9fa9169f87 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -113,9 +113,32 @@ fn is_selection_tool(tool: Tool) -> bool { /// Put `tool`'s fields of `o` back to their defaults. pub fn reset_options(o: &mut ToolOptions, tool: Tool) { - let d = ToolOptions::default(); + let keys = option_keys(tool); + if keys.is_empty() { + return; + } + let (Ok(Value::Object(mut cur)), Ok(Value::Object(d))) = (serde_json::to_value(&*o), serde_json::to_value(ToolOptions::default())) else { + return; + }; + for k in keys { + if let Some(v) = d.get(*k) { + cur.insert((*k).to_string(), v.clone()); + } + } + if let Ok(n) = serde_json::from_value(Value::Object(cur)) { + *o = n; + } +} + +/// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and +/// what a tool preset made with + stores (#7356). +pub fn option_keys(tool: Tool) -> &'static [&'static str] { + // `reset!` names each field once; one that is misspelt or gone fails to compile. macro_rules! reset { - ($($f:ident),* $(,)?) => {{ $( o.$f = d.$f.clone(); )* }}; + ($($f:ident),* $(,)?) => {{ + let _ = |o: &ToolOptions| { $(let _ = &o.$f;)* }; + &[$(stringify!($f)),*] + }}; } match tool { Tool::Move => reset!(move_auto_select, move_target, move_show_transform), @@ -144,7 +167,10 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { ), Tool::PerspectiveCrop => reset!(crop_width, crop_height, crop_resolution, crop_resolution_unit, perspective_crop_grid), Tool::Eyedropper => reset!(eyedropper_size, eyedropper_sample, eyedropper_ring), - Tool::Pencil => reset!(pencil_auto_erase), + Tool::Brush => reset!(brush_clear), + Tool::Pencil => reset!(pencil_auto_erase, pencil_clear), + Tool::Eraser => reset!(eraser_mode), + Tool::Remove => reset!(remove_new_layer, remove_after_each_stroke), Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg), Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), @@ -155,7 +181,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::SpotHealing => reset!(spot_type, sample_all_layers), Tool::Healing => reset!(clone_aligned, clone_sample), Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color, sample_all_layers), - Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, sample_all_layers), + Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, cam_transform_on_drop, sample_all_layers), Tool::RedEye => reset!(red_eye_pupil_size, red_eye_darken), Tool::CloneStamp => reset!(clone_aligned, clone_sample), Tool::PatternStamp => reset!(pattern_stamp_aligned, pattern_stamp_impressionist, pattern_stamp_scale, pattern_stamp_angle), @@ -173,12 +199,9 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { | Tool::Ruler | Tool::Note | Tool::Count - | Tool::Brush | Tool::MixerBrush - | Tool::Eraser | Tool::Hand | Tool::RotateView - | Tool::Remove | Tool::HistoryBrush | Tool::PathSelection | Tool::DirectSelection @@ -188,7 +211,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { | Tool::DeleteAnchorPoint | Tool::ConvertPoint | Tool::Artboard - | Tool::Frame => {} + | Tool::Frame => &[], } } @@ -202,6 +225,21 @@ mod tests { PhotocraftApp::new(s, crate::Services::default()) } + /// Every option belongs to a tool, so Reset Tool and tool presets (#7356) miss none, and every + /// listed name is a real serialized field. + #[test] + fn every_option_belongs_to_a_tool() { + let all = serde_json::to_value(ToolOptions::default()).unwrap(); + let all = all.as_object().unwrap(); + let listed: std::collections::BTreeSet<&str> = Tool::ALL.iter().flat_map(|t| option_keys(*t).iter().copied()).collect(); + for k in all.keys() { + assert!(listed.contains(k.as_str()), "{k} is no tool's option"); + } + for k in listed { + assert!(all.contains_key(k), "{k} is not a ToolOptions field"); + } + } + /// Reset Tool resets only the current tool's options; Reset All Tools asks, then resets all. #[test] fn reset_tool_and_reset_all_tools() { From fb6f8b4fa6cab1c441f945ad03bea53d89d626d6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:24 +0200 Subject: [PATCH 128/304] Canvas Size: growing the canvas re-renders PSD fill layers (#7362) (#7363) A fill layer from a PSD keeps its stored pixels as a fill cache that covers only the old canvas. Canvas Size, an extending Crop and Reveal All translated that cache but kept it, so the added area of the fill layer stayed transparent. Drop the fill caches whenever the canvas grows, so the fill re-renders from its model over the whole canvas, as Photoshop does (Free Transform and Perspective Crop already drop it). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 18 ++++++++++++++++++ crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..4559ca49a5 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -54,6 +54,18 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn } } +/// Drops every fill cache of a layer tree. A fill layer covers the whole canvas, but its cache +/// (the pixels a PSD stored) only the old one: after the canvas grows the fill must re-render +/// from its model, or the added area stays transparent (#7362). +fn drop_fill_caches(layers: &mut [Layer]) { + for l in layers { + l.fill_cache = None; + if let LayerContent::Group(g) = &mut l.content { + drop_fill_caches(&mut g.children); + } + } +} + /// Converts every pixel surface of a layer tree (masks included) to `depth`. pub(crate) fn convert_layers_depth(layers: &mut [Layer], depth: SampleType) { for_each_surface(layers, true, &mut |surf, _| { @@ -337,6 +349,9 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi surf.write_interleaved(keep, &saved); } } + if extends { + drop_fill_caches(&mut doc.layers); + } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); Ok(()) } @@ -397,6 +412,9 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { s.edit("Canvas Size", |doc, _| { translate_doc(doc, "image.canvasSize", dx, dy)?; doc.size = Size::new(nw, nh); + if nw > ow as u32 || nh > oh as u32 { + drop_fill_caches(&mut doc.layers); + } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); let canvas = doc.bounds(); let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..0849cd941a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -702,6 +702,38 @@ fn fill_layer_pixel_rewrites_refresh_the_effect_maps() { assert_eq!(after, fresh, "the effect maps track the cache surface, not the Fill spec alone"); } +#[test] +fn growing_the_canvas_re_renders_a_cached_fill_layer() { + // A PSD fill layer keeps its stored pixels as a fill cache covering only the old canvas. + // Canvas Size, an extending Crop and Reveal All must re-render the fill over the new area, + // as Photoshop does, instead of leaving the added bands transparent (#7362). + let grow: [(&str, Value); 2] = [ + ("image.canvasSize", json!({"width": 100, "anchor": "left"})), + ("image.crop", json!({"x": 0, "y": 0, "width": 100, "height": 48, "deleteCroppedPixels": false})), + ]; + for (cmd, params) in grow { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "transparent"})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})).unwrap(); + s.edit("cache", |doc, active| { + let fmt = doc.pixel_format(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let photocraft_doc::LayerContent::Fill(f) = &l.content else { return Err(EngineError::Other("not a fill layer".into())) }; + let fill = f.clone(); + let mut surface = photocraft_raster::Surface::new(fmt); + surface.fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &[1.0, 0.0, 0.0, 1.0]); + l.fill_cache = Some(photocraft_doc::FillCache { fill, surface }); + Ok(()) + }) + .unwrap(); + s.execute(cmd, params).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.size.width, 100, "{cmd} grows the canvas"); + let px = photocraft_compose::render(d, d.bounds()).get(90, 20); + assert!(px[3] > 0.99 && px[0] > 0.99 && px[1] < 0.01, "{cmd}: the fill covers the added area, got {px:?}"); + } +} + #[test] fn group_effect_maps_follow_child_visibility_and_opacity() { // A styled group's maps come from its children's composite: hiding a child or changing its From a4c7b5ef89654b2cc3c1beccaff2af947ea0b228 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:29 +0200 Subject: [PATCH 129/304] Rotate Canvas Arbitrary: keep the angle at full precision, so rotated crops land on the pixel (#7369) (#7370) rotate_arbitrary read `angle` as f32. rotated_crop sizes its canvas with rotated_size at f64 and passes the angle through JSON; near a whole-pixel ceil boundary the f32 angle grew the canvas 1 px more (1000x1000 at 0.11470651449337071: 1003 instead of 1002), shifting the crop by 1 px. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 3 ++- crates/engine/src/mode_cmds/tests.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 840f1c6096..4625368ff6 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -63,7 +63,8 @@ pub fn rotated_size(size: Size, deg: f64) -> Size { pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.rotation.arbitrary"; - let angle = f64::from(num(p, "angle", 0.0)); + // At full precision: an f32 angle can grow the canvas 1 px past what `rotated_size` gives callers. + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(0.0); if !angle.is_finite() || angle.abs() > 3600.0 { return Err(bad(CMD, "angle out of range")); } diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index a78a11db7d..d506128d78 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -81,6 +81,18 @@ fn rotate_arbitrary_other_modes() { } } +#[test] +fn rotate_arbitrary_keeps_the_full_precision_angle() { + // Rounded to f32 this angle crosses a whole pixel: 1003 px instead of 1002, so a rotated crop + // that sized its canvas with `rotated_size` at f64 would land 1 px off. + let angle = 0.114_706_514_493_370_71; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1000, "height": 1000})).unwrap(); + let r = s.execute("image.rotation.arbitrary", json!({"angle": angle})).unwrap(); + assert_eq!(rotated_size(Size::new(1000, 1000), angle), Size::new(1002, 1002)); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(1002), Some(1002))); +} + #[test] fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() { let mut s = Session::new(); From d459ca7939bdde87ace76537902f8286d86137a7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:39:34 +0200 Subject: [PATCH 130/304] Image Rotation: Arbitrary keeps Color Sampler points on their pixel (#7372) (#7373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sampler points are pixel indices, but rotate_arbitrary mapped them as the pixel's top-left corner while the warp moves pixels by their centres, so after a turn a point sat one pixel off its pixel, or fell off the canvas (a corner point at 90° or 180°). Map the pixel centre, as #6720 does for flips and right-angle turns; the turned Crop shares this map. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 4 +++- crates/engine/src/sampler_cmds.rs | 40 +++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 4625368ff6..fbe58af6a3 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -78,13 +78,15 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { } let bg = s.tools.background; // Color Sampler points turn with the canvas (the same map the edit applies to the geometry). + // They are pixel indices, so map the pixel's centre, as the warp samples the pixels. { let old = s.active().ok_or(EngineError::NoDocument)?.doc.size; let new = rotated_size(old, deg); let (sn, cs) = deg.to_radians().sin_cos(); let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); - s.set_sampler_map(Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }); + let a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }; + s.set_sampler_map(Affine::translate(0.5, 0.5).then(&a).then(&Affine::translate(-0.5, -0.5))); } let size = s.edit("Rotate Canvas", |doc, _| { let old = doc.size; diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 0894ab72e1..57787b2e83 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -365,7 +365,7 @@ mod tests { #[test] fn a_right_angle_rotation_moves_points() { - // 90° clockwise about the centre: the doc centre stays put. + // 90° clockwise about the centre: pixel (x, y) of the 100×80 canvas goes to (79 - y, x). let mut s = session(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap(); @@ -373,8 +373,7 @@ mod tests { assert_eq!(p.len(), 1); let pos = p[0]["position"].as_array().unwrap(); let (x, y) = (pos[0].as_f64().unwrap(), pos[1].as_f64().unwrap()); - // The new canvas is 80×100, so the centre is (40, 50). - assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); + assert!((x - 39.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); } #[test] @@ -406,15 +405,46 @@ mod tests { } } + #[test] + fn an_arbitrary_rotation_keeps_points_on_their_pixel() { + // The pixel at (3, 3) of the 100×80 document, and where each turn moves it. + for (angle, direction, at) in [(90, "cw", [76.0, 3.0]), (90, "ccw", [3.0, 96.0]), (180, "cw", [96.0, 76.0])] { + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 0, "y": 0})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": angle, "direction": direction})).unwrap(); + assert_eq!(s.take_samplers_removed(), 0, "{angle} {direction}: the corner point stays on the canvas"); + let p = points(&mut s); + assert_eq!(p.len(), 2, "{angle} {direction}: the corner point stays on the canvas"); + assert_eq!(p[0]["position"], json!(at), "{angle} {direction}"); + assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{angle} {direction}"); + } + } + #[test] fn a_rotated_crop_moves_points() { - // The crop frame is centred on the point, so the point lands at the new canvas centre. + // The crop frame is centred on the point's pixel; turned 90° that pixel lands at (10, 9). let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(50, 40, 51, 41), &black); + Ok(()) + }) + .unwrap(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20, "angle": 90})).unwrap(); let p = points(&mut s); assert_eq!(p.len(), 1); - assert_eq!(p[0]["position"], json!([10.0, 10.0])); + assert_eq!(p[0]["position"], json!([10.0, 9.0])); + assert_eq!(p[0]["rgb"], json!([0, 0, 0])); } #[test] From 4b2bee675eb2741c7d4505e6baf941930f8c409a Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 20:09:39 +0530 Subject: [PATCH 131/304] Spot Healing Brush trail is Photoshop's dark half-opaque tint (#7342) (#7374) The stroke preview was white at a quarter opacity, invisible on white and faint elsewhere. Photoshop shows the area to heal in half-opaque dark grey; the trail now does too. Other retouch trails are unchanged. Test: drag_preview_canvas::the_spot_healing_trail_is_a_dark_half_opaque_tint drags the real canvas offscreen on white and mid-grey and checks the trail darkens the pixels to 40-60 %; it fails with the old colour. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/tests/drag_preview_canvas.rs | 24 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 91644b5f73..c2494a08e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4968,6 +4968,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View Tool::QuickSelection => Color32::from_white_alpha(40), // The Remove Tool tints what it will remove, like Photoshop's. Tool::Remove => crate::theme::Tokens::get(painter.ctx()).danger.gamma_multiply(0.45), + // Photoshop paints the area to heal in half-opaque dark grey; a faint white trail + // barely showed on light pixels (#7342). + Tool::SpotHealing => Color32::from_black_alpha(128), _ => Color32::from_white_alpha(60), }; let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/drag_preview_canvas.rs index fc516ed307..9b7d682abb 100644 --- a/crates/ui-egui/tests/drag_preview_canvas.rs +++ b/crates/ui-egui/tests/drag_preview_canvas.rs @@ -295,3 +295,27 @@ fn a_held_marquee_drag_keeps_requesting_frames() { assert!(d.is_some_and(|d| d <= Duration::from_millis(100)), "mid-drag repaint request: {d:?}"); button(&mut h, 300.0, 220.0, false); } + +#[test] +fn the_spot_healing_trail_is_a_dark_half_opaque_tint() { + // #7342: the trail was white at a quarter opacity, invisible on white and faint elsewhere. + // Photoshop shows the area to heal in half-opaque dark grey. + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = harness(1.0) else { return }; + h.run_steps(4); + for bg in ["white", "#808080"] { + setup(&mut h, bg, "spotHealing"); + let app = h.state_mut(); + app.session.tools.brush.size = 40.0; + app.session.tools.brush.hardness = 1.0; + let before = render(&mut h); + drag_to(&mut h, [80.0, 150.0], [320.0, 150.0]); + let img = render(&mut h); + let p = screen(&h, 200.0, 150.0); + let (was, now) = (before.luma(p.x as i64, p.y as i64).unwrap_or(0), img.luma(p.x as i64, p.y as i64).unwrap_or(0)); + assert!(f32::from(now) < f32::from(was) * 0.6, "spot healing trail on {bg}: luma {was} -> {now}, not dark enough"); + assert!(f32::from(now) > f32::from(was) * 0.4, "spot healing trail on {bg}: luma {was} -> {now}, should stay see-through"); + button(&mut h, 320.0, 150.0, false); + h.run_steps(2); + } +} From 89fa322a3c6f65373fe49944f39c238d9413e143 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 16:50:27 +0200 Subject: [PATCH 132/304] Crop: Esc and Enter go to an open options bar popup first (#7386) (#7388) The shortcut pass runs before the panels are drawn, and its crop/polygon branch consumed Esc and Enter even while an egui popup of the options bar was open (the Ratio list, the overlay and gear menus): Esc cancelled the crop and left the list open, Enter committed the crop beneath it. Skip the branch while `egui::Popup::is_any_open` (kept in memory from the last frame; `ctx.any_popup_open` only sees the current pass). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/shortcuts.rs | 5 +- crates/ui-egui/tests/crop_popup_keys.rs | 92 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/crop_popup_keys.rs diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bfa1904c4f..1ad8d6d92a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -618,7 +618,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - if !app.ui.polygon.is_empty() || app.ui.crop_rect.is_some() { + // An open options bar popup (the Crop Ratio list, the overlay and gear menus) takes the keys + // first: Esc closes it, and neither key reaches the crop beneath (#7386). Its id is kept in + // memory from the last frame; `ctx.any_popup_open` only sees this frame's, drawn later. + if (!app.ui.polygon.is_empty() || app.ui.crop_rect.is_some()) && !egui::Popup::is_any_open(ctx) { if pressed(Key::Enter) { if !app.ui.polygon.is_empty() { crate::canvas::commit_polygon(app); diff --git a/crates/ui-egui/tests/crop_popup_keys.rs b/crates/ui-egui/tests/crop_popup_keys.rs new file mode 100644 index 0000000000..9c893ae7e0 --- /dev/null +++ b/crates/ui-egui/tests/crop_popup_keys.rs @@ -0,0 +1,92 @@ +//! Crop tool: Esc and Enter belong to an open options bar popup first (#7386). The shortcut pass +//! runs before the panels are drawn, so the crop branch used to take the key: Esc cancelled the +//! frame and left the Ratio list open, Enter committed the crop beneath it. +//! +//! Skips when no GPU adapter exists (like `crop_default_mode.rs`). + +use egui::Key; +use egui_kittest::kittest::Queryable; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + // The queries below use English labels; don't follow the system locale. + app.session.prefs.edit(|p| p.interface.language = "en".into()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A 200x100 document with the Crop tool's 1:1 frame pending and the Ratio list open again. +fn setup() -> Option { + let mut h = harness()?; + h.run_steps(4); + { + let app = h.state_mut(); + app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).expect("new"); + app.sync_views(); + } + control(&mut h, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [100, 50]})); + h.get_by_value("Ratio").click(); + h.run_steps(3); + h.get_by_label("1 : 1 (Square)").click(); + h.run_steps(3); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "the 1:1 frame is pending"); + assert!(!egui::Popup::is_any_open(&h.ctx), "the list closed after the pick"); + h.get_by_value("1 : 1 (Square)").click(); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "the Ratio list is open again"); + Some(h) +} + +#[test] +fn esc_closes_the_open_ratio_list_and_keeps_the_frame() { + // Windows WARP can access-violate when GPU harnesses initialize or tear down concurrently: + // hold the GPU lock for the whole test, harness included. + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + let frame = h.state().ui.crop_rect; + h.key_press(Key::Escape); + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx), "Esc closes the list"); + assert_eq!(h.state().ui.crop_rect, frame, "the frame stays as it was"); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "still pending"); + // With the list closed, the keys reach the crop again: Enter commits it. + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(100, 100), "the next Enter crops"); +} + +#[test] +fn enter_with_the_ratio_list_open_does_not_commit_the_crop() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(200, 100), "nothing cropped"); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "the frame is still pending"); +} From 80553ecd939ace5dd530416f827ad6b9657236dd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:32 +0200 Subject: [PATCH 133/304] Refuse strict ATN export of Close with an explicit document target (#7393) Fixes #7299 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 7 ++++++- crates/engine/src/atn_cmds/tests.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index fa86e292f3..d027dd15cb 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1577,7 +1577,12 @@ fn close_from(d: &Descriptor) -> Mapped { if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } } -fn close_to(_: &str, _: &Value) -> Option { +/// A Close of an explicit document has no mapping here: written as a plain Close it would +/// close the active document on playback, so it is left to `skipUnsupported`. +fn close_to(_: &str, p: &Value) -> Option { + if p.get("document").is_some_and(|d| !d.is_null()) { + return None; + } Some(desc("null").with("Svng", enm("YsN ", "N "))) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 980d5eb9e2..fd8038775e 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1005,3 +1005,18 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer"); assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } + +/// Photoshop's Close has no document target: a Close of a specific document must not be +/// exported as a plain Close, which would close the active document on playback. +#[test] +fn close_with_document_target_is_not_exported_as_plain_close() { + let targeted = Action { name: "Probe".into(), steps: vec![("file.close".into(), json!({"document": 0}))], ..Default::default() }; + assert!(export("Audit4", &[&targeted], &[], false).is_err()); + let (_, skipped) = export("Audit4", &[&targeted], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "file.close".into() }]); + let plain = Action { name: "Plain".into(), steps: vec![("file.close".into(), json!({}))], ..Default::default() }; + let (bytes, skipped) = export("Audit4", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("file.close".to_string(), json!({}))]); +} From 713dbbed2f779a393fc86949a1077b317f1d50a7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:37 +0200 Subject: [PATCH 134/304] Save Actions ATN files atomically so a failed write keeps the old file (#7403) Fixes #7290 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 3 ++- crates/engine/src/atn_cmds/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d027dd15cb..e0767330ca 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3264,7 +3264,8 @@ fn save(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> std::io::Result<()> { - std::fs::write(path, bytes) + // Atomic replacement (temporary file + rename): a failed write keeps the previous ATN (#7290). + photocraft_format::atomic_write(std::path::Path::new(path), bytes) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index fd8038775e..b280a6d679 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -585,6 +585,33 @@ fn save_writes_the_file_and_the_set_of_the_selected_action() { std::fs::remove_dir_all(&dir).ok(); } +/// #7290: saving over an existing ATN must replace it atomically (a temporary file renamed over +/// it), never truncate and rewrite it in place, so a write that fails partway (a full disk) +/// leaves the previously valid file readable. A handle opened on the old file still sees the old +/// bytes after an atomic replacement; an in-place rewrite changes them under it. +#[cfg(unix)] +#[test] +fn save_over_an_existing_file_never_rewrites_it_in_place() { + use std::io::Read; + let mut s = Session::new(); + s.actions.list.push(Action::new("Owned", vec![("select.all".into(), json!({}))])); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Owned.atn"); + let p = path.to_string_lossy().to_string(); + s.execute("actions.save", json!({"path": p})).unwrap(); + let before = std::fs::read(&path).unwrap(); + let mut old = std::fs::File::open(&path).unwrap(); + s.actions.list.push(Action::new("Second", vec![("select.all".into(), json!({}))])); + s.execute("actions.save", json!({"path": p})).unwrap(); + let mut seen = Vec::new(); + old.read_to_end(&mut seen).unwrap(); + assert_eq!(seen, before, "the existing ATN was rewritten in place"); + let after = atn::parse(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after.actions.len(), 2); + let names: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, vec![std::ffi::OsString::from("Owned.atn")], "no temporary file is left behind"); +} + #[test] fn layer_param_accepts_a_name() { let mut s = Session::new(); From 65778c1f7e930d9a3d0b37ced92bbe43735a8d26 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:41 +0200 Subject: [PATCH 135/304] Raw tone curve: no NaN between a knot at black and the next one (#7416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `develop::tone` interpolates the camera curve logarithmically between knots, and a curve whose first knot sits at black (0, 0) made the first segment divide by log₂ 0: every value in it came out NaN, and `retone` (the open-time Shadows/Highlights preview) propagated it into the pixels. From a knot at black the segment is now linear, where log₂ has no value; the other segments are unchanged. Test: `retone_lifts_shadows_with_a_curve_from_black` (finite output, shadows lifted, highlights kept) fails without the fix. Split out of #3816 at the reviewer's request. Co-authored-by: Claude Fable 5.1 --- crates/raw/src/develop.rs | 3 ++- crates/raw/src/tone.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index e3ab70b0c7..5d28508750 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -535,7 +535,8 @@ pub(crate) fn tone(curve: &[[f32; 2]], v: f32) -> f32 { } let i = curve.partition_point(|p| p[0] <= v).clamp(1, curve.len() - 1); let ([x0, y0], [x1, y1]) = (curve[i - 1], curve[i]); - let f = (v.log2() - x0.log2()) / (x1.log2() - x0.log2()); + // Logarithmic between knots; linear from a knot at black, where log₂ has no value. + let f = if x0 > 0.0 { (v.log2() - x0.log2()) / (x1.log2() - x0.log2()) } else { (v - x0) / (x1 - x0) }; y0 + (y1 - y0) * f } diff --git a/crates/raw/src/tone.rs b/crates/raw/src/tone.rs index 80a80bfdd6..1c2730bd2c 100644 --- a/crates/raw/src/tone.rs +++ b/crates/raw/src/tone.rs @@ -710,6 +710,19 @@ mod tests { assert!(Haze::new(w, h, |i| px[i], 0.0).is_none()); } + #[test] + fn retone_lifts_shadows_with_a_curve_from_black() { + // A curve whose first knot is black: its first segment must not divide by log₂ 0. + let (w, h) = (96, 64); + let curve = [[0.0, 0.0], [0.05, 0.12], [0.3, 0.6], [1.0, 1.0]]; + let px: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 48 { [0.12, 0.11, 0.1, 1.0] } else { [0.7, 0.63, 0.56, 1.0] }).collect(); + let mut out = px.clone(); + retone(&mut out, w, h, &curve, SHADOWS); + assert!(out.iter().flatten().all(|v| v.is_finite())); + assert!(out[30 * w + 10][0] > px[30 * w + 10][0] + 0.05, "{:?}", out[30 * w + 10]); + assert!((out[30 * w + 80][0] - px[30 * w + 80][0]).abs() < 0.03, "{:?}", out[30 * w + 80]); + } + #[test] fn retone_changes_nothing_at_zero_and_matches_the_develop_stage() { let (w, h) = (300, 120); From 491af6d42c3043a079ef4a37b2562ba2d8e7acb4 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:47 +0200 Subject: [PATCH 136/304] Camera Raw histogram: spikes run off the top, as in Photoshop's Camera Raw (#7419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog's histogram fitted its tallest interior column to the plot, so one spike (a dark sky, a bright wall) flattened the midtones' hill to a few pixels. Camera Raw 16.1.1 scales linearly by the typical columns and lets a spike run off the top (measured on Photoshop 25.4: 49 px per 10 000 pixels in a 106 px plot, the 95th-percentile column at 14 546, the tallest at 21 600). `rgb_histogram::Spikes`: `Fit` (the old behaviour, every other histogram keeps it) and `Clip` (the scale is at most 1.5 × the 95th-percentile column, taller columns clip at the top). The Camera Raw dialog's histogram uses `Clip`. Test: `camera_raw_scale_lets_a_shadow_spike_run_off_the_top`. Split out of #3816 at the reviewer's request. Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/camera_raw_scope_ui.rs | 3 +- crates/ui-egui/src/rgb_histogram.rs | 62 ++++++++++++++++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/camera_raw_scope_ui.rs b/crates/ui-egui/src/camera_raw_scope_ui.rs index c9f98080c9..9cb5233d00 100644 --- a/crates/ui-egui/src/camera_raw_scope_ui.rs +++ b/crates/ui-egui/src/camera_raw_scope_ui.rs @@ -384,7 +384,8 @@ fn graph(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeSt response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), tl!("Tone Histogram"))); ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background()); let plot = rect.shrink(4.0); - crate::rgb_histogram::paint(ui.painter(), plot, d.histogram(), &t); + // Camera Raw's scale: the typical columns set it and a shadow or highlight spike runs off the top. + crate::rgb_histogram::paint_with(ui.painter(), plot, d.histogram(), &t, crate::rgb_histogram::Spikes::Clip); let corner = 18.0; if let Some(sample) = d.hover_sample(false) { crate::rgb_histogram::paint_sample(ui.painter(), plot, sample.rgb, &t); diff --git a/crates/ui-egui/src/rgb_histogram.rs b/crates/ui-egui/src/rgb_histogram.rs index f8787ad66b..efae4cce41 100644 --- a/crates/ui-egui/src/rgb_histogram.rs +++ b/crates/ui-egui/src/rgb_histogram.rs @@ -5,7 +5,7 @@ use photocraft_algo::histogram::{BINS, RgbHistogram}; /// A five-bin binomial display kernel softens quantization combs. It cannot bridge /// a wide empty range. Clipping bins are pinned and excluded from filtering adjacent bins. -fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] { +fn curves(h: &RgbHistogram, spikes: Spikes) -> [[f32; BINS]; 3] { let mut curves = [[0.0; BINS]; 3]; for (out, bins) in curves.iter_mut().zip(&h.channels) { for (i, value) in out.iter_mut().enumerate() { @@ -22,7 +22,18 @@ fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] { // preserve them at the edges, capped at the top. A flat black/white image uses its edge peak. let interior = curves.iter().flat_map(|c| c.iter().skip(1).take(BINS - 2)).copied().fold(0.0f32, f32::max); let peak = if interior > 0.0 { interior } else { curves.iter().flatten().copied().fold(0.0f32, f32::max) }; - let scale = if peak > 0.0 { peak * 1.1 } else { 1.0 }; + let mut scale = if peak > 0.0 { peak * 1.1 } else { 1.0 }; + if spikes == Spikes::Clip { + // Camera Raw scales linearly with the 95th-percentile column at two thirds of the height + // and lets taller spikes run off the top (measured on Photoshop 25.4's Camera Raw 16.1.1: + // 49 px per 10 000 pixels in a 106 px plot, 95th percentile 14 546, full 21 600). + let mut columns: Vec = (1..BINS - 1).map(|i| curves.iter().map(|c| c[i]).fold(0.0f32, f32::max)).collect(); + columns.sort_unstable_by(f32::total_cmp); + let p95 = columns.get(columns.len() * 95 / 100).copied().unwrap_or(0.0); + if p95 > 0.0 { + scale = scale.min(p95 * 1.5); + } + } for value in curves.iter_mut().flatten() { *value = (*value / scale).min(1.0); } @@ -101,11 +112,24 @@ fn fill(rect: Rect, curves: &[[f32; BINS]; 3], t: &Tokens) -> Mesh { mesh } +/// How a histogram treats columns far above the rest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Spikes { + /// Fit the tallest interior column (edge columns excluded). + Fit, + /// Camera Raw's: scale by the typical columns, spikes run off the top. + Clip, +} + pub(crate) fn paint(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens) { + paint_with(painter, rect, h, t, Spikes::Fit); +} + +pub(crate) fn paint_with(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens, spikes: Spikes) { if !rect.is_finite() || !rect.is_positive() || !rect.size().is_finite() { return; } - let curves = curves(h); + let curves = curves(h, spikes); painter.add(fill(rect, &curves, t)); for (channel, values) in curves.iter().enumerate() { if !values.iter().any(|&v| v > 0.0) { @@ -139,7 +163,7 @@ pub(crate) fn paint_sample(painter: &egui::Painter, rect: Rect, rgb: [f32; 3], t #[cfg(test)] fn mesh(rect: Rect, h: &RgbHistogram, t: &Tokens) -> Mesh { - fill(rect, &curves(h), t) + fill(rect, &curves(h, Spikes::Fit), t) } #[cfg(test)] @@ -161,6 +185,28 @@ mod tests { }) } + #[test] + fn camera_raw_scale_lets_a_shadow_spike_run_off_the_top() { + // A dark-sky photo: a tall spike in the first columns over a hill in the midtones. + let mut h = RgbHistogram::default(); + for c in &mut h.channels { + for (i, v) in c.iter_mut().enumerate() { + let x = i as f32 / 255.0; + *v = (14_000.0 * (-((x - 0.5) / 0.18).powi(2)).exp()) as u64 + 3_000; + } + c[1] = 88_000; + c[2] = 42_000; + } + let fit = curves(&h, Spikes::Fit); + let clip = curves(&h, Spikes::Clip); + assert!(fit[1][128] < 0.25, "fitting the spike flattens the hill: {}", fit[1][128]); + assert!((0.5..0.75).contains(&clip[1][128]), "Camera Raw keeps the hill at about two thirds: {}", clip[1][128]); + assert_eq!(clip[1][1], 1.0, "the spike is clipped at the top"); + // Without spikes both scales agree. + let flat = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() }; + assert_eq!(curves(&flat, Spikes::Fit), curves(&flat, Spikes::Clip)); + } + #[test] fn shared_scale_and_overlaps_are_exact_for_every_theme() { let h = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() }; @@ -217,15 +263,15 @@ mod tests { let mut h = RgbHistogram { channels: [[10; BINS]; 3], ..Default::default() }; h.channels[0][0] = 1_000_000; h.channels[1][255] = 1_000_000; - let p = curves(&h); + let p = curves(&h, Spikes::Fit); assert_eq!(p[0][0], 1.0); assert_eq!(p[1][255], 1.0); assert!((p[0][1] - 1.0 / 1.1).abs() < 0.0001); assert!((p[1][254] - 1.0 / 1.1).abs() < 0.0001); let mut h = RgbHistogram::default(); h.channels[0][255] = 100; - assert!(curves(&h)[0][255] > 0.9); - assert_eq!(curves(&h)[0][254], 0.0); + assert!(curves(&h, Spikes::Fit)[0][255] > 0.9); + assert_eq!(curves(&h, Spikes::Fit)[0][254], 0.0); } #[test] @@ -247,7 +293,7 @@ mod tests { channel[i] = 100; } } - let p = curves(&h); + let p = curves(&h, Spikes::Fit); let low = p[0][40..210].iter().copied().fold(f32::INFINITY, f32::min); let high = p[0][40..210].iter().copied().fold(0.0f32, f32::max); assert!(high - low < 0.2, "quantization comb still dominates the contour: {}", high - low); From 70664978863424db8d166e29935fe2fab5b5da96 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:51:21 +0200 Subject: [PATCH 137/304] Keep fractional translation-only Free Transform on .atn import (#7401) Fixes #7298 Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 9 ++++++--- crates/engine/src/atn_cmds/tests.rs | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index e0767330ca..a32256920a 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1613,14 +1613,17 @@ fn transform_from(d: &Descriptor) -> Mapped { if d.get("Skew").is_some() || d.get("Usng").is_some() { return unsupported; } - let geometry_only = p.keys().all(|k| k == "dx" || k == "dy"); + // A whole-pixel move is a plain `layer.translate`; a fractional one needs `edit.transform` + // to keep its sub-pixel placement and interpolation. + let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); + let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; match (ref_class(r).as_deref(), target(r)) { (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), (Some("Lyr"), _) => { let Some(mut lp) = layer_target(r) else { return unsupported }; if geometry_only { - lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into()); - lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).into()); + lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).into()); return step("layer.translate", Value::Object(lp)); } lp.extend(p); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index b280a6d679..335a3cbead 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -649,7 +649,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { let moved = desc("null") .with("null", target_ref("Lyr ")) .with("FTcs", enm("QCSt", "Qcsa")) - .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0)))) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.0)).with("Vrtc", px(12.0)))) .with("Intr", enm("Intp", "Bcbc")); let scaled = desc("null") .with("null", target_ref("Lyr ")) @@ -1047,3 +1047,24 @@ fn close_with_document_target_is_not_exported_as_plain_close() { let imported = import(&bytes).unwrap(); assert_eq!(imported.actions[0].steps, vec![("file.close".to_string(), json!({}))]); } + +/// A translation-only Free Transform with a fractional offset must not collapse into an +/// integer `layer.translate`: the sub-pixel placement and interpolation have to survive. +#[test] +fn fractional_translation_only_transform_keeps_offset_and_interpolation() { + let params = json!({"dx": 0.5, "dy": 0, "interpolation": "bilinear"}); + let d = transform_to("edit.transform", ¶ms).unwrap(); + let file = AtnFile { + name: UnicodeString::new_nul("Frac"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("transform", "Transform", Some(d))], ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert!(imp.unsupported.is_empty()); + let steps = &imp.actions[0].steps; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "edit.transform"); + assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5)); + assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); +} From 18f8d33577f5648e03934c1c9ce421ea3d7c872f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 11:00:42 -0400 Subject: [PATCH 138/304] docs: add SonarCloud and CodeRabbit badges to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 12e2ef4b12..1e85aca5b0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha + Scanned by SonarCloud + Reviewed by CodeRabbit

From 5c7c8479234da520d374381c7573eb9e7b55a76b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 18 Oct 2026 10:41:35 -0600 Subject: [PATCH 139/304] perf(canvas): report how long the GPU spends on a composite (#7275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composite_ms` measures encoding and submitting a composite, not running it: on a 24 MP refresh it read 7.7 ms for work the GPU spent ~171 ms on, so nothing in the app could see a regression that moved work onto the GPU. A timestamp is written on the GPU timeline at the start and end of every command encoder a refresh submits, and the spans are summed and read back asynchronously. Nothing waits, so a measurement arrives a frame or two later and carries the pixel count of the refresh it belongs to. It is reported as a new `Option` `perf.gpu_ms`; `composite_ms` keeps its meaning, so its consumers are untouched. At 24 MP it reports 83.9 ms against a waited wall clock of 88.4 ms, where the app previously said 3.1 ms. What it leaves out, documented in the module: queue-level uploads, which wgpu runs in its own command buffer ahead of ours, and the end-of-batch writeback after the last timestamp. That is ~5 % of the reported time here and proportional to the composite, against the ~22x of the number it replaces. Unavailable rather than wrong: without TIMESTAMP_QUERY and TIMESTAMP_QUERY_INSIDE_ENCODERS the timer is never built and `gpu_ms` is None — on older drivers, the CPU compositor, wasm, and Apple silicon, whose GPUs support counter sampling only at stage boundaries. `PHOTOCRAFT_NO_GPU_TIMING` suppresses both the timer and the feature request. Pass-boundary timestamps would cover Apple silicon and need only TIMESTAMP_QUERY, but they capture 70-78 % of the encoder-level span here and the shortfall grows with document size, so they were measured and rejected. D3D12 is exercised: device creation, a 100 ns/tick period and the full write/resolve/map round-trip succeed with no validation errors, including across a flushed three-buffer refresh. Numbers throughout are from an integrated Intel GPU on Mesa/Vulkan and a software D3D12 adapter; no vendor Windows driver and no Apple hardware was tested. --- crates/gpu/src/lib.rs | 99 +++++++++- crates/gpu/src/timing.rs | 308 +++++++++++++++++++++++++++++++ crates/gpu/tests/timing.rs | 282 ++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 9 +- crates/ui-egui/src/gpu_canvas.rs | 116 +++++++++++- crates/ui-egui/src/lib.rs | 12 ++ 6 files changed, 817 insertions(+), 9 deletions(-) create mode 100644 crates/gpu/src/timing.rs create mode 100644 crates/gpu/tests/timing.rs diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 3d60adaf1e..e99dc53416 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -42,6 +42,7 @@ mod fx; pub mod health; pub mod plan; mod prefix; +pub mod timing; use std::collections::HashMap; use std::num::NonZeroU64; @@ -55,6 +56,7 @@ use photocraft_raster::{Surface, Tile}; pub use health::{DeviceHealth, Fault}; pub use plan::{Kernel, Plan, Role, Unsupported, plan}; +pub use timing::{GpuTimer, GpuTiming}; /// Accumulator format for intermediate buffers. Full float: discontinuous operations /// (Posterize, Threshold, Hard Mix, Dissolve) must land on the same side of their thresholds as @@ -107,6 +109,12 @@ pub struct ChunkOut<'a> { /// What a render did (for profiling and tests). #[derive(Clone, Copy, Debug, Default)] pub struct Stats { + /// How long the GPU spent executing a composite, once its timestamps came back. Because the + /// readback is asynchronous this belongs to an *earlier* render than the one returning it + /// (it carries that render's own pixel count, so the two are never confused), and it is + /// `None` unless [`Compositor::measure_gpu_time`] is on and a measurement has landed. + /// Always `None` from [`Compositor::encode`], which submits nothing of its own. + pub gpu_time: Option, pub passes: usize, pub chunks: usize, pub slots: u32, @@ -474,6 +482,9 @@ pub struct Compositor { cmyk: u64, /// The device's health (see [`Compositor::set_health`]); `None` = assumed healthy. health: Option, + /// Timestamp queries measuring how long the GPU spends on a refresh (see + /// [`Compositor::measure_gpu_time`]); `None` = not measured. + timer: Option, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -709,6 +720,7 @@ impl Compositor { effect_maps: map_error.is_none(), cmyk: 0, health: None, + timer: None, } .with_texture_limit(device.limits().max_texture_dimension_2d) } @@ -961,6 +973,41 @@ impl Compositor { self.health.as_ref().and_then(DeviceHealth::fault) } + /// Measure how long the GPU spends executing each [`Compositor::render`], with timestamp + /// queries read back asynchronously (nothing waits for the GPU). Returns whether the device + /// supports it: without [`timing::FEATURES`] there is no measurement at all, and callers + /// report no GPU time rather than the encode-and-submit cost `composite_ms` already is. + /// + /// A measurement arrives a frame or two after the refresh it belongs to, so it carries that + /// refresh's own pixel count; take it with [`Compositor::take_gpu_timing`]. + pub fn measure_gpu_time(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> bool { + // `PHOTOCRAFT_NO_GPU_TIMING` turns it off: a kill switch for a driver whose timestamp + // queries misbehave, and the control arm for measuring what the queries themselves + // cost — the device is identical either way, so only the instrumentation varies. + if std::env::var_os("PHOTOCRAFT_NO_GPU_TIMING").is_some() { + return false; + } + if self.timer.is_none() { + self.timer = timing::GpuTimer::new(device, queue); + } + self.timer.is_some() + } + + /// Whether [`Compositor::measure_gpu_time`] is on for this compositor. + pub fn measures_gpu_time(&self) -> bool { + self.timer.is_some() + } + + /// The most recent finished GPU measurement, leaving none behind (a stale number is never + /// reported twice as new). `None` while unmeasured, unsupported, or still in flight. + /// + /// [`Compositor::render`] already drains this into [`Stats::gpu_time`], so the app never + /// calls it; it is how a bench or a test collects a measurement without issuing another + /// render to carry it. + pub fn take_gpu_timing(&mut self) -> Option { + self.timer.as_mut().and_then(timing::GpuTimer::take) + } + /// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is /// still open; the chunk texture is reused afterwards, so the sink must record any copies /// or passes that read it into the given encoder. Submits the work before returning; a @@ -976,8 +1023,47 @@ impl Compositor { ) -> Result { let result = scoped_work(device, self.health.is_some(), || { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); - let stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true)?; + if self.timer.as_ref().is_some_and(timing::GpuTimer::busy) { + // The readback map completes on a poll. The app polls every frame, but a + // non-blocking poll here keeps the measurements coming whatever the caller does. + match &self.health { + Some(h) => h.poll(device), + None => { + let _ = device.poll(wgpu::PollType::Poll); + } + } + } + // Whatever came back since the last render travels out with this one's stats, so + // nothing has to reach back into the compositor for it afterwards. + let landed = self.timer.as_mut().and_then(timing::GpuTimer::take); + if let Some(t) = &mut self.timer { + let r = region.intersect(&doc.bounds()); + t.start(&mut encoder, r.width() as u64 * r.height() as u64); + } + let mut stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true); + if let Ok(s) = &mut stats { + s.gpu_time = landed; + } + if let Some(t) = &mut self.timer { + // A refresh that failed part way measures nothing: its spans describe work the + // caller is about to redo on the CPU. + match stats { + Ok(_) => t.finish(&mut encoder), + Err(_) => { + t.cancel(); + // The refresh is lost, but a measurement that came back with it is not: + // hand it to the next render rather than drop it on the fallback path. + if let Some(landed) = landed { + t.restore(landed); + } + } + } + } + let stats = stats?; queue.submit([encoder.finish()]); + if let Some(t) = &mut self.timer { + t.submitted(); + } Ok(stats) }); if result.is_err() { @@ -1129,7 +1215,7 @@ impl Compositor { stats.evicted += evicted; freed += bytes; if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes || self.recorded >= FLUSH_OPS) { - submit_batch(device, queue, encoder, self.health.as_ref(), &mut last_submit); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit); self.staged = 0; self.recorded = 0; work = 0; @@ -1404,7 +1490,7 @@ impl Compositor { if self.recorded >= FLUSH_OPS && let Some(last) = batch.as_deref_mut() { - submit_batch(device, queue, encoder, self.health.as_ref(), last); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), last); self.staged = 0; self.recorded = 0; stats.flushes += 1; @@ -2039,9 +2125,14 @@ fn submit_batch( queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, health: Option<&DeviceHealth>, + timer: Option<&mut timing::GpuTimer>, last: &mut Option, ) { - let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + let mut done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + if let Some(t) = timer { + // One refresh, several command buffers: the measurement spans all of them. + t.split(&mut done, encoder); + } let prev = last.replace(queue.submit([done.finish()])); #[cfg(not(target_arch = "wasm32"))] if let Some(prev) = prev { diff --git a/crates/gpu/src/timing.rs b/crates/gpu/src/timing.rs new file mode 100644 index 0000000000..e79548d84c --- /dev/null +++ b/crates/gpu/src/timing.rs @@ -0,0 +1,308 @@ +//! GPU execution time of a composite (#7186), measured with wgpu timestamp queries. +//! +//! `GpuCanvas::refresh` reports `composite_ms`, the CPU cost of *encoding and submitting* the +//! composite. That is not the cost of running it: at 24 MP the encode costs ~7.7 ms and the GPU +//! then spends ~171 ms finishing the work, so every consumer of `composite_ms` under-reports a +//! GPU-bound canvas by more than an order of magnitude and cannot see a regression that moves +//! work onto the GPU. +//! +//! Waiting for the GPU to report the true number would serialise the two processors and make the +//! app slower, so this measures instead of waiting: a timestamp is written on the GPU timeline at +//! the start and end of every command encoder a refresh submits, and the values are read back +//! asynchronously. The sum of the spans is the time the GPU spent executing that refresh, with +//! the CPU encoding gaps between submissions excluded. Nothing blocks; the numbers simply arrive +//! a frame or two later, so each measurement carries the pixel count of the refresh it belongs to +//! and is never attributed to whichever refresh happens to be in flight when it lands. +//! +//! **What it does not count.** The spans run between bottom-of-pipe timestamps inside the +//! refresh's own command buffers. Two kinds of GPU work fall outside them: queue-level uploads +//! (`Queue::write_texture`), which wgpu executes in its own command buffer before ours, and +//! whatever the GPU still does after the final timestamp retires — the end-of-batch writeback of +//! the render targets. Measured against a waited-for wall clock on Intel UHD P630 / Mesa, with +//! no uploads outstanding, the uncounted remainder is **~5 % of the reported time**, and it is +//! proportional to the composite rather than a fixed submit latency (1.0 ms at 6 MP, 4.5 ms at +//! 24 MP, 7.6 ms at 42.6 MP, against 21 / 83 / 158 ms reported). So the number is a slight +//! under-report of the GPU's total contribution, by a few per cent rather than the ~22x of the +//! encode-and-submit time it replaces. +//! +//! **What it costs.** Nothing measurable. The same refresh on the same device with +//! `PHOTOCRAFT_NO_GPU_TIMING` set waits 88.1 ms against 87.9 ms measured — a difference of +//! −0.2 ms on an ~88 ms refresh, i.e. inside the noise of a shared host. Per refresh the +//! queries add two timestamp writes (two more per flush), one resolve and one copy of at most +//! 1 KiB, a `map_async` and an `Arc` allocation; the query set and both buffers are allocated +//! once per compositor and are size-independent, so a resize reallocates nothing. +//! +//! The measurement is *optional by construction*. Without `TIMESTAMP_QUERY` and +//! `TIMESTAMP_QUERY_INSIDE_ENCODERS` on the device (older drivers, most browsers, the Noop +//! backend), [`GpuTimer::new`] returns `None` and callers report no GPU time at all rather than a +//! plausible wrong number — reporting a plausible wrong number is the failure this fixes. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +/// The GPU execution time of one composite, with the refresh it measured. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuTiming { + /// Time the GPU spent executing the refresh's command buffers (ms). Excludes queue-level + /// uploads, which run in wgpu's own command buffer ahead of ours, and the end-of-batch work + /// after the last timestamp: see the module doc for the size of that (~5 % here, more on a + /// refresh that uploads). + pub ms: f64, + /// Pixels that refresh composited (so a late measurement stays interpretable). + pub px: u64, + /// Command buffers the refresh submitted (1 unless it flushed). + pub spans: u32, +} + +/// Device features [`GpuTimer`] needs. +pub const FEATURES: wgpu::Features = wgpu::Features::TIMESTAMP_QUERY.union(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS); + +/// Spans one measurement may hold. A refresh submits once per flush; a 200 MP document flushes a +/// handful of times, so this is far above anything real. A measurement that would exceed it is +/// dropped rather than truncated (a truncated sum is exactly the under-report being fixed). +const SPANS: u32 = 64; +/// Any span total above this is a wrapped or garbage timestamp, not a composite. +const SANE_MS: f64 = 600_000.0; + +/// Readback state of the buffer holding the resolved timestamps. +const UNMAPPED: u8 = 0; +const MAPPED: u8 = 1; +const FAILED: u8 = 2; + +/// Timestamp queries for one compositor, reused for every refresh. +/// +/// At most one measurement is in flight: while the last one is still being read back a refresh +/// goes unmeasured, which costs a sample rather than correctness. +pub struct GpuTimer { + set: wgpu::QuerySet, + /// Resolved timestamps (`QUERY_RESOLVE | COPY_SRC`). + resolve: wgpu::Buffer, + /// Host-visible copy of `resolve` (`MAP_READ | COPY_DST`). + read: wgpu::Buffer, + /// Nanoseconds per timestamp tick, as the queue reports it. + period_ns: f32, + /// Timestamps written into the current recording. + written: u32, + /// Whether a span is open (a start timestamp waiting for its end). + open: bool, + /// Pixels of the recording in progress. + px: u64, + /// The recording overflowed [`SPANS`]: finish without resolving. + overflowed: bool, + /// The recording in progress has already had command buffers submitted (it flushed), so its + /// query slots may still be in use by the GPU. + flushed: bool, + /// A recording was abandoned after it had already submitted timestamps. Writing a query + /// again resets that slot (wgpu-core resets per write), and resetting one an in-flight + /// submission is still writing is undefined, with no way to learn when it has drained. + /// That only happens when a refresh fails after a flush, which on this path means the + /// device faulted and the canvas is about to drop to the CPU compositor anyway, so + /// measuring simply stops: no number, which is the contract everywhere else here. + stopped: bool, + /// Timestamps and pixels of the measurement being read back, and its readback flag. + in_flight: Option<(u32, u64, Arc)>, + /// [`Self::finish`] recorded a readback that [`Self::submitted`] has yet to map. Without + /// this, a refresh that starts before the last measurement lands would map the readback + /// buffer a second time while the first map is still pending, which wgpu rejects. + unmapped: bool, + /// The most recent completed measurement, until a caller takes it. + ready: Option, +} + +impl std::fmt::Debug for GpuTimer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GpuTimer").field("recording", &self.open).field("in_flight", &self.in_flight.is_some()).field("ready", &self.ready).finish() + } +} + +impl GpuTimer { + /// A timer for `device`, or `None` when it has no timestamp queries (the caller then reports + /// no GPU time, never an estimate). + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option { + if !device.features().contains(FEATURES) { + return None; + } + let period_ns = queue.get_timestamp_period(); + if !period_ns.is_finite() || period_ns <= 0.0 { + return None; + } + let bytes = u64::from(SPANS) * 2 * 8; + Some(Self { + set: device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("pc_compose_time"), ty: wgpu::QueryType::Timestamp, count: SPANS * 2 }), + resolve: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_resolve"), + size: bytes, + usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }), + read: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_read"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + period_ns, + written: 0, + open: false, + px: 0, + overflowed: false, + flushed: false, + stopped: false, + in_flight: None, + unmapped: false, + ready: None, + }) + } + + /// Start measuring a refresh of `px` pixels, opening its first span in `encoder`. Does + /// nothing while a previous measurement is still being read back (that refresh goes + /// unmeasured) — call [`Self::collect`] first to give it the chance to land. + pub fn start(&mut self, encoder: &mut wgpu::CommandEncoder, px: u64) { + if self.stopped || self.in_flight.is_some() || self.open { + return; + } + self.written = 0; + self.overflowed = false; + self.flushed = false; + self.px = px; + self.open = true; + self.stamp(encoder); + } + + /// Close the span in `done` and open the next in `next`: one refresh that submits several + /// command buffers is one measurement, summed over them. + pub fn split(&mut self, done: &mut wgpu::CommandEncoder, next: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(done); + self.stamp(next); + self.flushed = true; + } + + /// Close the last span and record the resolve and readback copy into `encoder`, which the + /// caller is about to submit. Call [`Self::submitted`] once it has. + pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(encoder); + self.open = false; + let count = self.written; + if self.overflowed || count < 2 || !count.is_multiple_of(2) { + self.abandon(); + return; + } + let bytes = u64::from(count) * 8; + encoder.resolve_query_set(&self.set, 0..count, &self.resolve, 0); + encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes); + self.in_flight = Some((count, self.px, Arc::new(AtomicU8::new(UNMAPPED)))); + self.unmapped = true; + self.written = 0; + // This recording is over: its slots are protected by `in_flight` until the readback + // lands, not by `flushed`. Leaving `flushed` set would let a later refresh that + // recorded nothing (because `start` found the readback still in flight) stop measuring + // for good when it failed, on the strength of this recording's flush. + self.flushed = false; + } + + /// Map the readback buffer after the caller submitted the encoder [`Self::finish`] wrote + /// into. The map completes on a later device poll; nothing blocks here. + pub fn submitted(&mut self) { + if !self.unmapped { + return; + } + self.unmapped = false; + let Some((count, _, flag)) = &self.in_flight else { return }; + let (bytes, flag) = (u64::from(*count) * 8, flag.clone()); + self.read.slice(0..bytes).map_async(wgpu::MapMode::Read, move |r| { + flag.store(if r.is_ok() { MAPPED } else { FAILED }, Ordering::Release); + }); + } + + /// Abandon the recording in progress (the refresh failed part way, so its spans mean + /// nothing). + pub fn cancel(&mut self) { + self.open = false; + self.abandon(); + } + + /// Drop the recording in progress. Once its timestamps have reached the GPU its slots can't + /// be reused safely, so measuring stops for good rather than resetting a live query. + fn abandon(&mut self) { + self.stopped |= self.flushed; + self.written = 0; + self.overflowed = false; + self.flushed = false; + } + + /// Whether a measurement is being read back (so this refresh won't be measured). + pub fn busy(&self) -> bool { + self.in_flight.is_some() + } + + /// Read back a finished measurement if the map completed, and return whatever is ready. + /// Cheap and non-blocking; callers poll it per refresh or per frame. + pub fn collect(&mut self) -> Option { + if let Some((count, px, flag)) = &self.in_flight { + match flag.load(Ordering::Acquire) { + MAPPED => { + let (count, px) = (*count, *px); + self.ready = self.value(count, px); + self.read.unmap(); + self.in_flight = None; + } + FAILED => { + self.in_flight = None; + } + _ => {} + } + } + self.ready + } + + /// Put a measurement back after a caller took it but could not use it (its refresh failed), + /// so the sample is reported by the next one instead of being lost on the fallback path. + /// A measurement that has landed since wins: it is the newer of the two. + pub fn restore(&mut self, t: GpuTiming) { + self.ready.get_or_insert(t); + } + + /// Take the most recent completed measurement, leaving none behind, so a stale number is + /// never reported twice as if it were new. + pub fn take(&mut self) -> Option { + self.collect(); + self.ready.take() + } + + /// Sum the resolved spans of a mapped readback. + fn value(&self, count: u32, px: u64) -> Option { + let bytes = u64::from(count) * 8; + let view = self.read.slice(0..bytes).get_mapped_range().ok()?; + let mut ticks = 0u64; + for pair in view.as_chunks::<16>().0 { + let (a, b) = pair.split_at(8); + let start = u64::from_le_bytes(a.try_into().ok()?); + let end = u64::from_le_bytes(b.try_into().ok()?); + // A driver that reset or wrapped its timer gives an end before its start: the whole + // measurement is then unusable, and no number is better than a wrong one. + if end < start { + return None; + } + ticks += end - start; + } + drop(view); + let ms = ticks as f64 * f64::from(self.period_ns) / 1.0e6; + (ms.is_finite() && ms < SANE_MS).then_some(GpuTiming { ms, px, spans: count / 2 }) + } + + /// Write one timestamp, noting an overflow instead of wrapping round the query set. + fn stamp(&mut self, encoder: &mut wgpu::CommandEncoder) { + if self.written >= SPANS * 2 { + self.overflowed = true; + return; + } + encoder.write_timestamp(&self.set, self.written); + self.written += 1; + } +} diff --git a/crates/gpu/tests/timing.rs b/crates/gpu/tests/timing.rs new file mode 100644 index 0000000000..24c2057253 --- /dev/null +++ b/crates/gpu/tests/timing.rs @@ -0,0 +1,282 @@ +//! The canvas timing must measure GPU *execution*, not encode-and-submit (#7186). +//! +//! `composite_ms` reports the CPU cost of handing the composite to the driver, which on a +//! GPU-bound refresh is a small fraction of what the refresh costs — at 24 MP it read 7.7 ms for +//! work that took ~171 ms. `Compositor::measure_gpu_time` adds timestamp queries whose spans are +//! summed into a [`GpuTiming`]. This test pins that down from both sides: the measurement must +//! track the waited-for wall clock and must be several times the submit time, so an accidental +//! return to timing the submit fails it. +//! +//! **Hardware only.** A software rasteriser composites on the CPU, where submit and execution +//! are the same work, so the whole premise disappears and the test would pass on a bug. It +//! therefore refuses to run on a CPU adapter and says so, as every GPU timing measurement in +//! this project must (egui_kittest's `default_wgpu_setup` prefers CPU adapters on purpose). + +use std::time::Instant; + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_geom::Rect; +use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing}; + +/// Pixels in `r`, as the compositor counts them. +fn area(r: Rect) -> u64 { + r.width() as u64 * r.height() as u64 +} + +fn block_on(f: F) -> F::Output { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut f = std::pin::pin!(f); + loop { + if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + std::thread::yield_now(); + } +} + +/// A real GPU with timestamp queries, or `None` with the reason printed. +fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> { + let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }; + let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&options)) else { + eprintln!("SKIP gpu timing: no adapter"); + return None; + }; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu || info.backend == wgpu::Backend::Noop { + // Not a skip we can be quiet about: a GPU timing test on llvmpipe measures nothing. + eprintln!("SKIP gpu timing: {} is a software adapter, which cannot show a GPU/CPU split", info.name); + return None; + } + if !adapter.features().contains(timing::FEATURES) { + eprintln!("SKIP gpu timing: {} has no timestamp queries", info.name); + return None; + } + let descriptor = wgpu::DeviceDescriptor { required_features: timing::FEATURES, ..Default::default() }; + let Ok((device, queue)) = block_on(adapter.request_device(&descriptor)) else { + eprintln!("SKIP gpu timing: no device with timestamp queries"); + return None; + }; + let mut comp = Compositor::new(&device); + assert!(comp.measure_gpu_time(&device, &queue), "the device has the features, so the timer must build"); + eprintln!("gpu timing on {} ({:?}, {:?})", info.name, info.device_type, info.backend); + Some((device, queue, comp)) +} + +fn rnd(seed: u32, i: u32) -> f32 { + let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + h ^= h >> 16; + (h & 0xffff) as f32 / 65535.0 +} + +/// A document whose composite is clearly GPU-bound: several full-canvas blended photo layers. +fn document(size: Size) -> Document { + let mut doc = Document::with_background("timing", size, ColorMode::Rgb, SampleType::U8, Color::rgba(0.5, 0.5, 0.5, 1.0)); + let fmt = doc.pixel_format(); + let rect = Rect::from_xywh(0, 0, size.width, size.height); + for (i, blend) in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Overlay, BlendMode::SoftLight].into_iter().enumerate() { + let mut layer = Layer::raster(format!("l{i}"), fmt); + let channels = fmt.channels(); + let px: Vec = (0..rect.width() * rect.height()).flat_map(|p| (0..channels).map(move |c| rnd(7 + i as u32 * 131 + c as u32 * 7919, p))).collect(); + if let Some(s) = layer.surface_mut() { + s.write_region(rect, &px); + } + layer.blend = blend; + layer.opacity = 0.8; + doc.layers.push(layer); + } + doc +} + +/// Render `region` and return (submit ms, waited-for wall ms, the measurement). +fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option) { + let t0 = Instant::now(); + comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU"); + let submit_ms = t0.elapsed().as_secs_f64() * 1000.0; + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + // The readback map completes on a poll, and its callback runs on the next one. + let mut timing = None; + for _ in 0..4 { + timing = comp.take_gpu_timing(); + if timing.is_some() { + break; + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + } + (submit_ms, wall_ms, timing) +} + +#[test] +fn gpu_time_tracks_execution_not_submission() { + let Some((device, queue, mut comp)) = gpu() else { return }; + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + // Warm the residency and pipelines: the first refresh uploads every layer. + let _ = measure(&device, &queue, &mut comp, &doc, full); + + // Best of three, by the smallest encode. Everything below is a ratio against `submit_ms`, + // and CPU contention on a shared machine only ever inflates it — one run that happened to + // encode behind a busy scheduler (8.2 ms against a typical 1.0 ms) failed the comparison + // while the measurement itself was right. The GPU side is unaffected by that contention, + // so taking the least-disturbed encode tests the same property without the false failure. + let mut best: Option<(f64, f64, Option)> = None; + for _ in 0..3 { + let run = measure(&device, &queue, &mut comp, &doc, full); + if run.2.is_some() && best.as_ref().is_none_or(|b| run.0 < b.0) { + best = Some(run); + } + } + let (submit_ms, wall_ms, timing) = best.expect("at least one of three refreshes reports a GPU time"); + let t = timing.expect("a hardware device with timestamp queries must report a GPU time"); + eprintln!("full {}x{}: submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, gpu {:.1} ms", size.width, size.height, t.ms); + + assert_eq!(t.px, area(full), "the measurement carries the pixels of the refresh it measured"); + assert!(t.spans >= 1, "at least one command buffer was timed"); + + // The premise: on this adapter the refresh really is dominated by GPU execution. A machine + // fast enough that it isn't cannot tell the two timings apart, so say so instead of failing. + if wall_ms < 3.0 * submit_ms { + eprintln!("SKIP the comparison: this GPU finishes a 6 MP composite in {wall_ms:.1} ms against {submit_ms:.1} ms of encoding"); + return; + } + + // Timing the submit again (the bug) would report roughly `submit_ms` here. + assert!(t.ms > 3.0 * submit_ms, "gpu time {:.1} ms looks like the submit time {submit_ms:.1} ms, not the execution", t.ms); + // …and it must be the real execution, not merely a bigger number: the waited-for wall clock + // is encode + execute, so the GPU span sits below it and accounts for most of it. + assert!(t.ms <= wall_ms * 1.25, "gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + assert!(t.ms >= (wall_ms - submit_ms) * 0.5, "gpu time {:.1} ms is far below the {:.1} ms the refresh waited", t.ms, wall_ms - submit_ms); + + // A quarter of the canvas is a quarter of the per-pixel work: a constant or a stale value + // would not follow it. + let quarter = Rect::from_xywh(0, 0, size.width / 2, size.height / 2); + let (_, _, small) = measure(&device, &queue, &mut comp, &doc, quarter); + let s = small.expect("the smaller refresh is measured too"); + eprintln!("quarter: gpu {:.1} ms for {} px against {:.1} ms for {} px", s.ms, s.px, t.ms, t.px); + assert_eq!(s.px, area(quarter)); + assert!(s.ms < t.ms * 0.6, "a quarter-canvas refresh took {:.1} ms against the full canvas's {:.1} ms", s.ms, t.ms); +} + +#[test] +fn an_unmeasured_compositor_reports_nothing() { + let Some((device, queue, _)) = gpu() else { return }; + let mut comp = Compositor::new(&device); + assert!(!comp.measures_gpu_time(), "timing is off until it is asked for"); + let doc = document(Size::new(256, 256)); + comp.render(&device, &queue, &doc, Rect::from_xywh(0, 0, 256, 256), |_, _| {}).expect("composites"); + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.take_gpu_timing(), None, "no measurement is reported rather than a wrong one"); +} + +#[test] +fn a_refresh_that_submits_several_times_is_one_measurement() { + let Some((device, queue, mut comp)) = gpu() else { return }; + // A budget far below the document's layer pages makes the refresh evict as it goes, which is + // what makes it submit in batches: the measurement must span all of them, not just the last. + comp.set_memory_budget(24 << 20); + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + let _ = measure(&device, &queue, &mut comp, &doc, full); + let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full); + let t = timing.expect("a batched refresh is measured like any other"); + eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms); + assert_eq!(t.px, area(full)); + if t.spans < 2 { + eprintln!("SKIP the sum: this refresh still fit one command buffer"); + return; + } + assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + // What this bound excludes is summing only the last command buffer. A correct sum measures + // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so + // no single buffer of the several carries much over half of it, and timing only the last + // would land near 40 %. 0.6 sits between the two with room on both sides for a machine + // whose split is less even than this one's. + assert!( + t.ms >= (wall_ms - submit_ms) * 0.6, + "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?", + t.ms, + wall_ms - submit_ms + ); +} + +#[test] +fn refreshes_faster_than_the_readback_stay_valid() { + let Some((device, queue, mut comp)) = gpu() else { return }; + // Refreshes arriving before the last measurement has been read back must simply go + // unmeasured. Mapping the readback buffer again while its first map is still pending is a + // validation error, and an unusable device drops the whole canvas to the CPU compositor. + let health = DeviceHealth::watch(&device); + comp.set_health(health.clone()); + let size = Size::new(600, 400); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + for i in 0..16 { + comp.render(&device, &queue, &doc, full, |_, _| {}).unwrap_or_else(|e| panic!("refresh {i} failed: {e}")); + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.fault(), None, "hurried refreshes must not fault the device"); + assert_eq!(health.errors(), 0, "no uncaptured wgpu errors"); +} + +/// A refresh that recorded nothing must not stop the timer for good. `finish` leaves the +/// measurement's slots protected by the pending readback, not by `flushed`; if `flushed` +/// survived a successful finish, the next refresh to fail while that readback was still in +/// flight would set `stopped` on the strength of the *earlier* refresh's flush — even though it +/// wrote no timestamps itself — and the compositor would silently never measure again. +#[test] +fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() { + let Some((device, queue, _)) = gpu() else { return }; + let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries"); + + // A refresh that flushes once, then succeeds. + let mut first = device.create_command_encoder(&Default::default()); + let mut second = device.create_command_encoder(&Default::default()); + timer.start(&mut first, 1024); + timer.split(&mut first, &mut second); + queue.submit([first.finish()]); + timer.finish(&mut second); + queue.submit([second.finish()]); + timer.submitted(); + + // A later refresh fails before recording anything, because the readback is still in flight. + let mut third = device.create_command_encoder(&Default::default()); + timer.start(&mut third, 2048); + timer.cancel(); + drop(third); + + // The first measurement still lands... + let mut first_timing = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + first_timing = timer.take(); + if first_timing.is_some() { + break; + } + } + let landed = first_timing.expect("the flushed measurement completes"); + assert_eq!(landed.spans, 2, "both command buffers are one measurement"); + + // ...and the timer still measures afterwards, which is what the bug destroyed. + let mut next = device.create_command_encoder(&Default::default()); + timer.start(&mut next, 4096); + timer.finish(&mut next); + queue.submit([next.finish()]); + timer.submitted(); + let mut after = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + after = timer.take(); + if after.is_some() { + break; + } + } + let after = after.expect("the timer still measures after a refresh that recorded nothing"); + assert_eq!(after.px, 4096, "the measurement belongs to the refresh that produced it"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d8d943c81f..a5f1452dd1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1648,11 +1648,11 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: // An adjustment preview composited where the view was: catch up where it moved to. if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, visible) { let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); } if let Some(r) = crate::adjust_preview::live_uncovered(app, id, raw_key, visible) { let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); } return true; } @@ -1693,7 +1693,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: log::info!("{e}; using the CPU compositor"); } if r.kind != crate::gpu_canvas::REFRESH_DEFERRED { - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); } if r.kind == "full" { crate::notices::slow_cpu_refresh(app, id.0, r.composite_ms + r.upload_ms, r.fallback.as_deref()); @@ -2015,6 +2015,9 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option let (display, _) = canvas_display(app, &doc, None); let r = gpu.refresh(key, &frame.doc, frame.damage, display.as_deref()); app.perf.record(if r.kind.starts_with("gpu") { "gpu-adjust-proxy" } else { "adjust-proxy" }, r.px, r.composite_ms, r.upload_ms); + if let Some(t) = r.gpu { + app.perf.record_gpu(t); + } crate::adjust_preview::proxy_drawn(app, &frame); } Some((frame.k, key, size)) diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 1770979db1..65388d5049 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -295,6 +295,15 @@ impl GpuCanvas { } } + /// Take any GPU measurement that has landed since the last call, without compositing + /// anything. [`Compositor::render`] carries measurements out in its stats, but a refresh is + /// the only thing that collects them, so the last refresh before the app goes idle would + /// never be reported. The frame loop calls this so it is. + pub fn take_gpu_timing(&self) -> Option { + let mut r = self.rs.renderer.write(); + r.callback_resources.get_mut::()?.compositor.as_mut()?.take_gpu_timing() + } + /// GPU bytes the wgpu compositor holds: resident layer pages and caches (effect maps and /// lower-stack chunks). Keep the scorecard's memory measurements inclusive of reuse. pub fn compositor_bytes(&self) -> Option<(u64, usize)> { @@ -472,6 +481,8 @@ impl GpuCanvas { }, }; comp.set_health(self.health.clone()); + // Measured, not waited for: the timestamps come back a frame or two later. + comp.measure_gpu_time(device, queue); if let Some(b) = res.compositor_budget { comp.set_memory_budget(b); } @@ -636,8 +647,10 @@ impl GpuCanvas { Ok(stats) => { out.kind = if damage.is_some() { "gpu-rect" } else { "gpu-full" }; out.px = region.width() as u64 * region.height() as u64; + // Encoding and submitting only: the GPU is still running this work. out.composite_ms = now_ms() - t0; out.uploads = stats.tiles_uploaded as u64; + out.gpu = stats.gpu_time; return out; } Err(e) => out.fallback = Some(e.0), @@ -876,6 +889,14 @@ pub struct Refresh { pub upload_ms: f64, /// Layer tiles the wgpu compositor uploaded. pub uploads: u64, + /// How long the GPU spent executing a recent wgpu composite, once its timestamps came back + /// (a frame or two after that refresh, so usually an earlier one than this). It carries its + /// own pixel count and is never this refresh's unless they match. `None` when the device has + /// no timestamp queries, on the CPU compositor, and on wasm: no number at all is reported + /// rather than `composite_ms`, which measures encoding and submitting, not executing. It + /// leaves out layer-tile uploads (`uploads` above counts them), so a first refresh of a + /// document under-reports by more than the few per cent of a warm one. + pub gpu: Option, /// Why the wgpu compositor wasn't used. pub fallback: Option, } @@ -903,11 +924,42 @@ pub fn use_adapter_limits_with(setup: &mut egui_wgpu::WgpuSetup, on_adapter: imp if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { create.device_descriptor = std::sync::Arc::new(move |adapter| { on_adapter(adapter); - wgpu::DeviceDescriptor { label: Some("photocraft wgpu device"), required_limits: device_limits(adapter), ..Default::default() } + device_descriptor(adapter) }); } } +/// How the app asks for its device: egui's defaults, the adapter's texture and buffer size +/// limits ([`device_limits`]), and timestamp queries where the adapter has them, so the canvas +/// can report how long the GPU actually spends compositing ([`photocraft_gpu::GpuTimer`]). +/// A bench or test that builds its own device should use this rather than [`device_limits`] +/// alone, or it measures a device the app never creates. (`bevel_bench`, `fx_bench` and +/// `interactive_bench` still call [`device_limits`] directly and so report no GPU time.) +pub fn device_descriptor(adapter: &wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> { + wgpu::DeviceDescriptor { + label: Some("photocraft wgpu device"), + required_limits: device_limits(adapter), + required_features: timing_features(adapter), + ..Default::default() + } +} + +/// The timestamp-query features when the adapter supports all of them, else none: asking for a +/// feature an adapter lacks fails device creation, and a canvas without GPU timing is far better +/// than no canvas. +/// +/// `PHOTOCRAFT_NO_GPU_TIMING` suppresses the request as well as the timer +/// ([`photocraft_gpu::Compositor::measure_gpu_time`]), so the variable also rescues a driver +/// that advertises the features and then fails or misbehaves on device creation — not only one +/// whose timestamps come back wrong. +pub fn timing_features(adapter: &wgpu::Adapter) -> wgpu::Features { + let want = photocraft_gpu::timing::FEATURES; + if std::env::var_os("PHOTOCRAFT_NO_GPU_TIMING").is_some() { + return wgpu::Features::empty(); + } + if adapter.features().contains(want) { want } else { wgpu::Features::empty() } +} + /// egui's base limits (WebGL2's on GL), clamped to adapter support, with its maximum texture and buffer sizes. pub fn device_limits(adapter: &wgpu::Adapter) -> wgpu::Limits { device_limits_for(adapter.get_info().backend, adapter.limits()) @@ -938,7 +990,9 @@ pub struct Perf { pub ui_ms: f64, /// Worst UI frame of the last ~second (ms). pub ui_max_ms: f64, - /// Last canvas refresh: CPU composite (ms). + /// Last canvas refresh, CPU side (ms): the CPU composite on the CPU path, and on the GPU + /// path the cost of *encoding and submitting* the composite — not of running it, which is + /// `gpu_ms`. pub composite_ms: f64, /// Last canvas refresh: conversion + upload (ms). pub upload_ms: f64, @@ -950,6 +1004,16 @@ pub struct Perf { pub gpu: bool, /// Layer tiles uploaded by the last GPU composite. pub gpu_uploads: u64, + /// How long the GPU spent executing a recent composite (ms), from timestamp queries read + /// back after the fact. `None` on the CPU compositor, on a device without timestamp queries + /// and on wasm — there is then no honest number to report, and `composite_ms` is not one. + /// Excludes layer-tile uploads and end-of-batch work (see `photocraft_gpu::timing`), so it + /// is a few per cent under the GPU's full share of a warm refresh and further under a cold + /// one. + pub gpu_ms: Option, + /// Pixels of the refresh `gpu_ms` measured, which is a frame or two behind + /// `last_refresh_px` and may be a different size. + pub gpu_ms_px: u64, /// Why the last refresh fell back to the CPU compositor (None = GPU composited). pub gpu_fallback: Option, /// Adapter, backend, driver and fallback state (Help › System Info). @@ -1102,10 +1166,28 @@ impl Perf { self.upload_ms = upload_ms; if !kind.starts_with("gpu") { self.gpu_uploads = 0; + // The CPU compositor did this one: there is no GPU time, and the last GPU + // refresh's would be read as this refresh's. + self.gpu_ms = None; + self.gpu_ms_px = 0; } else { self.gpu_fallback = None; } } + + /// Record a canvas refresh and any GPU measurement that came back with it. + pub fn record_refresh(&mut self, r: &Refresh) { + self.record(r.kind, r.px, r.composite_ms, r.upload_ms); + if let Some(t) = r.gpu { + self.record_gpu(t); + } + } + + /// Record a GPU composite measurement that came back (see [`photocraft_gpu::GpuTiming`]). + pub fn record_gpu(&mut self, t: photocraft_gpu::GpuTiming) { + self.gpu_ms = Some(t.ms); + self.gpu_ms_px = t.px; + } pub fn span(&mut self, name: &'static str, ms: f64) { self.spans.insert(name, (ms * 100.0).round() / 100.0); } @@ -2380,6 +2462,36 @@ mod mip_tests; mod tests { use super::*; + /// A GPU measurement arrives a frame or two after its refresh, so most GPU refreshes carry + /// none; the last one reported must stand until a newer one lands. A CPU refresh is + /// different: there is no GPU time at all then, and keeping the old number would show it as + /// this refresh's. Runs anywhere — the GPU timing tests need hardware and CI has none. + #[test] + fn a_gpu_measurement_stands_until_replaced_and_a_cpu_refresh_clears_it() { + let timing = |ms, px| photocraft_gpu::GpuTiming { ms, px, spans: 1 }; + let refresh = |kind, gpu| Refresh { kind, gpu, ..Default::default() }; + let mut perf = Perf::default(); + assert_eq!(perf.gpu_ms, None, "nothing is reported before a measurement"); + + perf.record_refresh(&refresh("gpu-full", Some(timing(83.9, 24_000_000)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(83.9), 24_000_000)); + + // The common case: a GPU refresh whose own measurement has not come back yet. + perf.record_refresh(&refresh("gpu-rect", None)); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(83.9), 24_000_000), "the last measurement stands"); + + perf.record_refresh(&refresh("gpu-rect", Some(timing(11.2, 1_098_640)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(11.2), 1_098_640), "a newer measurement replaces it"); + + // The CPU compositor did this one: the GPU number belongs to no refresh on screen. + perf.record_refresh(&refresh("full", None)); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (None, 0), "a CPU refresh clears it rather than showing a stale one"); + + // And it comes back on the next GPU refresh that carries one. + perf.record_refresh(&refresh("gpu-full", Some(timing(42.0, 6_000_000)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(42.0), 6_000_000)); + } + #[test] fn device_limits_support_v3d_shader_variable_limit() { let supported = wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..wgpu::Limits::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f97c6968fc..2c23a29e36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2119,6 +2119,18 @@ impl eframe::App for PhotocraftApp { ctx.set_cursor_icon(egui::CursorIcon::Wait); } let ui_ms = gpu_canvas::now_ms() - t0; + // A GPU measurement lands a frame or two after the refresh it belongs to. Refreshes + // carry them out, but the last refresh before the app settles has none following it, so + // drain the timer here too or its number is never reported. Drain it either way: a + // measurement left sitting would surface later against an unrelated refresh. Report it + // only while the canvas is still on the GPU, since a CPU refresh clears `gpu_ms` + // precisely so a GPU number is never shown as that refresh's. + if let Some(gpu) = &self.gpu + && let Some(t) = gpu.take_gpu_timing() + && self.perf.last_refresh.starts_with("gpu") + { + self.perf.record_gpu(t); + } self.perf.frame(ui_ms); if self.stroke_trace.enabled() { self.stroke_trace.canvas(self.perf.frame_refresh_ms); From fa99e183b6a8ded4b23e3389bed2e5e5d0e7c4b1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 18 Oct 2026 17:04:55 +0000 Subject: [PATCH 140/304] Color panel field fills its group in a custom workspace too #2455 stretched the pro Color panel's SV field and hue strip to the dock group's height, but only the legacy dock told the field that height. The shared docking renderer from #3097 drew the panel without it, so once the layout was customized the field fell back to 120 pt. Both docks now offer the group body's height through one helper. --- crates/ui-egui/src/dock.rs | 5 +---- crates/ui-egui/src/panel_docking.rs | 1 + crates/ui-egui/src/panel_docking_tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/panels.rs | 11 ++++++++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index deaea4e783..0412c4fc67 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -521,10 +521,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod ui.set_min_height(inner); body(app, ui, g, tab); } else { - // The pro Color field stretches to the group's height instead of a fixed 120 pt. - if t.pro && g == Group::Color && tab == 0 { - ui.data_mut(|d| d.insert_temp(crate::panels::color_field_fill_id(), inner)); - } + crate::panels::offer_color_field_fill(ui, t.pro, g, tab); // No scroll fade: it darkens real colours, swatches and previews at the edge // (#2726); the scroll bar already shows there is more. ui.spacing_mut().scroll.fade.strength = 0.0; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a62ecafff3..f68c3e46e7 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -547,6 +547,7 @@ fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: &str) { if group.scrolls_itself(tab) { crate::panels::dock_body(app, ui, *group, tab); } else { + crate::panels::offer_color_field_fill(ui, pro, *group, tab); egui::ScrollArea::vertical() .id_salt(("shared-panel-body", id)) .auto_shrink([false, false]) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index a4ae378eda..193ff2e6b8 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -585,6 +585,23 @@ fn explicit_reveal_activates_existing_tab_and_expands_custom_group() { assert_eq!(app.ui.docking, before); } +// #2455 in a custom workspace: the pro Color field fills a tall floating group, not 120 pt. +#[test] +fn pro_color_field_fills_a_tall_custom_group() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"color","x":40,"y":60,"width":320,"height":560})).unwrap(); + assert!(app.ui.docking.is_some()); + let h = harness(app, vec2(1280.0, 800.0)); + // The SV field and the 14 pt hue strip beside it are draggable, equally tall and 8 pt apart. + let drags: Vec = + h.ctx.viewport(|v| v.this_pass.widgets.layers().flat_map(|(_, w)| w).filter(|w| w.sense.senses_drag()).map(|w| w.rect).collect()); + let field = drags + .iter() + .find(|f| drags.iter().any(|s| s.width() == 14.0 && s.top() == f.top() && s.height() == f.height() && (s.left() - f.right() - 8.0).abs() < 0.5)) + .expect("Color field with its hue strip"); + assert!(field.height() > 300.0, "field stays short in a 560 pt group: {field:?}"); +} + // #4028: in a custom workspace too, a new adjustment layer brings Properties to the front. #[test] fn a_new_adjustment_layer_activates_properties_in_a_custom_workspace() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 10476013ee..f2961fd7b6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3794,10 +3794,19 @@ fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) { } /// Where the dock tells the Color field how tall its group body is. -pub(crate) fn color_field_fill_id() -> egui::Id { +fn color_field_fill_id() -> egui::Id { egui::Id::new("color-field-fill-height") } +/// The pro Color field stretches to its dock group's height instead of a fixed 120 pt: a dock +/// calls this with the group body's `ui` before drawing the tab. +pub(crate) fn offer_color_field_fill(ui: &egui::Ui, pro: bool, group: crate::dock::Group, tab: usize) { + if pro && group == crate::dock::Group::Color && tab == 0 { + let inner = ui.available_height().max(0.0); + ui.data_mut(|d| d.insert_temp(color_field_fill_id(), inner)); + } +} + /// Height of the Color field: the group body's height (when docked) less the hex/RGB readout, /// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height. fn color_field_height(avail: Option, width: f32) -> f32 { From 9185b64b94c07458038e768f4b31778590ba93ee Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:31:45 -0400 Subject: [PATCH 141/304] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..2c4b8cb1a4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" From 050c8ebed8530010fd475bdf9a8f99eb47b32c73 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:47:45 -0400 Subject: [PATCH 142/304] docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1e85aca5b0..7554fcd8f2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha +
Scanned by SonarCloud Reviewed by CodeRabbit

From b3bee7496e847a36532ade7dfe0231873dd6adb7 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:04:16 -0400 Subject: [PATCH 143/304] Batch Same format: an input without an extension is saved as PNG same_format took everything after the last dot of the whole path, so a file name without an extension (or a dot in a folder name) gave a bogus output extension and the documented PNG fallback never ran. It now uses file_cmds::extension on the file name. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index cee41b9345..eb3f80016f 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1095,13 +1095,26 @@ pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, sa /// The extension a batch output takes with Format "Same": the input's own, except for formats /// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on -/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. +/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. An input +/// whose file name has no extension (a dot in a folder name doesn't count) is saved as PNG. fn same_format(path: &str) -> String { const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub"]; - let ext = path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase(); + let ext = extension(path).unwrap_or_else(|| "png".into()); if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext } } +#[cfg(test)] +#[test] +fn same_format_takes_the_file_names_extension() { + assert_eq!(same_format("/in/a.PNG"), "png"); + assert_eq!(same_format("/in/IMG_0001.ARW"), "psd"); + assert_eq!(same_format("/in/a.kra"), "psd"); + // No extension in the file name: PNG, not the path after a dot in a folder name. + assert_eq!(same_format("/in/v1.2/scan"), "png"); + assert_eq!(same_format("C:\\in.d\\scan"), "png"); + assert_eq!(same_format("/in/.hidden"), "png"); +} + fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { let mut files = Vec::new(); let mut errors = Vec::new(); From 5418399ddaa3dd251aac21e59ed3cd962d2f5623 Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Sun, 18 Oct 2026 22:24:54 +0300 Subject: [PATCH 144/304] perf(compose): blend pixel layers row by row, Normal in 8-pixel vector blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pixel layer with nothing clipped to it is read from its tiles a row at a time and blended straight onto the backdrop, instead of being copied into a tile-sized buffer first (a megabyte per layer per 256² render tile). Normal RGB rows go through a branch-free kernel over blocks of 8 pixels that the compiler vectorizes; few-band renders also run their bands in parallel. Bit for bit the same: the row kernel is tested against the scalar loop (NaN, infinities, subnormals, signed zeros), the row path against the tile copy at 8/16/32-bit with every kind of mode, fill and mask, and the whole GPU parity suite's CPU references (1620 renders) are unchanged. P60 on the CPU compositor: 627 -> 530 ms (the render without effects 300 -> 199 ms). --- crates/compose/src/lib.rs | 95 ++++++++++++++++++++++++--------- crates/compose/src/psblend.rs | 57 ++++++++++++++++++++ crates/compose/src/tests.rs | 99 +++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 25 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3cf2f1dc19..5d5a733e2b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -193,14 +193,15 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer } }); } else { - // Few, wide bands: the tiles of each band in parallel. - for (i, band) in out.px.chunks_mut(band_len).enumerate() { + // Few, wide bands: the tiles of each band in parallel, and the bands too, so a band's + // slowest tile doesn't leave the other cores idle until the next band starts. + out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| { let y0 = rect.y0 + i as i32 * tile; let parts: Vec = band_tiles(y0).into_par_iter().map(run).collect(); for part in &parts { put(band, y0, part); } - } + }); } } #[cfg(target_arch = "wasm32")] @@ -1350,6 +1351,38 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L } } +/// The surface `render_content` reads `layer`'s pixels from, when that is all it draws. +fn pixel_surface(layer: &Layer) -> Option<&Surface> { + if matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) { + return None; + } + layer.surface() +} + +/// A pixel layer (nothing clipped to it) onto `backdrop`, read from its tiles a row at a time: +/// the same arithmetic as `render_content` + `blend_into_fill`, without a tile-sized copy of the +/// layer (a megabyte per 256² render tile). +fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mut Buffer, cx: &Ctx) { + let rect = backdrop.rect; + let w = rect.width() as usize; + if w == 0 { + return; + } + let mv = mask_vals(layer, rect, cx); + let gamma = text_gamma(layer); + let mut row = vec![[0.0f32; 4]; w]; + for (ry, dst) in backdrop.px.chunks_exact_mut(w).enumerate() { + let y = rect.y0 + ry as i32; + surf.read_rgba_into(Rect::new(rect.x0, y, rect.x1, y + 1), &mut row); + if let Some(m) = mv.as_ref().and_then(|m| m.get(ry * w..(ry + 1) * w)) { + for (p, k) in row.iter_mut().zip(m) { + p[3] *= k; + } + } + blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma); + } +} + fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { let rect = backdrop.rect; // A TSL-off stroke base that is transparent here can still have visible clipped siblings; @@ -1468,6 +1501,11 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0); return; } + if clipped.is_empty() + && let Some(surf) = pixel_surface(layer) + { + return composite_pixel_layer(layer, surf, backdrop, cx); + } let Some(mut content) = render_content(layer, rect, cx) else { return }; advanced::composite_clipped(clipped, &mut content, cx); blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); @@ -1853,44 +1891,51 @@ fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32 /// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for /// Photoshop's special eight ([`psblend::composite_fill`]), plain coverage otherwise. fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { - if fill >= 1.0 || !psblend::fill_is_special(mode) { - return blend_into_g(backdrop, src, mode, opacity * fill, gamma); + for_rows(backdrop, src, |dst, s, x0, y| blend_span_fill(dst, s, x0, y, mode, opacity, fill, gamma)); +} + +/// [`blend_into`] mixing coverage in a `gamma` space (type layers). +fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { + for_rows(backdrop, src, |dst, s, x0, y| blend_span_g(dst, s, x0, y, mode, opacity, gamma)); +} + +/// Runs `f(backdrop row, src row, x0, y)` over the rows of two buffers covering the same rect. +fn for_rows(backdrop: &mut Buffer, src: &Buffer, mut f: impl FnMut(&mut [[f32; 4]], &[[f32; 4]], i32, i32)) { + let rect = backdrop.rect; + let w = (rect.width() as usize).max(1); + for (ry, (dst, s)) in backdrop.px.chunks_mut(w).zip(src.px.chunks(w)).enumerate() { + f(dst, s, rect.x0, rect.y0 + ry as i32); } - for (b, s) in backdrop.px.iter_mut().zip(&src.px) { +} + +/// [`blend_into_fill`] of one row: `dst` and `src` start at pixel (`x0`, `y`). +#[allow(clippy::too_many_arguments)] +fn blend_span_fill(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { + if fill >= 1.0 || !psblend::fill_is_special(mode) { + return blend_span_g(dst, src, x0, y, mode, opacity * fill, gamma); + } + for (b, s) in dst.iter_mut().zip(src) { if s[3] > 0.0 { *b = psblend::composite_fill(mode, *b, *s, opacity, fill, gamma); } } } -/// [`blend_into`] mixing coverage in a `gamma` space (type layers). -fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { +/// [`blend_into_g`] of one row: `dst` and `src` start at pixel (`x0`, `y`). +fn blend_span_g(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: BlendMode, opacity: f32, gamma: f32) { // The blend space is constant in this loop. Some TLS implementations synchronise even a // boolean read, so select the common Normal kernel once instead of once per pixel. if mode == BlendMode::Normal && gamma == 1.0 && !psblend::LAB_MIX.with(|l| l.get()) { - for (i, b) in backdrop.px.iter_mut().enumerate() { - let s = src.px[i]; - if s[3] <= 0.0 { - continue; - } - *b = psblend::composite_normal_rgb(*b, s, opacity); - } - return; + return psblend::composite_normal_rgb_span(dst, src, opacity); } - let rect = backdrop.rect; - let w = rect.width() as i32; - for (i, b) in backdrop.px.iter_mut().enumerate() { - let mut s = src.px[i]; + for (x, (b, s)) in (x0..).zip(dst.iter_mut().zip(src)) { + let mut s = *s; if s[3] <= 0.0 { continue; } - let mut mode = mode; if mode == BlendMode::Dissolve { - let x = rect.x0 + (i as i32 % w); - let y = rect.y0 + (i as i32 / w); s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 }; - mode = BlendMode::Normal; - *b = blend::composite(mode, *b, s, 1.0); + *b = blend::composite(BlendMode::Normal, *b, s, 1.0); continue; } *b = blend::composite_gamma(mode, *b, s, opacity, gamma); diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index 4cff6f3082..b5eeaa79d0 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -238,6 +238,63 @@ pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity normal_rgb(backdrop, source, as_, ao) } +/// [`composite_normal_rgb`] over a row, bit for bit, in blocks the compiler vectorizes (see +/// [`normal_lanes`]); the tail that doesn't fill a block goes through the scalar kernel. +pub(crate) fn composite_normal_rgb_span(dst: &mut [[f32; 4]], src: &[[f32; 4]], opacity: f32) { + let n = dst.len().min(src.len()); + let (dc, dt) = dst[..n].as_chunks_mut::(); + let (sc, st) = src[..n].as_chunks::(); + for (b, s) in dc.iter_mut().zip(sc) { + normal_lanes(b, s, opacity); + } + for (b, s) in dt.iter_mut().zip(st) { + // Not `s[3] > 0`: a NaN alpha is blended (into NaN), as the scalar loop does. + if s[3] <= 0.0 { + continue; + } + *b = composite_normal_rgb(*b, *s, opacity); + } +} + +/// Pixels per block of [`normal_lanes`]: two 128-bit vectors (or one 256-bit) per channel. +const LANES: usize = 8; + +/// [`composite_normal_rgb`] (after the caller's `source alpha > 0` test) on one block of pixels, +/// without branches: one array per channel, fixed-length loops the compiler vectorizes, and a +/// select per pixel between the backdrop, zero and the mix. Same operations in the same order +/// (Rust never fuses them into multiply-adds) and the same tests, NaN included: bit-identical. +#[inline] +#[allow(clippy::needless_range_loop)] // lanes index several arrays in lockstep +fn normal_lanes(b: &mut [[f32; 4]; LANES], s: &[[f32; 4]; LANES], opacity: f32) { + let lane = |p: &[[f32; 4]; LANES], c: usize| -> [f32; LANES] { std::array::from_fn(|i| p[i][c]) }; + let (b0, b1, b2, ab) = (lane(b, 0), lane(b, 1), lane(b, 2), lane(b, 3)); + let (s0, s1, s2, s3) = (lane(s, 0), lane(s, 1), lane(s, 2), lane(s, 3)); + let mut as_ = [0.0f32; LANES]; + let mut ao = [0.0f32; LANES]; + let mut kb = [0.0f32; LANES]; + let mut inv = [0.0f32; LANES]; + for i in 0..LANES { + as_[i] = s3[i] * opacity; + ao[i] = as_[i] + ab[i] * (1.0 - as_[i]); + kb[i] = ab[i] * (1.0 - as_[i]); + inv[i] = 1.0 / ao[i]; + } + for i in 0..LANES { + let mix = [(kb[i] * b0[i] + as_[i] * s0[i]) * inv[i], (kb[i] * b1[i] + as_[i] * s1[i]) * inv[i], (kb[i] * b2[i] + as_[i] * s2[i]) * inv[i], ao[i]]; + // The scalar path's tests: skipped where the source is empty (`s[3] <= 0`) or adds + // nothing (`as_ <= 0`), zero where nothing is left (`ao <= 0`). + let keep = (s3[i] <= 0.0) | (as_[i] <= 0.0); + let gone = ao[i] <= 0.0; + b[i] = if keep { + b[i] + } else if gone { + [0.0; 4] + } else { + mix + }; + } +} + #[inline] fn normal_rgb(backdrop: [f32; 4], source: [f32; 4], as_: f32, ao: f32) -> [f32; 4] { // B(Cb, Cs) = Cs. Keep the scalar composite's operation order and straight alpha. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..4cc6e7925e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -80,6 +80,105 @@ fn normal_rgb_kernel_preserves_scalar_bits() { } } +/// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover +/// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals +/// and values outside 0..1. +#[test] +fn normal_rgb_span_preserves_scalar_bits() { + let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30]; + let mut seed = 0x9e37_79b9u32; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + let mut value = || { + let r = next(); + if r % 3 == 0 { specials[(r / 3) as usize % specials.len()] } else { (r % 1000) as f32 / 999.0 } + }; + for len in 0..41 { + for _ in 0..40 { + let backdrop: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect(); + let source: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect(); + let opacity = value(); + let mut want = backdrop.clone(); + for (b, s) in want.iter_mut().zip(&source) { + if s[3] <= 0.0 { + continue; + } + *b = original_normal_rgb(*b, *s, opacity); + } + let mut got = backdrop; + psblend::composite_normal_rgb_span(&mut got, &source, opacity); + for (i, (g, w)) in got.iter().zip(&want).enumerate() { + assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}"); + } + } + } +} + +/// A pixel layer read and blended a row at a time gives exactly what blending a tile-sized copy +/// of it gave, across depths, modes (Dissolve's per-pixel noise and the fill-special modes +/// included), fill, masks (feathered too) and a rect that is not tile-aligned and runs off the +/// layer's pixels. +#[test] +fn pixel_layer_rows_match_tile_copy() { + let rect = Rect::new(-3, 2, 37, 21); + let modes = [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Dissolve, + BlendMode::Overlay, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::VividLight, + BlendMode::Difference, + BlendMode::Hue, + ]; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut l = Layer::raster("p", fmt); + for y in 0..20 { + for x in 0..30 { + let t = (x * 7 + y * 13) as f32 / 400.0; + let a = [0.0, 0.03, 0.5, 1.0][(x + y) as usize % 4]; + l.surface_mut().unwrap().write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, [t.fract(), 1.0 - t.fract(), 0.3, a])); + } + } + let mut sharp = LayerMask::reveal_all(); + sharp.surface.fill_rect(Rect::new(5, 0, 12, 30), &[0.25]); + let mut feathered = sharp.clone(); + feathered.feather = 2.5; + for mask in [None, Some(sharp), Some(feathered)] { + l.mask = mask; + for mode in modes { + for fill in [1.0, 0.4] { + l.blend = mode; + l.opacity = 0.8; + l.fill_opacity = fill; + let mut doc = doc_white(40, 30); + doc.layers.push(l.clone()); + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(&doc, &patterns); + let layer = &doc.layers[1]; + let backdrop = Buffer { + rect, + px: (0..rect.width() * rect.height()) + .map(|i| [(i % 11) as f32 / 10.0, (i % 5) as f32 / 4.0, 0.6, [1.0, 0.5, 0.0][i as usize % 3]]) + .collect(), + }; + let mut want = backdrop.clone(); + let content = render_content(layer, rect, &cx).unwrap(); + blend_into_fill(&mut want, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); + let mut got = backdrop; + composite_pixel_layer(layer, pixel_surface(layer).unwrap(), &mut got, &cx); + assert_buffer_bits(&got, &want); + } + } + } + } +} + fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) { let rect = Rect::new(-3, -2, 5, 0); let mut bottom = Surface::new(fmt); From 33dd5f0103c9b509d5dd80c45477ada380a8f3d2 Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Sun, 18 Oct 2026 22:24:54 +0300 Subject: [PATCH 145/304] perf(gpu): blend plain layers several to a pass, P60/P61 111 -> 28 ms - LayerBlend: a plain layer is blended straight from its texture in one pass instead of a Content pass to a scratch chunk and a Blend pass. - fs_layerbatch: runs of up to four plain layers that only feed each other are drawn as one pass, reading and writing the chunk once (the uniform record grows to its 256-byte stride to hold the extra layers). - Drop shadows and outer glows alone: the merge builds the layer from its shape (F_LAYER_SHAPE), and nothing is copied back when the effect region covers the canvas. Such a layer takes 3 passes instead of 6. - fs_layerbatch_normal: runs of Normal layers use a shader with Normal's code alone. Per-pass timestamps showed the batch spent 80% of its time on the register cost of carrying every blend mode, not on the arithmetic; Normal goes through the same function in every shader, so a layer gives the same bits whichever pass draws it. P60 / P61 with the GPU compositor (8 GB budget, M4 Pro): 111 / 113 ms -> 28.2 / 28.1 ms. With the app's default Memory Usage these documents still fall back to the CPU compositor on unified memory, so the budgets stay unenforced. Output: over the GPU parity suite (1620 renders) 1038 are bit-identical to before and the rest differ by at most 1e-4/255, except one pixel one 8-bit step apart where an adjustment rounds at .5; the mean difference from the CPU reference is unchanged. New parity tests cover runs of every mode, Normal runs at 8/16/32-bit and Lab, and exterior effects at any fill. --- crates/gpu/src/compose.wgsl | 120 ++++++++++++++- crates/gpu/src/lib.rs | 229 ++++++++++++++++++++++++++++- crates/gpu/src/plan.rs | 138 ++++++++++++++--- crates/gpu/tests/parity.rs | 142 +++++++++++++++++- crates/gpu/tests/uniform_limits.rs | 9 +- 5 files changed, 596 insertions(+), 42 deletions(-) diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6457b1192c..1b62e2d46d 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -31,6 +31,13 @@ struct Op { map_origin: vec2, map_size: vec2, p4: vec4, + // fs_layerbatch's third extra layer and the rest of its second (see `batch_layer`); zero + // elsewhere. With them the record fills its 256-byte stride. + q0: vec4, + q1: vec4, + q2: vec4, + q3: vec4, + q4: vec4, }; const F_MASK: u32 = 1u; // layer has an enabled mask @@ -53,6 +60,7 @@ const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment res const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) const F_BLEND_IF: u32 = 524288u; // lerp: B back towards A by the Blend If ranges in p0..p3 const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w +const F_LAYER_SHAPE: u32 = 1048576u; // fx merge: B is the shape; layer = its colour at fill p0.x inside it × its alpha @group(0) @binding(0) var chunk: Chunk; @group(0) @binding(1) var op: Op; @@ -312,6 +320,9 @@ fn fill_is_special(mode: i32) -> bool { // colour (Hard Mix: (cb - f(1 - cs)) / (1 - f)) at `opacity`, Fill staying coverage over // transparency. Lab documents mix Normal in CIELAB; type layers mix in the text gamma space. fn composite_fill(mode: i32, b: vec4, s: vec4, opacity: f32, fill: f32, gamma_on: bool) -> vec4 { + // The same code as fs_layerbatch_normal, so a layer gives the same bits whichever pass + // draws it (a render resumed from the prefix cache splits runs differently). + if (mode == M_NORMAL && !gamma_on && (op.flags & F_LAB) == 0u) { return composite_normal_fill(b, s, opacity, fill); } let f = clamp(fill, 0.0, 1.0); let special = f < 1.0 && fill_is_special(mode); let ab = b.a; @@ -340,6 +351,21 @@ fn composite_fill(mode: i32, b: vec4, s: vec4, opacity: f32, fill: f32 return vec4(text_dec(wb * text_enc(b.rgb, g) + wbs * text_enc(bl, g) + ws * text_enc(s.rgb, g), g), ao); } +// composite_fill for Normal outside Lab documents and without text gamma: Fill is plain coverage +// and the blend is the source. Kept apart so fs_layerbatch_normal doesn't carry every other +// mode's code, which costs registers and so pixels in flight. +fn composite_normal_fill(b: vec4, s: vec4, opacity: f32, fill: f32) -> vec4 { + let ab = b.a; + let over = s.a * opacity * clamp(fill, 0.0, 1.0); + if (over <= 0.0) { return b; } + let ao = ab + (1.0 - ab) * over; + if (ao <= 0.0) { return vec4(0.0); } + let wb = ab * (1.0 - over) / ao; + let wbs = ab * over / ao; + let ws = (1.0 - ab) * over / ao; + return vec4(wb * b.rgb + wbs * s.rgb + ws * s.rgb, ao); +} + // photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB). const D50: vec3 = vec3(0.96422, 1.0, 0.82521); fn lab_f(t: f32) -> f32 { @@ -812,12 +838,9 @@ fn fs_mask(in: VOut) -> @location(0) vec4 { return c; } -// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve. -@fragment -fn fs_blend(in: VOut) -> @location(0) vec4 { - let p = local(in.pos); - let b = textureLoad(tex_a, p, 0); - var s = textureLoad(tex_b, p, 0); +// blend_into(backdrop = b, src = s, mode, opacity) at chunk pixel p, including Dissolve. +fn blend_px(p: vec2, b: vec4, src: vec4) -> vec4 { + var s = src; if (s.a <= 0.0) { return b; } if (op.mode == M_DISSOLVE) { s.a = select(0.0, 1.0, dissolve_noise(doc_px(p)) < s.a * op.opacity * op.fill); @@ -826,6 +849,85 @@ fn fs_blend(in: VOut) -> @location(0) vec4 { return composite_fill(op.mode, b, s, op.opacity, op.fill, (op.flags & F_TEXT_GAMMA) != 0u); } +// blend_into(backdrop = A, src = B, mode, opacity), including Dissolve. +@fragment +fn fs_blend(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + return blend_px(p, textureLoad(tex_a, p, 0), textureLoad(tex_b, p, 0)); +} + +// fs_content then fs_blend in one pass: the layer's own pixels (times its mask) blended straight +// onto A, without writing them to a chunk texture and reading them back. +@fragment +fn fs_layerblend(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + return blend_px(p, textureLoad(tex_a, p, 0), s); +} + +// The pixel at document pixel d of an extra layer of fs_layerbatch: an unmasked plain layer whose +// pixels are in texture `t` (0: tex_b, 1: tex_c, 2: map_tex). Three vec4 of the record describe +// it: geo = (texture origin x, y, size w, h), color = its colour outside the texture, +// prm = (opacity, fill, mode, 1 when it has a texture), all whole numbers stored as floats. +fn batch_texel(t: i32, d: vec2, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let q = d - vec2(geo.xy); + if (prm.w > 0.5 && inside(q, vec2(geo.zw))) { + if (t == 0) { + return textureLoad(tex_b, q, 0); + } else if (t == 1) { + return textureLoad(tex_c, q, 0); + } + return textureLoad(map_tex, q, 0); + } + return color; +} + +// One extra layer of fs_layerbatch (see batch_texel) blended onto b at document pixel d. +fn batch_layer(t: i32, d: vec2, b: vec4, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let s = batch_texel(t, d, geo, color, prm); + if (s.a <= 0.0) { return b; } + return composite_fill(i32(prm.z), b, s, prm.x, prm.y, false); +} + +// batch_layer of a Normal layer outside Lab documents. +fn batch_layer_normal(t: i32, d: vec2, b: vec4, geo: vec4, color: vec4, prm: vec4) -> vec4 { + let s = batch_texel(t, d, geo, color, prm); + if (s.a <= 0.0) { return b; } + return composite_normal_fill(b, s, prm.x, prm.y); +} + +// fs_layerblend of this pass's layer, then `op.kind` (at most 3) more plain layers: consecutive +// LayerBlend passes the executor draws as one, reading and writing the chunk once for all of them. +@fragment +fn fs_layerbatch(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + var b = blend_px(p, textureLoad(tex_a, p, 0), s); + if (op.kind >= 1) { b = batch_layer(0, d, b, op.p0, op.p1, op.p2); } + if (op.kind >= 2) { b = batch_layer(1, d, b, op.p3, op.q0, op.q1); } + if (op.kind >= 3) { b = batch_layer(2, d, b, op.q2, op.q3, op.q4); } + return b; +} + +// fs_layerbatch for a run of Normal layers without text gamma, outside Lab documents. +@fragment +fn fs_layerbatch_normal(in: VOut) -> @location(0) vec4 { + let p = local(in.pos); + let d = doc_px(p); + var s = layer_texel(d); + s.a = s.a * mask_value(d); + var b = textureLoad(tex_a, p, 0); + if (s.a > 0.0) { b = composite_normal_fill(b, s, op.opacity, op.fill); } + if (op.kind >= 1) { b = batch_layer_normal(0, d, b, op.p0, op.p1, op.p2); } + if (op.kind >= 2) { b = batch_layer_normal(1, d, b, op.p3, op.q0, op.q1); } + if (op.kind >= 3) { b = batch_layer_normal(2, d, b, op.q2, op.q3, op.q4); } + return b; +} + // composite_atop(base = A, src = B): blend as if the base were opaque, keep its alpha. @fragment fn fs_atop(in: VOut) -> @location(0) vec4 { @@ -1168,8 +1270,12 @@ fn mix_premul(a: vec4, b: vec4, k: f32) -> vec4 { fn fs_fxmerge(in: VOut) -> @location(0) vec4 { let p = local(in.pos); var w = textureLoad(tex_a, p, 0); - let l = textureLoad(tex_b, p, 0); + var l = textureLoad(tex_b, p, 0); let c = textureLoad(tex_c, p, 0); + if ((op.flags & F_LAYER_SHAPE) != 0u) { + // fs_fxinit INIT_INSIDE then INIT_ALPHA, on the shape in B. + l = vec4(l.rgb, select(0.0, op.p0.x, l.a > INSIDE_EPS) * min(l.a, 1.0)); + } if (l.a > 0.0 && (op.flags & F_NO_LAYER) == 0u) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); } let atop = (op.flags & F_ATOP) != 0u; var before = c; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index e99dc53416..468649a108 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -70,7 +70,7 @@ const MAP16: wgpu::TextureFormat = wgpu::TextureFormat::R16Float; pub const CHUNK: u32 = 1024; const STRIDE: u64 = 256; const CHUNK_UNIFORM: u64 = 16; -const OP_UNIFORM: u64 = 176; +const OP_UNIFORM: u64 = 256; /// GPU memory the effect-map cache may hold before evicting layers not drawn this frame. pub const FX_BUDGET: usize = 1536 << 20; /// Largest side of the square pages layer surfaces are stored in on the GPU (smaller when the @@ -139,6 +139,10 @@ pub struct Stats { pub prefix_hits: usize, /// Planned prefix passes skipped, summed over the reused chunks. pub prefix_passes_skipped: usize, + /// Plain layers blended inside another layer's pass (`fs_layerbatch`), summed over chunks. + pub layers_batched: usize, + /// Batched passes drawn with Normal's code alone (`fs_layerbatch_normal`), summed over chunks. + pub normal_batches: usize, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1405,6 +1409,8 @@ impl Compositor { .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); + // Runs of plain layers drawn as one pass each (`fs_layerbatch`). + let runs = batch_runs(plan, checkpoint.map(|c| c.pass_end)); // Chunk pool. while self.pool.len() < plan.slots as usize { @@ -1439,7 +1445,13 @@ impl Compositor { } let (tex, mask) = bound.views[i]; let map = bound.maps[i].as_ref().map(|(_, r)| *r); - let w = op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map); + let run = runs.get(i).copied().unwrap_or(1); + let w = if run > 1 { + let extras: Vec<_> = (i + 1..i + run).filter_map(|j| Some((plan.passes.get(j)?, bound.views.get(j).and_then(|v| v.0).map(|t| t.1)))).collect(); + batch_op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), &extras) + } else { + op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map) + }; let off = (op_base + i as u64 * STRIDE) as usize; data[off..][..w.len()].copy_from_slice(&w); } @@ -1468,6 +1480,13 @@ impl Compositor { let lv = fr.luts.get(i).and_then(Option::as_ref).map_or(dummy, |(_, v)| v); let map = bound.maps[i].as_ref().map_or(dummy, |(v, _)| v); let pat = fr.patterns.get(i).and_then(Option::as_ref).unwrap_or(dummy); + let run = runs.get(i).copied().unwrap_or(1); + if run > 1 { + // The extra layers' pixels in tex_b, tex_c and map_tex (`batch_layer`). + let extra = |e: usize| bound.views.get(i + e).and_then(|v| v.0).map_or(dummy, |(k, _)| resident_views[k]); + bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), extra(1), tv, mv, lv, extra(2), extra(3), pat]))); + continue; + } bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), slot(p.b), tv, mv, lv, slot(p.c), map, pat]))); } @@ -1481,8 +1500,10 @@ impl Compositor { stats.prefix_hits = stats.prefix_hits.saturating_add(1); stats.prefix_passes_skipped = stats.prefix_passes_skipped.saturating_add(resume); } + // Passes drawn inside the previous `fs_layerbatch` pass. + let mut batched_to = 0; for (i, p) in plan.passes.iter().enumerate() { - if i < resume { + if i < resume || i < batched_to { continue; } // Chunk textures, uniforms and bind groups outlive the submit, and the queue @@ -1542,7 +1563,21 @@ impl Compositor { _ => {} } self.recorded += 1; - let target = &self.pool[p.dst as usize].1; + // A run draws as one pass into the slot its last layer writes. + let run = runs.get(i).copied().unwrap_or(1); + let (kernel, dst) = match plan.passes.get(i + run - 1) { + Some(last) if run > 1 => { + batched_to = i + run; + stats.layers_batched = stats.layers_batched.saturating_add(run - 1); + let kernel = plan.passes.get(i..i + run).map_or(Kernel::LayerBatch, batch_kernel); + if kernel == Kernel::LayerBatchNormal { + stats.normal_batches = stats.normal_batches.saturating_add(1); + } + (kernel, last.dst) + } + _ => (p.kernel, p.dst), + }; + let target = &self.pool[dst as usize].1; let clear = p.kernel == Kernel::Clear; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("pc_compose_pass"), @@ -1563,7 +1598,7 @@ impl Compositor { if clear { continue; } - pass.set_pipeline(&self.kit.pipelines[&(p.kernel, self.acc_format)]); + pass.set_pipeline(&self.kit.pipelines[&(kernel, self.acc_format)]); pass.set_scissor_rect(scissor.x0 as u32, scissor.y0 as u32, scissor.width(), scissor.height()); pass.set_bind_group(0, &bg0, &[(ci as u64 * STRIDE) as u32, (op_base + i as u64 * STRIDE) as u32]); pass.set_bind_group(1, bg1[i].as_ref(), &[]); @@ -2046,6 +2081,38 @@ fn web_time_now() -> Option { /// The uniform record of a pass. fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { + words(&op_values(p, tex, mask, map)) +} + +/// The uniform record of the `fs_layerbatch` pass that draws `p` (whose texture and mask cover +/// `tex` and `mask`) and then `extras`, each an unmasked plain layer's `LayerBlend` pass with the +/// area its texture covers. Layout: see `batch_layer` in compose.wgsl. +fn batch_op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, extras: &[(&plan::Pass<'_>, Option<[i32; 4]>)]) -> Vec { + let mut v = op_values(p, tex, mask, None); + // Word offsets of the record's vec4s holding the extra layers, three each: p0, p1, p2 | p3, + // q0, q1 | q2, q3, q4. + const AT: [usize; 9] = [20, 24, 28, 32, 44, 48, 52, 56, 60]; + if let Some(kind) = v.get_mut(1) { + *kind = I(extras.len().min(AT.len() / 3) as i32); + } + for (e, (x, area)) in extras.iter().take(AT.len() / 3).enumerate() { + let r = area.unwrap_or([0; 4]); + let geo = [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]; + let prm = [x.opacity, x.fill, plan::mode_index(x.mode) as f32, if area.is_some() { 1.0 } else { 0.0 }]; + for (k, vals) in [geo, x.color, prm].into_iter().enumerate() { + let at = AT[e * 3 + k]; + for (c, f) in vals.into_iter().enumerate() { + if let Some(w) = v.get_mut(at + c) { + *w = F(f); + } + } + } + } + words(&v) +} + +/// The words of a pass's uniform record (`Op` in compose.wgsl). +fn op_values(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, map: Option) -> Vec { let mut flags = p.flags; let (mut to, mut ts, mut mo, mut ms) = ([0; 2], [0; 2], [0; 2], [0; 2]); if let Some(r) = tex { @@ -2092,7 +2159,67 @@ fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, m } v.extend([I(mpo[0]), I(mpo[1]), I(mps[0]), I(mps[1])]); v.extend(p.extra.iter().map(|f| F(*f))); - words(&v) + // q0..q4: only `fs_layerbatch` reads them (`batch_op_words`). + v.extend((0..20).map(|_| F(0.0))); + v +} + +/// Most layers one `fs_layerbatch` pass blends: its own and the three more its record holds. +const BATCH: usize = 4; + +/// For each pass, how many passes from it on the executor draws as one `fs_layerbatch` pass +/// (1: the pass alone). A run starts at a `LayerBlend` pass over the whole chunk (no clip) that +/// isn't a gradient (whose parameters use the words the extra layers go in), and continues while +/// the next pass is a `LayerBlend` of an unmasked plain layer (no text gamma, no Dissolve) onto +/// exactly what the previous pass wrote, a slot nothing else reads before it is written again. +/// A run doesn't cross `capture`, the pass where this render captures its prefix checkpoint, and +/// never ends in the slot it reads from. +fn batch_runs(plan: &Plan<'_>, capture: Option) -> Vec { + let passes = &plan.passes; + let n = passes.len(); + let reads = |p: &plan::Pass<'_>, s: plan::Slot| [p.a, p.b, p.c, p.d].contains(&Some(s)); + // feeds_next[i]: what pass i writes is read by pass i + 1 alone. + let feeds_next: Vec = (0..n) + .map(|i| { + let (Some(this), Some(next)) = (passes.get(i), passes.get(i + 1)) else { return false }; + let dst = this.dst; + if next.a != Some(dst) || [next.b, next.c, next.d].contains(&Some(dst)) { + return false; + } + for later in passes.iter().skip(i + 2) { + // A copy into a slot keeps what lies outside its rect: that is a read too. + if reads(later, dst) || (later.dst == dst && later.kernel == Kernel::CopyRect) { + return false; + } + if later.dst == dst { + return true; + } + } + dst != plan.root + }) + .collect(); + let lead = |p: &plan::Pass<'_>| p.kernel == Kernel::LayerBlend && p.clip.is_none() && !p.gradient; + let extra = |p: &plan::Pass<'_>| lead(p) && p.mask.is_none() && p.flags & plan::F_TEXT_GAMMA == 0 && p.mode != photocraft_color::BlendMode::Dissolve; + (0..n) + .map(|i| { + let Some(first) = passes.get(i).filter(|p| lead(p)) else { return 1 }; + let (mut k, mut best) = (1, 1); + while k < BATCH && feeds_next.get(i + k - 1).copied().unwrap_or(false) && passes.get(i + k).is_some_and(extra) && capture != Some(i + k) { + k += 1; + if passes.get(i + k - 1).is_some_and(|p| Some(p.dst) != first.a) { + best = k; + } + } + best + }) + .collect() +} + +/// The pipeline that draws `run` (passes `batch_runs` joined): `fs_layerbatch_normal` when every +/// layer blends Normal without text gamma outside Lab documents, else `fs_layerbatch`. +fn batch_kernel(run: &[plan::Pass<'_>]) -> Kernel { + let normal = |p: &plan::Pass<'_>| p.mode == photocraft_color::BlendMode::Normal && p.flags & (plan::F_TEXT_GAMMA | plan::F_LAB) == 0; + if run.iter().all(normal) { Kernel::LayerBatchNormal } else { Kernel::LayerBatch } } /// Canvas rounded out to whole tiles. @@ -2537,6 +2664,96 @@ pub fn render_to_vec_stats( mod tests { use super::*; + /// A run joins `LayerBlend` passes that only feed each other, at most `BATCH` of them, and + /// never ends in the slot it reads from. + fn check_runs(p: &Plan<'_>, capture: Option) -> Vec { + let runs = batch_runs(p, capture); + assert_eq!(runs.len(), p.passes.len()); + for (i, &k) in runs.iter().enumerate() { + assert!((1..=BATCH).contains(&k), "pass {i}: run {k}"); + if k > 1 { + assert_ne!(Some(p.passes[i + k - 1].dst), p.passes[i].a, "run at {i} renders into its input"); + for j in i + 1..i + k { + assert_eq!(p.passes[j].kernel, Kernel::LayerBlend); + assert_eq!(p.passes[j].a, Some(p.passes[j - 1].dst), "run at {i}: pass {j} doesn't read the one before"); + assert!(p.passes[j].mask.is_none() && p.passes[j].mode != photocraft_color::BlendMode::Dissolve, "run at {i}: pass {j}"); + assert_ne!(capture, Some(j), "run at {i} skips the captured checkpoint"); + } + } + } + runs + } + + #[test] + fn plain_layers_batch_and_runs_stop_where_a_pass_must_stand_alone() { + use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerMask}; + let mut d = Document::with_background("t", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); + for i in 0..12 { + let mut l = Layer::raster(format!("l{i}"), d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[0.2, 0.4, 0.6, 0.5]); + d.layers.push(l); + } + let p = plan::plan(&d).unwrap(); + let runs = check_runs(&p, None); + assert!(runs.iter().any(|k| *k >= 3), "{runs:?}"); + // A run stops at the checkpoint this render captures, wherever that is. + for cp in &p.checkpoints { + check_runs(&p, Some(cp.pass_end)); + } + // A masked or Dissolve layer, or a clipped one, never joins a run as an extra layer. + d.layers[3].mask = Some(LayerMask::hide_all()); + d.layers[6].blend = BlendMode::Dissolve; + d.layers[9].clipped = true; + check_runs(&plan::plan(&d).unwrap(), None); + // Nothing to batch: a lone layer. + d.layers.truncate(1); + assert_eq!(check_runs(&plan::plan(&d).unwrap(), None), vec![1; plan::plan(&d).unwrap().passes.len()]); + } + + #[test] + fn batch_records_hold_three_extra_layers_in_the_spare_words() { + let lead = plan::Pass::new(Kernel::LayerBlend, 0); + let mut x = plan::Pass::new(Kernel::LayerBlend, 1); + x.opacity = 0.25; + x.fill = 0.5; + x.color = [0.1, 0.2, 0.3, 0.4]; + let w = batch_op_words(&lead, None, None, &[(&x, Some([1, 2, 3, 4])), (&x, None), (&x, None), (&x, None)]); + assert_eq!(w.len() as u64, OP_UNIFORM); + let word = |i: usize| <[u8; 4]>::try_from(&w[i * 4..i * 4 + 4]).unwrap(); + let f = |i: usize| f32::from_le_bytes(word(i)); + // At most three extras; the first's geometry, colour and parameters in p0, p1, p2. + assert_eq!(i32::from_le_bytes(word(1)), 3); + assert_eq!([f(20), f(21), f(22), f(23)], [1.0, 2.0, 3.0, 4.0]); + assert_eq!([f(24), f(25), f(26), f(27)], [0.1, 0.2, 0.3, 0.4]); + assert_eq!([f(28), f(29), f(31)], [0.25, 0.5, 1.0]); + // The third's parameters in q4; one without pixels says so. + assert_eq!([f(60), f(61), f(63)], [0.25, 0.5, 0.0]); + // Other passes leave the batch words zero. + let plain = op_words(&x, None, None, None); + assert!(plain[44 * 4..].iter().all(|b| *b == 0)); + } + + #[test] + fn runs_of_normal_layers_draw_with_normal_code_alone() { + use photocraft_color::BlendMode; + let pass = |mode: BlendMode, flags: u32| { + let mut p = plan::Pass::new(Kernel::LayerBlend, 0); + p.mode = mode; + p.flags = flags; + p + }; + let normal = pass(BlendMode::Normal, plan::F_HDR); + assert_eq!(batch_kernel(&[normal.clone(), pass(BlendMode::Normal, 0)]), Kernel::LayerBatchNormal); + // Any other mode, text gamma or a Lab document anywhere in the run takes the full shader. + for other in + [pass(BlendMode::Multiply, 0), pass(BlendMode::Dissolve, 0), pass(BlendMode::Normal, plan::F_TEXT_GAMMA), pass(BlendMode::Normal, plan::F_LAB)] + { + assert_eq!(batch_kernel(&[normal.clone(), other.clone()]), Kernel::LayerBatch); + assert_eq!(batch_kernel(&[other, normal.clone()]), Kernel::LayerBatch); + } + } + #[test] fn uniform_buffer_sizes_are_checked_before_allocation() { assert_eq!(uniform_buffer_len(1, 255, 65_536).unwrap(), 65_536); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 58ce6e4299..6cb44b9200 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -34,6 +34,14 @@ pub enum Kernel { Content, Mask, Blend, + /// `Content` then `Blend` in one pass: a plain layer's pixels blended straight onto A. + LayerBlend, + /// A run of consecutive `LayerBlend` passes drawn as one (never planned: the executor draws + /// it in their place, see `batch_runs`). + LayerBatch, + /// `LayerBatch` of a run whose layers all blend Normal without text gamma, outside Lab + /// documents (never planned: the executor picks it, see `batch_kernel`). + LayerBatchNormal, Atop, Adjust, AdjMix, @@ -73,6 +81,9 @@ impl Kernel { Kernel::Content => "fs_content", Kernel::Mask => "fs_mask", Kernel::Blend => "fs_blend", + Kernel::LayerBlend => "fs_layerblend", + Kernel::LayerBatch => "fs_layerbatch", + Kernel::LayerBatchNormal => "fs_layerbatch_normal", Kernel::Atop => "fs_atop", Kernel::Adjust => "fs_adjust", Kernel::AdjMix => "fs_adjmix", @@ -111,10 +122,13 @@ impl Kernel { } /// Every kernel with a pipeline. - pub const DRAWN: [Kernel; 21] = [ + pub const DRAWN: [Kernel; 24] = [ Kernel::Content, Kernel::Mask, Kernel::Blend, + Kernel::LayerBlend, + Kernel::LayerBatch, + Kernel::LayerBatchNormal, Kernel::Atop, Kernel::Adjust, Kernel::AdjMix, @@ -395,6 +409,10 @@ pub const F_ADD_DIFF: u32 = 16384; pub const F_BLEND_IF: u32 = 524288; /// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`. pub const F_TEXT_GAMMA: u32 = 8192; +/// `FxMerge`: B is the layer's shape (its content); the layer is its colour at fill opacity +/// (`p0.x`) inside the shape, times the shape's alpha: `FxInit` `INIT_INSIDE` then +/// `INIT_ALPHA` in the merge, for a layer with no interior effects between them. +pub const F_LAYER_SHAPE: u32 = 1_048_576; /// A layer's Blend If ranges for `F_BLEND_IF`: Gray, then the colour channels, each as /// [this layer, underlying layer]; every range as two floats, `black low × 256 + black high` and @@ -764,26 +782,36 @@ impl<'a> Planner<'a> { } return Ok(backdrop); } - let start = self.passes.len(); - let mut content = self.content(layer)?; - for c in visible_clipped { - content = self.atop(c, content)?; - } - if let Some(clip) = clip { - // Everything that built the layer's content only matters inside its bounds. - for p in &mut self.passes[start..] { - if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) { - p.clip = Some(clip); + // A layer with nothing clipped to it whose content is one Content pass is blended + // straight from its pixels (`Kernel::LayerBlend`): one pass instead of two, and no + // scratch chunk written and read back. + let mut p = if visible_clipped.is_empty() && single_content_pass(layer) { + let mut p = self.content_pass(layer)?; + p.kernel = Kernel::LayerBlend; + p + } else { + let start = self.passes.len(); + let mut content = self.content(layer)?; + for c in visible_clipped { + content = self.atop(c, content)?; + } + if let Some(clip) = clip { + // Everything that built the layer's content only matters inside its bounds. + for p in &mut self.passes[start..] { + if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) { + p.clip = Some(clip); + } } } - } - let mut p = Pass::new(Kernel::Blend, 0); + let mut p = Pass::new(Kernel::Blend, 0); + p.b = Some(content); + p + }; p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop }); - p.b = Some(content); p.mode = layer.blend; p.opacity = layer.opacity; p.fill = layer.fill_opacity; - p.flags = gamma_flag(layer); + p.flags |= gamma_flag(layer); p.extra[3] = photocraft_compose::text_gamma(layer); p.clip = clip; let merged = self.emit(p); @@ -804,6 +832,11 @@ impl<'a> Planner<'a> { /// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop); /// returns the new backdrop. Consumes both. fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot { + // A clip over the whole canvas left no pixel of `merged` uncomputed: it is the backdrop. + if clip.contains_rect(&self.cx.canvas) { + self.release(backdrop); + return merged; + } let dst = if self.refs[backdrop as usize] == 1 { backdrop } else { @@ -1129,6 +1162,25 @@ impl<'a> Planner<'a> { } } + // Only exterior effects (drop shadows, outer glows): nothing is painted between the + // layer's colour inside its shape and the shape's alpha, so the merge derives both from + // the shape (`F_LAYER_SHAPE`) instead of two passes building the layer. + let premerge = photocraft_compose::effects::premerges_layer(layer); + if !relative && !premerge && items.iter().all(|e| matches!(e, Effect::DropShadow(_) | Effect::OuterGlow(_))) { + let mut p = Pass::new(Kernel::FxMerge, 0); + p.a = Some(w); + p.b = Some(lay_src); + p.c = Some(self.retain(backdrop)); + p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + p.opacity = layer.opacity; + p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer) | F_LAYER_SHAPE; + p.params[0][0] = layer.fill_opacity; + p.extra[3] = photocraft_compose::text_gamma(layer); + p.clip = Some(clip); + let merged = self.emit(p); + self.release(content); + return Ok(self.write_back(backdrop, merged, clip)); + } // The layer: its colour at fill opacity inside its shape; interior effects are painted // relative to the shape (coverage within it), then the shape's alpha applies. let mut l = if relative { @@ -1139,7 +1191,6 @@ impl<'a> Planner<'a> { }; // Blend Interior Effects as Group off: the layer merges in its mode first and the // interior effects paint over the result (`effects::premerges_layer`). - let premerge = photocraft_compose::effects::premerges_layer(layer); if premerge { let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(self.retain(w)); @@ -1806,6 +1857,16 @@ pub fn mode_index(m: BlendMode) -> i32 { } } +/// Whether `Planner::content` draws `layer` as one `Planner::content_pass`: not a group's stack, +/// an adjustment, or a pattern fill painted in passes. +fn single_content_pass(layer: &Layer) -> bool { + match &layer.content { + LayerContent::Group(_) | LayerContent::Adjustment(_) => false, + LayerContent::Fill(f @ Fill::Pattern { .. }) => layer.fill_cache.as_ref().is_some_and(|c| c.fill == *f), + _ => true, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1946,15 +2007,15 @@ mod tests { d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format())); } let p = plan(&d).unwrap(); - // Empty layers draw nothing. - assert_eq!(p.passes.len(), 1 + 2); + // Empty layers draw nothing: the clear and the background's one fused pass. + assert_eq!(p.passes.len(), 1 + 1); for (i, l) in d.layers.iter_mut().skip(1).enumerate() { l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]); } let p = plan(&d).unwrap(); assert!(p.slots <= 3, "{} slots", p.slots); - // The background, then each small layer over its bounds and copied back. - assert_eq!(p.passes.len(), 1 + 2 + 10 * 3); + // The background, then each small layer blended over its bounds and copied back. + assert_eq!(p.passes.len(), 1 + 1 + 10 * 2); } #[test] @@ -1989,11 +2050,44 @@ mod tests { assert_eq!(p.fx.len(), 1); let m = photocraft_compose::effects::margin(&d.layers[1]); assert_eq!(p.fx[0].region, Rect::new(2, 2, 5, 5).inflate(m).intersect(&d.bounds().inflate(m))); - // The effect result lands back in the backdrop slot. + // The effect region covers the whole canvas: the merged result is the backdrop, no copy. + assert!(p.fx[0].region.contains_rect(&d.bounds())); let last = p.passes.last().unwrap(); - assert_eq!(last.kernel, Kernel::CopyRect); + assert_eq!(last.kernel, Kernel::FxMerge); assert_eq!(last.dst, p.root); assert!(p.slots <= 6, "{} slots", p.slots); + // On a larger canvas the result lands back in the backdrop slot over the region only. + let mut big = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE); + big.layers.push(d.layers[1].clone()); + let p = plan(&big).unwrap(); + assert!(!p.fx[0].region.contains_rect(&big.bounds())); + let last = p.passes.last().unwrap(); + assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(p.fx[0].region))); + } + + #[test] + fn exterior_effects_merge_from_the_shape() { + // A drop shadow alone: the layer's pixels, the shadow, the merge. With an interior effect + // the layer is built in passes of its own (colour inside the shape, the overlay, alpha). + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut l = Layer::raster("fx", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 0.5]); + l.fill_opacity = 0.7; + l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + d.layers.push(l); + let p = plan(&d).unwrap(); + let fx: Vec = p.passes.iter().skip(2).map(|p| p.kernel).collect(); + assert_eq!(fx, [Kernel::Content, Kernel::FxPaint, Kernel::FxMerge], "{:?}", p.passes); + let merge = p.passes.last().unwrap(); + assert!(merge.flags & F_LAYER_SHAPE != 0); + assert_eq!(merge.params[0][0], 0.7); + d.layers[1] + .effects + .items + .push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::WHITE }); + let p = plan(&d).unwrap(); + assert!(p.passes.iter().all(|p| p.flags & F_LAYER_SHAPE == 0)); + assert!(p.passes.iter().any(|p| p.kernel == Kernel::FxInit)); } #[test] diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..b71d9ca52e 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1220,21 +1220,103 @@ fn small_layers_composite_over_their_bounds() { fn many_layers_submit_inside_one_chunk() { // P12: a refresh recorded every pass of a page cell into one command buffer, and with a few // hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is - // invalid"). 700 layers need well over 1000 passes in the document's only chunk. + // invalid"). 1200 layers need well over 1000 passes in the document's only chunk: each is + // masked, so it is drawn in a pass of its own rather than batched with its neighbours. let Some(mut g) = gpu() else { return }; let mut d = base_doc(64, 48); let modes: Vec = BlendMode::layer_modes().collect(); - for (i, mode) in modes.iter().copied().cycle().take(700).enumerate() { + for (i, mode) in modes.iter().copied().cycle().take(1200).enumerate() { let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0); l.blend = mode; l.opacity = 0.9; + l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0)); d.layers.push(l); } - let stats = diff_rect(&mut g, &d, d.bounds(), "700 layers").unwrap_or_else(|e| panic!("{e}")); + let stats = diff_rect(&mut g, &d, d.bounds(), "1200 layers").unwrap_or_else(|e| panic!("{e}")); + assert_eq!(stats.layers_batched, 0, "{stats:?}"); assert_eq!(stats.chunks, 1); assert!(stats.flushes > 0, "{stats:?}"); } +#[test] +fn runs_of_plain_layers_blend_in_one_pass() { + // Consecutive plain layers are drawn several to a pass (`fs_layerbatch`). Every layer mode at + // varied opacity and fill (the fill-special modes included), solid fills, layers that end + // inside the canvas, and the layers that end a run: masked, Dissolve, small (clipped to their + // bounds) and clipping groups. Then an edit in the middle, so the prefix cache resumes + // part-way up the stack and runs start at its checkpoint. + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(96, 64); + let modes: Vec = BlendMode::layer_modes().collect(); + for (i, mode) in modes.iter().copied().cycle().take(90).enumerate() { + let i32_ = i as i32; + let rect = if i % 5 == 2 { Rect::from_xywh(-8, 10, 70, 40) } else { Rect::from_xywh(0, 0, 96, 64) }; + let mut l = noise_layer("l", PixelFormat::RGBA8, rect, 900 + i as u32, 0.0); + l.blend = mode; + l.opacity = 0.35 + 0.05 * (i % 13) as f32; + l.fill_opacity = if i % 4 == 1 { 0.6 } else { 1.0 }; + if i % 11 == 7 { + l.mask = Some(mask(Rect::from_xywh(i32_ % 30, 0, 50, 64), 70 + i as u32, 0.0)); + } + if i % 23 == 9 { + l.clipped = true; + } + d.layers.push(l); + if i % 9 == 4 { + let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.3, 0.6, 0.2, 0.4)))); + f.blend = modes[i % modes.len()]; + d.layers.push(f); + } + if i % 17 == 8 { + d.layers.push(noise_layer("small", PixelFormat::RGBA8, Rect::from_xywh(20, 20, 6, 5), 40 + i as u32, 0.5)); + } + } + let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs").unwrap_or_else(|e| panic!("{e}")); + assert!(stats.layers_batched > 0, "no layers were batched: {stats:?}"); + let mid = d.layers.len() / 2; + d.layers[mid].opacity = 0.5; + let stats = diff_rect(&mut g, &d, d.bounds(), "plain layer runs, edited in the middle").unwrap_or_else(|e| panic!("{e}")); + assert!(stats.layers_batched > 0, "{stats:?}"); +} + +#[test] +fn runs_of_normal_layers_blend_with_normal_code_alone() { + // Runs whose layers all blend Normal draw with `fs_layerbatch_normal`: varied opacity and fill + // over a partly transparent backdrop, masked layers leading runs, solid fills and layers that + // end inside the canvas, at every depth. Lab documents mix Normal in CIELAB, so their runs + // keep the full shader. + let Some(mut g) = gpu() else { return }; + for (mode, depth) in + [(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Rgb, SampleType::F32), (ColorMode::Lab, SampleType::U8)] + { + let mut d = Document::new("normal", Size::new(96, 64), mode, depth); + let fmt = d.pixel_format(); + d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 96, 64), 5, 0.0)); + for i in 0..40u32 { + let rect = if i % 5 == 3 { Rect::from_xywh(-6, 9, 70, 44) } else { Rect::from_xywh(0, 0, 96, 64) }; + let mut l = noise_layer("n", fmt, rect, 600 + i, if i % 3 == 0 { 0.0 } else { 0.4 }); + l.opacity = 0.2 + 0.07 * (i % 11) as f32; + l.fill_opacity = [1.0, 0.55, 0.0, 0.8][i as usize % 4]; + if i % 7 == 2 { + l.mask = Some(mask(Rect::from_xywh(i as i32 % 25, 0, 60, 64), 50 + i, 0.0)); + } + d.layers.push(l); + if i % 6 == 4 { + let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.7, 0.2, 0.5, 0.6)))); + f.opacity = 0.45; + d.layers.push(f); + } + } + let what = format!("normal runs {mode:?} {depth:?}"); + let stats = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}")); + if mode == ColorMode::Lab { + assert!(stats.layers_batched > 0 && stats.normal_batches == 0, "{what}: {stats:?}"); + } else { + assert!(stats.normal_batches > 0, "{what}: {stats:?}"); + } + } +} + fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer { let mut l = Layer::raster(name, fmt); let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32); @@ -1570,6 +1652,60 @@ fn layer_effects_every_kind() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +#[test] +fn exterior_effects_merge_from_the_shape_at_any_fill() { + // Drop shadows and outer glows alone: the merge builds the layer from its shape (fill inside + // it, times its alpha) instead of passes of its own. Fill from 0 to 1, opacity, modes + // (including one that blends with the knocked-out shadow), both depths, a layer covering the + // canvas (no copy back), and the layer clipped onto a base (merged atop it). + let Some(mut g) = gpu() else { return }; + let mut ds = shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0); + let mut ds_through = shadow(BlendMode::Normal, 0.9, 45.0, 4.0, 5.0, 0.2); + ds_through.knocks_out = false; + let og = glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge); + ds.use_global_light = true; + let stacks = [ + ("drop shadow", vec![Effect::DropShadow(ds.clone())]), + ("drop shadow, not knocked out", vec![Effect::DropShadow(ds_through)]), + ("outer glow", vec![Effect::OuterGlow(og.clone())]), + ("shadow and glow", vec![Effect::OuterGlow(og), Effect::DropShadow(ds)]), + ]; + for depth in [SampleType::U8, SampleType::U16] { + for (name, fx) in &stacks { + for (opacity, fill, blend) in [ + (1.0, 1.0, BlendMode::Normal), + (0.8, 0.6, BlendMode::Normal), + (1.0, 0.0, BlendMode::Normal), + (0.7, 0.35, BlendMode::Multiply), + (1.0, 0.5, BlendMode::Screen), + ] { + let what = format!("{name} {depth:?} opacity {opacity} fill {fill} {blend:?}"); + let mut d = fx_doc(96, 80, depth); + let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]); + l.effects.items = fx.clone(); + (l.opacity, l.fill_opacity, l.blend) = (opacity, fill, blend); + d.layers.push(l.clone()); + fx_check(&mut g, &d, &what); + // The same layer clipped onto a base. + let mut base = blob("base", d.pixel_format(), 40.0, 44.0, 30.0, [0.9, 0.8, 0.3]); + base.effects.items.clear(); + d.layers.pop(); + d.layers.push(base); + l.clipped = true; + d.layers.push(l.clone()); + fx_check(&mut g, &d, &format!("{what}, clipped")); + // A partly transparent layer over the whole canvas. + let mut d = fx_doc(64, 48, depth); + let mut full = noise_layer("full", d.pixel_format(), Rect::from_xywh(0, 0, 64, 48), 7, 0.2); + full.effects.items = fx.clone(); + (full.opacity, full.fill_opacity, full.blend) = (opacity, fill, blend); + d.layers.push(full); + fx_check(&mut g, &d, &format!("{what}, full canvas")); + } + } + } +} + #[test] fn layer_effects_opacity_fill_blend_and_off_canvas() { let Some(mut g) = gpu() else { return }; diff --git a/crates/gpu/tests/uniform_limits.rs b/crates/gpu/tests/uniform_limits.rs index aea8fb6b12..994dbd7508 100644 --- a/crates/gpu/tests/uniform_limits.rs +++ b/crates/gpu/tests/uniform_limits.rs @@ -59,7 +59,7 @@ fn a_deep_stack_above_the_device_buffer_limit_returns_an_error_without_panicking let Some((adapter, device, queue)) = constrained_device() else { return }; let mut compositor = compositor(&adapter, &device); healthy_control(&mut compositor, &device, &queue); - let deep = fills(150); + let deep = fills(300); eprintln!( "uniform-limit reproduction: backend={:?}, buffer limit={}, page={}, passes={}", adapter.get_info().backend, @@ -80,7 +80,7 @@ fn an_oversized_stack_falls_back_without_faulting_the_watched_device() { let mut compositor = compositor(&adapter, &device); compositor.set_health(health.clone()); healthy_control(&mut compositor, &device, &queue); - let deep = fills(150); + let deep = fills(300); let fallback = match render_to_vec(&mut compositor, &device, &queue, &deep, deep.bounds()) { Ok(_) => panic!("the oversized stack must return Unsupported for CPU fallback"), Err(_) => photocraft_compose::flatten(&deep), @@ -104,7 +104,8 @@ fn the_exact_buffer_limit_still_renders_with_cpu_parity() { let health = DeviceHealth::watch(&device); let mut compositor = compositor(&adapter, &device); compositor.set_health(health.clone()); - let boundary = fills(127); + // Every fill layer is one pass (`Kernel::LayerBlend`) after the clear. + let boundary = fills(254); assert_eq!(plan(&boundary).unwrap().passes.len(), 255, "one chunk plus255passes uses exactly65536bytes"); let expected = photocraft_compose::flatten(&boundary); let actual = render_to_vec(&mut compositor, &device, &queue, &boundary, boundary.bounds()).expect("the exact supported buffer boundary is accepted"); @@ -121,7 +122,7 @@ fn later_oversized_page_cells_are_rejected_before_render_or_encode_calls_the_sin let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((adapter, device, queue)) = constrained_device() else { return }; let health = DeviceHealth::watch(&device); - let doc = fills_size(126, Size::new(4096, 2048)); + let doc = fills_size(252, Size::new(4096, 2048)); let region = photocraft_geom::Rect::new(2047, 0, 4096, 2048); assert_eq!(plan(&doc).unwrap().passes.len(), 253); // Default pages are2048, chunks1024: the narrow first page has2chunks and fits; From 92c4457b16f2c30206b9fde9042d72008348f927 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 18 Oct 2026 14:33:13 -0600 Subject: [PATCH 146/304] Preferences: the settings fill the resized dialog, buttons at the bottom (#7420) (#7462) * Preferences: the settings fill the resized dialog, buttons at the bottom (#7420) #5170 made Preferences resizable, but its contents still sized themselves from the app window (width capped at 1040, settings area at 680 tall), and the dialog opened at a 960x900 default taller than that. So it opened with empty space below the buttons, and enlarging it left the settings and the buttons stuck in the top-left corner. The settings now take their width and height from the resizable area, leaving room for the button row, and Preferences opens at 1040x800. The settings area stays a few points short of the space it is given: egui grows the area to fit its contents, so asking for more would make the dialog grow every frame. * Preferences: a rendered test drags the resize grip and checks the buttons stay in the corner (#7420) Opens Preferences in a kittest harness, drags its grip wider and then smaller, and checks the button row stays in the frame's bottom-right corner each time and that the dialog keeps its size afterwards. Fails with the sizing from before the fix. * Preferences resize test: the buttons must also stay inside the frame A button row past the frame's bottom or right edge gave a negative gap and passed; now each gap must be 0 to 24 pt. --- crates/ui-egui/src/dialogs.rs | 96 ++++++++++++++++++++++++++++------ crates/ui-egui/src/prefs_ui.rs | 40 ++++++++++++-- crates/ui-egui/src/widgets.rs | 7 ++- 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43b4f3d3f9..81e04e570c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -104,15 +104,22 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l))) } -fn resizable_dialog(fields: &serde_json::Map) -> bool { - fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields) +/// The size a resizable dialog opens at, or `None` for a dialog sized by its contents. +fn resizable_dialog(fields: &serde_json::Map) -> Option { + if crate::prefs_ui::is_preferences(fields) { + Some(crate::prefs_ui::PREFERENCES_SIZE) + } else if fields.contains_key("__web") { + Some(egui::vec2(960.0, 900.0)) + } else { + None + } } fn show_modal( modal: egui::Modal, ctx: &egui::Context, id: egui::Id, - resizable: bool, + resizable: Option, interactive: bool, contents: impl FnOnce(&mut egui::Ui) -> T, ) -> egui::ModalResponse { @@ -121,14 +128,9 @@ fn show_modal( if !interactive { ui.disable(); } - if resizable { + if let Some(size) = 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) + egui::Resize::default().id(id.with("resize")).default_size(size.min(max)).min_size(egui::vec2(560.0, 400.0)).max_size(max).show(ui, contents) } else { contents(ui) } @@ -207,7 +209,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.contains_key("__web"); let web_popup_open = web && egui::Popup::is_any_open(ctx); - let modal = show_modal(modal, ctx, id, resizable_dialog(&fields), interactive, |ui| { + let resizable = resizable_dialog(&fields); + let modal = show_modal(modal, ctx, id, resizable, interactive, |ui| { sizing = ui.is_sizing_pass(); let available = ui.available_size(); // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title @@ -218,7 +221,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if !compact { ui.set_min_width(if curves { 300.0 } else { 380.0 }); } - let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); + // A resizable dialog's contents fill its resizable area (#7420), the others the window. + let room = if resizable.is_some() { available.x } else { (ctx.content_rect().width() - 48.0).max(380.0) }; + let wide = crate::prefs_ui::width(&d.fields, room); if let Some(w) = wide { ui.set_min_width(w.min(460.0)); } @@ -1132,9 +1137,68 @@ mod preference_resize_tests { let ordinary = json!({}); let web = json!({"__web": true}); - assert!(resizable_dialog(prefs.as_object().unwrap())); - assert!(!resizable_dialog(shortcuts.as_object().unwrap())); - assert!(!resizable_dialog(ordinary.as_object().unwrap())); - assert!(resizable_dialog(web.as_object().unwrap())); + assert_eq!(resizable_dialog(prefs.as_object().unwrap()), Some(crate::prefs_ui::PREFERENCES_SIZE)); + assert_eq!(resizable_dialog(shortcuts.as_object().unwrap()), None); + assert_eq!(resizable_dialog(ordinary.as_object().unwrap()), None); + assert_eq!(resizable_dialog(web.as_object().unwrap()), Some(egui::vec2(960.0, 900.0))); + } + + /// Drags the dialog's resize grip (its frame's bottom-right corner) by `by`. + fn drag_corner(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, by: egui::Vec2) { + let corner = frame(h).max - egui::vec2(14.0, 14.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerMoved(corner + by)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner + by, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + } + + fn frame(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Rect { + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap(); + rects[0] + } + + /// The button row sits in the frame's bottom-right corner (its order follows the platform): + /// the contents fill the dialog, no empty space. + fn assert_buttons_in_corner(h: &egui_kittest::Harness<'_, PhotocraftApp>, when: &str) { + use egui_kittest::kittest::Queryable; + let f = frame(h); + let row = ["OK", "Cancel", "Apply"].map(|l| h.get_by_label(l).rect()).into_iter().reduce(|a, b| a.union(b)).unwrap(); + // Inside the frame (not past its edge) and near it. + let near = |gap: f32| (0.0..24.0).contains(&gap); + assert!(near(f.bottom() - row.bottom()) && near(f.right() - row.right()), "{when}: frame {f:?}, buttons {row:?}"); + } + + #[test] + fn preferences_contents_follow_the_dialog_when_it_is_resized() { + // #7420: the dialog resized but its contents kept a size from the window, leaving the + // buttons part-way up a dialog that opened, or was dragged, larger than them. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + crate::prefs_ui::open_preferences(&mut app, "interface"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + assert_buttons_in_corner(&h, "on open"); + let opened = frame(&h); + drag_corner(&mut h, egui::vec2(220.0, 80.0)); + assert!(frame(&h).width() > opened.width() + 150.0 && frame(&h).height() > opened.height() + 50.0, "wider: {:?}", frame(&h)); + assert_buttons_in_corner(&h, "wider"); + drag_corner(&mut h, egui::vec2(-500.0, -300.0)); + let smaller = frame(&h); + assert!(smaller.width() < opened.width() - 200.0 && smaller.height() < opened.height() - 150.0, "smaller: {smaller:?}"); + assert_buttons_in_corner(&h, "smaller"); + // The contents never ask for more than the dialog gives them, so it keeps its size. + h.run_steps(10); + assert_eq!(frame(&h), smaller); } } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 6c910865bd..7213407024 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -968,10 +968,22 @@ pub fn apply(app: &mut PhotocraftApp, id: u64) -> Result { Ok(result) } -/// Max dialog width for our dialogs. +/// The size Preferences opens at: room for the widest section and about 30 settings rows. The +/// dialog's resize grip changes it, and its contents follow (#7420). +pub const PREFERENCES_SIZE: egui::Vec2 = egui::vec2(1040.0, 800.0); + +/// The height of the settings area (section list and fields) when `available` is left below the +/// search field: everything but the dialog's button row (`item_spacing` + 8 pt + the buttons) and +/// a little slack, never less than a few rows. +fn settings_height(available: f32, item_spacing: f32, button_height: f32) -> f32 { + (available - (item_spacing + 8.0 + button_height + 4.0)).max(160.0) +} + +/// Max dialog width for our dialogs. Preferences fills the width it is given (`available`, its +/// resizable area). pub fn width(fields: &Map, available: f32) -> Option { match fields.get("__prefsui").and_then(Value::as_str)? { - "prefs" => Some(available.clamp(380.0, 1040.0)), + "prefs" => Some(available.max(380.0)), "shortcuts" => Some(720.0), _ => Some(460.0), } @@ -1246,7 +1258,10 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option= available, "{available}"); + } + assert_eq!(super::settings_height(1400.0, 4.0, 30.0) - super::settings_height(600.0, 4.0, 30.0), 800.0); + assert_eq!(super::settings_height(50.0, 4.0, 30.0), 160.0); + } use super::*; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 533f1ee052..b393c0787d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1276,9 +1276,14 @@ fn button_size(ui: &Ui, label: &str, min_width: f32) -> Vec2 { button_layout(ui, label, min_width, None, Tokens::get(ui.ctx()).text).1 } +/// The height of a primary or secondary button (and so of a dialog's button row). +pub fn button_height(ctx: &egui::Context) -> f32 { + if Tokens::get(ctx).pro { 28.0 } else { 30.0 } +} + fn button_layout(ui: &Ui, label: &str, min_width: f32, height: Option, fg: Color32) -> (std::sync::Arc, Vec2) { let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), theme::medium(13.0), fg); - let h = height.unwrap_or(if Tokens::get(ui.ctx()).pro { 28.0 } else { 30.0 }); + let h = height.unwrap_or(button_height(ui.ctx())); let size = vec2((galley.size().x + 28.0).max(min_width), h); (galley, size) } From 617264a124b409926755c1766f5049f5889d0d4b Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 18 Oct 2026 13:55:54 -0700 Subject: [PATCH 147/304] Tests: the slow-job frame loop test waits for the worker's progress message (#7424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it stepped frames until 400 ms of wall time and 10 frames had passed, and then looked for the job's status message, "Filtering tiles", in the progress dialog. The job's worker reports that message as its first statement, on a thread of its own. The dialog appears after 250 ms whether or not the worker has run, and it reads "Working…" until the message arrives. On a loaded machine the worker can start after the 400 ms window, and the test then fails with "the job's status message". The test now steps at least 10 frames and until the dialog shows "Filtering tiles", through a new helper, frames_until, which fails the test when the condition does not hold within 60 s. It also checks that the job is still running at that point. The helper has the same signature as the frames_until in #3422. --- crates/ui-egui/src/jobs_ui_tests.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f2b7d0fbc0..70ce973be0 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -84,6 +84,21 @@ fn frames_for_at_least(h: &mut Harness<'_, PhotocraftApp>, ms: u64, min_frames: (n, worst) } +/// Step at least `min` frames and until `ready` holds; returns (frames, slowest frame in ms). +/// The test fails when `ready` still does not hold after 60 s. +fn frames_until(h: &mut Harness<'_, PhotocraftApp>, min: u64, ready: impl Fn(&Harness<'_, PhotocraftApp>) -> bool) -> (u64, f64) { + let t = Instant::now(); + let (mut n, mut worst) = (0, 0.0f64); + while n < min || !ready(h) { + assert!(t.elapsed() < Duration::from_secs(60), "not ready after {n} frames"); + let f = Instant::now(); + h.step(); + worst = worst.max(f.elapsed().as_secs_f64() * 1000.0); + n += 1; + } + (n, worst) +} + #[test] fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let mut h = app_harness(); @@ -91,7 +106,9 @@ fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let doc_before = h.state().session.active().unwrap().doc.clone(); let job = slow_job(h.state_mut(), &gate); let frame0 = h.state().frame; - let (n, worst) = frames_for_at_least(&mut h, 400, 10); + // The worker thread reports "Filtering tiles" when it starts, which can be after the dialog appears. + let (n, worst) = frames_until(&mut h, 10, |h| h.query_by_label("Filtering tiles").is_some()); + assert!(h.state().session.job(job).is_some(), "the job is still running"); assert!(h.state().frame - frame0 >= 10, "the UI kept rendering ({n} frames)"); eprintln!("{n} frames during the job, slowest {worst:.1} ms"); // The status bar shows the job, and after the delay the modal progress dialog. From 89b9e72640d92c7d6cb0912c84a1967d4e8c7df4 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 18 Oct 2026 13:55:59 -0700 Subject: [PATCH 148/304] Tests: the authentication deadline test writes its first line before the deadline starts (#7428) an_authenticated_connection_outlives_the_authentication_deadline started its 200 ms deadline after it spawned the thread that writes the first line. When the OS started that thread late, the first read failed with TimedOut at crates/automation/src/security.rs:447. On a loaded machine the photocraft-automation lib tests failed 5 of 30 runs this way. The test thread now writes the first line before the deadline starts, and the limit is 1 s. The sender thread writes the second line after the test thread has called authenticated() and 300 ms after the deadline, so the second read still waits past the deadline. The test still fails when authenticated() is not called. The product's AUTH_DEADLINE is unchanged. --- crates/automation/src/security.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 89a760b32a..0735511f20 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -480,16 +480,23 @@ mod tests { #[test] fn an_authenticated_connection_outlives_the_authentication_deadline() { let (server, mut client) = tcp_pair(); + // The test thread writes the first line before the deadline starts. The sender writes the + // second line once the connection has authenticated and the deadline has passed. + client.write_all(b"one\n").unwrap(); + let limit = Duration::from_secs(1); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit)); + let deadline = Instant::now() + limit; + let (tx, rx) = std::sync::mpsc::channel(); let sender = std::thread::spawn(move || { - client.write_all(b"one\n").unwrap(); - std::thread::sleep(Duration::from_millis(500)); + rx.recv().unwrap(); + std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now())); client.write_all(b"two\n").unwrap(); }); - let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); let mut line = String::new(); assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "one\n"); reader.get_mut().authenticated().unwrap(); + tx.send(()).unwrap(); // Sent 300 ms after the deadline: only the idle timeout applies now. assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); assert_eq!(line, "two\n"); From 9b4648e1952765b84a6af24178186500e02ad1f4 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:56:04 +0300 Subject: [PATCH 149/304] Font menu: hovering a font no longer grows its row and makes the list jump (#7406) (#7425) * fix(type): font menu rows keep their size when hovered or selected, so the list no longer jumps (#7406) * test(type): hovering a font row moves no row and keeps the menu width (#7406) --- crates/ui-egui/src/type_font_picker_tests.rs | 29 ++++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 6 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 3dfe34dedc..4aa25c750f 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -83,6 +83,35 @@ fn hovering_a_font_row_sets_preview_and_leaving_clears_it() { assert!(h.ctx.data(|d| d.get_temp::(hover_id)).is_none()); } +/// #7406: hovering a font highlights its row without moving anything: the other rows keep +/// their place, the menu its width, and every row (the selected one too) takes one height. +#[test] +fn hovering_a_font_row_moves_no_other_row() { + let mut h = harness(); + // The app's theme: its hovered and selected buttons draw a stroke (egui's default style + // would hide the jump). + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run(); + open(&mut h); + let rows = |h: &Harness<'static, State>| -> Vec { (0..8).map(|i| h.get_by_label(&format!("Family {i:02}")).rect()).collect() }; + let before = rows(&h); + let width = popup_rect(&h).expect("font menu open").width(); + let tops: Vec = before.windows(2).map(|w| w[1].top() - w[0].top()).collect(); + assert!(tops.windows(2).all(|t| (t[0] - t[1]).abs() < 0.01), "rows are evenly spaced, the selected Family 00 included: {tops:?}"); + for hovered in [2, 5] { + h.hover_at(before[hovered].center()); + h.run_steps(2); + let after = rows(&h); + for (i, (b, a)) in before.iter().zip(&after).enumerate() { + if i != hovered { + assert_eq!(b, a, "hovering Family {hovered:02} moved Family {i:02}"); + } + } + assert_eq!(after[hovered].center(), before[hovered].center(), "the hovered row stays centred where it was"); + assert_eq!(popup_rect(&h).map(|r| r.width()), Some(width), "the menu keeps its width"); + } +} + #[test] fn clearing_the_query_restores_the_menu_height() { let mut h = harness(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d9a4ae9928..29882cd7d6 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1517,7 +1517,11 @@ pub(crate) fn family_picker_in( let mut hovered: Option = None; for (f, label) in rows { - let mut button = egui::Button::selectable(f == current, label).truncate(); + // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays + // out its frame's stroke only once hovered or selected, so with the theme's stroke the + // row under the pointer (and the selected one) grew and pushed the rows below down: + // the list jumped as the pointer moved (#7406). + let mut button = egui::Button::selectable(f == current, label).truncate().stroke(egui::Stroke::NONE); if let Some((size, [_, h])) = preview { button = button.right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); From a4436074c90350360c23979a32b3c40719a8b97e Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:56:10 +0300 Subject: [PATCH 150/304] =?UTF-8?q?Font=20field:=20=E2=86=91/=E2=86=93=20s?= =?UTF-8?q?tep=20through=20the=20fonts=20after=20a=20pick,=20as=20in=20Pho?= =?UTF-8?q?toshop=20(#7407)=20(#7426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(type): ↑/↓ on the focused font field step through the families after a pick, as in Photoshop (#7407) * test(type): the font field keeps the focus after a pick and ↑/↓ step the families (#7407) --- crates/ui-egui/src/type_font_picker_tests.rs | 82 ++++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 36 +++++++++ 2 files changed, 118 insertions(+) diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 4aa25c750f..6f05ed61a5 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -239,6 +239,88 @@ fn arrows_apply_fonts_without_closing_and_enter_accepts() { assert!(popup_rect(&h).is_none()); } +/// #7407: as in Photoshop, the field keeps the focus once a font is picked (click or Enter), +/// and ↑ / ↓ on it step through the families with the menu closed. Escape ends it. +#[test] +fn arrows_on_the_field_step_through_the_families_after_a_pick() { + let mut h = harness(); + open(&mut h); + h.get_by_label("Family 03").click(); + h.run(); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none(), "picking closes the menu"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "the field keeps the focus"); + let press = |h: &mut Harness<'static, State>, key: Key| { + h.key_press(key); + h.run(); + assert!(popup_rect(h).is_none(), "{key:?} leaves the menu closed"); + assert!(h.ctx.memory(|m| m.has_focus(button)), "{key:?} keeps the focus on the field"); + h.state().current.clone() + }; + assert_eq!(press(&mut h, Key::ArrowDown), "Family 04"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 05"); + assert_eq!(press(&mut h, Key::ArrowUp), "Family 04"); + // Enter in the open menu is a pick too. + open(&mut h); + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 05"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 06"); + // Escape takes the focus away; the arrows then leave the font alone. + h.key_press(Key::Escape); + h.run(); + assert!(!h.ctx.memory(|m| m.has_focus(button))); + h.key_press(Key::ArrowDown); + h.run(); + assert_eq!(h.state().current, "Family 06"); +} + +/// The first arrow right after a pick (in the very next frame) steps the font and keeps the +/// focus on the field, also between focusable widgets: the field's lock filter only holds from +/// the frame after it got the focus, and egui would read that arrow as a move to "Below". +#[test] +fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() { + for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] { + let families: Vec = (0..20).map(|i| format!("Family {i:02}")).collect(); + let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off }; + let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( + |ui, s: &mut State| { + let _ = ui.button("Above"); + s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); + s.popup = s.button.with("popup"); + let fams = s.families.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let _ = ui.button("Below"); + }, + state, + ); + h.run(); + open(&mut h); + if enter { + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + } else { + h.get_by_label("Family 05").click(); + } + // Frame by frame up to the pick, then the arrow in the next frame. + for _ in 0..8 { + if h.state().current == picked && popup_rect(&h).is_none() { + break; + } + h.step(); + } + assert_eq!(h.state().current, picked, "enter: {enter}"); + h.key_press(Key::ArrowDown); + h.step(); + h.run(); + assert_eq!(h.state().current, stepped, "enter: {enter}"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "enter: {enter}: the arrow moved the focus off the field"); + } +} + #[test] fn arrows_follow_the_filtered_list_and_handle_no_matches() { let mut h = harness(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 29882cd7d6..325e5122b4 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1451,6 +1451,8 @@ pub(crate) fn family_picker_in( preview: photocraft_engine::prefs::FontPreview, ) -> bool { let mut changed = false; + // A font picked from the open menu (click or Enter): the field keeps the focus afterwards. + let mut picked = false; let search_id = ui.id().with(("font-search", salt)); let focus_id = search_id.with("focus"); // The list opens on the press, so a press-drag-release picks a font too (#2735). @@ -1508,6 +1510,7 @@ pub(crate) fn family_picker_in( } } let accept = enter && rows.iter().any(|(f, _)| *f == current); + picked |= accept; if accept { ui.data_mut(|d| d.remove::(search_id)); ui.close(); @@ -1543,6 +1546,7 @@ pub(crate) fn family_picker_in( // A served family not fetched yet: start now, before any text needs it. photocraft_text::served::request(f); changed = true; + picked = true; ui.data_mut(|d| d.remove::(search_id)); hovered = None; ui.close(); @@ -1565,6 +1569,38 @@ pub(crate) fn family_picker_in( } let hovered_font = response.inner.unwrap_or_default(); + // As in Photoshop, the field keeps the keyboard focus once a font is picked, and ↑ / ↓ on the + // focused, closed field step to the previous / next family, applying each (#7407). The lock + // filter keeps egui from using those arrows to move the focus to another widget. + let field = response.response.id; + if picked { + ui.memory_mut(|m| m.request_focus(field)); + } + let open = egui::ComboBox::is_open(ui.ctx(), field); + if !open && ui.is_enabled() && ui.memory(|m| m.has_focus(field)) { + ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); + let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); + let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); + if down || up { + // The lock filter only holds from the frame after the field got the focus: an arrow + // right after a pick was already read by egui as a focus move, applied at the end of + // the frame. Cancel it, so the arrow only steps the font. + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + // The wheel's step: a negative notch is the next family. + let notches = f32::from(u8::from(up)) - f32::from(u8::from(down)); + let rows = font_menu_rows(families, labels, ""); + if let Some(i) = rows.iter().position(|(f, _)| *f == current) + && let Some(n) = wheel_family_index(i, rows.len(), notches) + && let Some((f, _)) = rows.get(n) + && **f != *current + { + current.clone_from(f); + photocraft_text::served::request(f); + changed = true; + } + } + // The wheel over the closed button steps through the families, a notch each, like Photoshop's // options-bar font menu: no need to open the list (#3350). if !egui::ComboBox::is_open(ui.ctx(), response.response.id) && ui.is_enabled() { From ecc1a9e47bdf6ffe7d6ef3c03861dfa07979a351 Mon Sep 17 00:00:00 2001 From: driftee <48539371+driftee@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:56:15 +0800 Subject: [PATCH 151/304] fix(transform): detach clipped preview ancestors (#7438) --- crates/ui-egui/src/transform_tool.rs | 62 +++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d14046db9d..45ef0b1528 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -792,11 +792,14 @@ fn preview_image( (None, Some(s)) if layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) => Some(s), _ => { let mut solo = doc.clone(); - show_only(&mut solo.layers, &[id]); - // Its clipping base is hidden here, which would clip it away entirely (#5239). - // Clear the target itself, not only the top-level group that contains it. - if let Some(l) = solo.layer_mut(id) { - l.clipped = false; + if layer.is_some() { + show_preview_target(&mut solo.layers, id); + } else { + // Keep the previous fallback for an invalid id: render the document, with only + // top-level clipping disabled. Production callers get ids from this document. + for l in &mut solo.layers { + l.clipped = false; + } } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { l.effects.enabled = false; @@ -986,6 +989,24 @@ fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { } } +/// Leave only `id`, its descendants and the groups holding it visible. Every layer on that path +/// is detached from its clipping base because the base is hidden in this isolated render (#5239). +fn show_preview_target(layers: &mut [photocraft_doc::Layer], id: LayerId) { + for l in layers { + if l.id == id { + l.clipped = false; + continue; + } + match &mut l.content { + LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)) => { + l.clipped = false; + show_preview_target(&mut g.children, id); + } + _ => l.visible = false, + } + } +} + fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } @@ -4768,6 +4789,37 @@ mod tests { ); } + #[test] + fn nested_group_preview_detaches_every_clipped_ancestor() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let target_pixels = doc.layers.pop().unwrap(); + let target = Layer::group("target", vec![target_pixels]); + let target_id = target.id; + let mut middle = Layer::group("middle clipped ancestor", vec![target]); + middle.clipped = true; + let mut inner_base = Layer::raster("hidden inner clipping base", PixelFormat::RGBA8); + inner_base.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + let mut outer = Layer::group("outer clipped ancestor", vec![inner_base, middle]); + outer.clipped = true; + doc.layers.push(outer); + + let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0), "hidden clipping bases must not make a nested target disappear"); + } + + #[test] + fn unknown_preview_target_keeps_the_previous_document_fallback() { + let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active().unwrap(); + let (image, _) = preview_image(&st.doc, LayerId::fresh(), None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0)); + } + /// The textured meshes `draw_content` paints for the moving pixels (not the box or handles). fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec { let ctx = gpu_ctx(); From 502d055c6c8cb5d86850d02bee32a2ff1da1e400 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 02:26:21 +0530 Subject: [PATCH 152/304] file.saveACopy refuses the document's own file without "overwrite" (#7437) (#7456) * file.saveACopy refuses the document's own file without "overwrite" (#7437) Save a Copy through the engine (control channel, MCP, serve, headless action playback) silently replaced the open document's own file when given its path, so a lossy re-encode could not be undone. It now returns BadParams when `path` is the active document's file (same text, ./ or .. spelling, hard link, case on Windows via same_file) unless the params pass "overwrite": true, and nothing is written. The desktop dialog writes through the shell after the OS confirms a replace, so it is unchanged. Co-Authored-By: Claude Opus 5.5 * same_file: compare file identity on Windows, so a hard link is the same file On Windows `same_file` compared canonical path strings, so a hard link to the input (another name for the same file) passed the new convert guard without --in-place. It now compares the volume serial number and file index (the `same-file` crate, already in the dependency tree; std's file_index is unstable), as unix compares dev and inode, falling back to the case-insensitive canonical path when a file won't open. The engine's same_file test now checks hard links on every platform. (Review on #7454.) Co-Authored-By: Claude Opus 5.5 * saveACopy own-file test: hard link on every platform, doc state unchanged after a refusal Each refused copy (same path, ./ spelling, hard link) now also checks the document keeps its path, revision and unsaved state. The hard link is no longer unix-only: same_file compares file identity on Windows too. (Review on #7456.) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/Cargo.toml | 5 ++++ crates/engine/src/file_cmds.rs | 18 +++++++++++-- crates/engine/src/file_cmds/tests.rs | 38 ++++++++++++++++++++++++++++ crates/engine/src/web_cmds/tests.rs | 7 ++--- 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26920a968e..132863df32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4812,6 +4812,7 @@ dependencies = [ "photocraft-vector", "rayon", "roxmltree", + "same-file", "serde", "serde_json", "tempfile", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 65dd1e16e0..fb3bc3fbec 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,11 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a +# file is that file (std's `MetadataExt::file_index` is unstable). +[target.'cfg(windows)'.dependencies] +same-file = "1" + # Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] photocraft-ipp = { workspace = true } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index f99aefcff0..1fb3fcf300 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -234,7 +234,12 @@ pub fn same_file(a: &str, b: &str) -> bool { } #[cfg(windows)] { - // NTFS and FAT compare names without case. + // The volume serial number and file index of each, as unix compares dev and inode, so a + // hard link (another name for the file) is the same file. + if let Ok(same) = same_file::is_same_file(a, b) { + return same; + } + // Should a file not open: NTFS and FAT compare names without case. if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(); } @@ -481,6 +486,15 @@ fn revert(s: &mut Session) -> Result { fn save_a_copy(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.saveACopy")?.to_string(); let d = s.active().ok_or(EngineError::NoDocument)?; + // A copy over the document's own file replaces it, and a flat or lossy format can't be + // undone, so it takes an explicit `overwrite` (#7437). The desktop dialog writes through the + // shell, after the OS has confirmed the replace. + if d.path.as_deref().is_some_and(|own| same_file(&path, own)) && p.get("overwrite").and_then(Value::as_bool) != Some(true) { + return Err(EngineError::BadParams { + cmd: "file.saveACopy".into(), + msg: "`path` is the document's own file, so the copy would replace it: choose another path, or pass \"overwrite\": true".into(), + }); + } let mut doc = (*d.doc).clone(); if p.get("layers").and_then(Value::as_bool) == Some(false) { // "Layers" unchecked in Save a Copy: write the flattened image. @@ -1639,7 +1653,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 624edfdd3f..3ab50c4a77 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -135,6 +135,44 @@ fn save_a_copy_keeps_path_and_dirty_state() { assert!(s.execute("file.saveACopy", json!({})).is_err()); } +/// A copy over the document's own file, however the path is spelt, would replace it (a lossy +/// re-encode can't be undone), so it is refused unless `overwrite` is passed (#7437). +#[test] +fn save_a_copy_refuses_the_documents_own_file() { + let dir = tmp("copy-own"); + let path = png(&dir, "own.png", 6, 4, "#ff0000"); + let bytes = std::fs::read(&path).unwrap(); + let mut s = Session::new(); + open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let link = join(&dir, "link.png"); + std::fs::hard_link(&path, &link).unwrap(); + let rev = s.active().unwrap().revision; + for p in [path.clone(), format!("{dir}/./own.png"), link] { + let r = s.execute("file.saveACopy", json!({"path": p})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("overwrite")), "{p}: {r:?}"); + assert!(s.execute("file.saveACopy", json!({"path": p, "overwrite": false})).is_err(), "{p}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "{p}"); + let st = s.active().unwrap(); + assert_eq!(st.path.as_deref(), Some(path.as_str()), "{p}"); + assert_eq!(st.revision, rev, "{p}"); + assert!(st.is_dirty(), "{p}"); + } + let other = join(&dir, "other.png"); + s.execute("file.saveACopy", json!({"path": other})).unwrap(); + assert!(std::fs::metadata(&other).unwrap().len() > 0); + s.execute("file.saveACopy", json!({"path": path, "overwrite": true})).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(s.active().unwrap().path.as_deref(), Some(path.as_str())); + assert!(s.active().unwrap().is_dirty()); + + // A document that was never saved has no file of its own: it copies anywhere, as before. + let mut fresh = session(4, 4, 8); + fresh.execute("file.saveACopy", json!({"path": path})).unwrap(); + assert_eq!(fresh.active().unwrap().path, None); +} + /// `tgaBits` picks the Targa's bits per pixel (byte 16 of the header); unset, a document with an /// alpha channel is written as 32-bit, with the channel as the alpha. #[test] diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 594fe8d496..bd55a549cb 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -476,11 +476,8 @@ fn same_file_sees_through_other_names_for_one_file() { assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); assert!(!crate::file_cmds::same_file(&a, &c)); assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); - #[cfg(unix)] - { - std::fs::hard_link(&a, &b).unwrap(); - assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); - } + std::fs::hard_link(&a, &b).unwrap(); + assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); #[cfg(windows)] assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); } From 140230b1414ed723bda32a330c87ebcf6fe06845 Mon Sep 17 00:00:00 2001 From: Hodd Date: Sun, 18 Oct 2026 22:56:26 +0200 Subject: [PATCH 153/304] Replacing selected type keeps its size, as in Photoshop (#7457) Typing or pasting over a selection now takes the style of the first selected character instead of the one before it, so retyping a smaller second line no longer takes the first line's size from the line break. Fixes #7413. --- crates/engine/src/type_cmds.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 5854be5cbf..712cc3c89d 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -504,8 +504,9 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P t.paragraphs = out.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect(); } -/// Replaces bytes `a..b` with `new`, keeping styles: inserted text takes the style of the -/// character before it (or after it at the start). +/// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its +/// first character (as Photoshop does, so retyping a smaller second line stays small); inserting +/// at a caret takes the style of the character before it (or after it at the start). pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); @@ -521,7 +522,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { *acc += r.0; Some((s, s + r.0)) }) - .position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e }) + .position(|(s, e)| { + if a == 0 { + e > 0 || s == 0 + } else if a < b { + a >= s && a < e + } else { + a > s && a <= e + } + }) .unwrap_or(0) }; for (i, (len, st)) in runs.into_iter().enumerate() { @@ -1547,6 +1556,25 @@ mod tests { assert_eq!(t.runs[0].style.size_pt, 40.0); } + /// Replacing a selected smaller second line (by typing or pasting) keeps its size, not the + /// first line's, whose line break precedes the selection. + #[test] + fn replacing_a_selection_keeps_its_first_characters_style() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "BIG\nsmall", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": id, "runs": [{"start": 4, "end": 9, "size": 12}]})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 9, "text": "AIR"}})).unwrap(); + let sizes = |s: &Session| text_layer(s, id).char_runs().iter().map(|r| (r.len, r.style.size_pt)).collect::>(); + assert_eq!(text_layer(&s, id).text, "BIG\nAIR"); + assert_eq!(sizes(&s), [(4, 40.0), (3, 12.0)]); + // A mixed selection takes its first character's style. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 6, "text": "xy"}})).unwrap(); + assert_eq!(sizes(&s), [(4, 40.0), (1, 12.0)]); + // Inserting at a caret still follows the character before it. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 4, "text": "!"}})).unwrap(); + assert_eq!(sizes(&s), [(5, 40.0), (1, 12.0)]); + } + fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> { let t = text_layer(s, id); let mut out = Vec::new(); From 8ea77bdfd6495542efe0a7db4206b214c5e25c4c Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:56:33 +0700 Subject: [PATCH 154/304] Fade: restore uniformly inverted layer masks (#7383) (#7463) * fix(fade): detect uniform mask changes and blend full canvas * test(fade): cover uniform mask inversion and Fade across bit depths * style(fade): keep default-pixel comparison readable * Format edit_menu_cmds.rs with rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/edit_menu_cmds.rs | 6 +++-- crates/engine/src/edit_menu_cmds/tests.rs | 32 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index c5f312e2cb..6f86413d13 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -68,7 +68,7 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { let now = st.doc.layer(layer); let was = prev.layer(layer); let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { - (Some(a), Some(b)) => crate_fingerprint(a) == crate_fingerprint(b), + (Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b), (None, None) => true, _ => false, }; @@ -214,7 +214,9 @@ fn fade(s: &mut Session, p: &Value) -> Result { if now.format() != before.format() { return Err(EngineError::Other("can't fade a step that changed the layer's pixel format".into())); } - let area = before.content_bounds().union(&now.content_bounds()); + // A uniform mask can change every pixel without storing a single tile. In that case + // the content bounds are empty, but Fade must still blend across the canvas. + let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; let label = format!("Fade {}", src.label); s.edit(&label, |doc, _| { let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index ecbb8a7cda..873061e462 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -57,6 +57,38 @@ fn fade_with_blend_mode_and_invalidation() { assert!(s.execute("edit.fade", json!({"mode": "nonsense"})).is_err()); } +#[test] +fn fade_uniform_layer_mask_invert_at_all_depths() { + for depth in [8, 16, 32] { + for (opacity, expected) in [(0, 1.0), (50, 0.5), (100, 0.0)] { + let mut s = session(depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask_pixel = |s: &Session| { + let layer = active(s); + let mask = &layer.mask.as_ref().unwrap().surface; + mask.pixel(5, 5)[0] + }; + assert!((mask_pixel(&s) - 1.0).abs() < 0.01); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert!(mask_pixel(&s) < 0.01, "depth {depth}: invert changed the mask"); + assert!(s.edit_state.fade.as_ref().is_some_and(|f| f.mask), "depth {depth}: Fade must target the mask"); + let history_before_fade = s.active().unwrap().history.past_len(); + + s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + for (x, y) in [(0, 0), (5, 5), (63, 47)] { + let pixel = active(&s).mask.as_ref().unwrap().surface.pixel(x, y)[0]; + assert!((pixel - expected).abs() < 0.02, "depth {depth}, opacity {opacity}, ({x}, {y}): {pixel}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history_before_fade + 1); + assert!(s.undo()); + assert!(mask_pixel(&s) < 0.01, "undo restores inverted mask"); + assert!(s.redo()); + assert!((mask_pixel(&s) - expected).abs() < 0.02, "redo restores faded mask"); + } + } +} + #[test] fn purge_commands() { let mut s = session(8); From fdd4ca7cbcf33fdb2c20044134453fb24fdac015 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:56:39 +0700 Subject: [PATCH 155/304] Remove: cancel queued strokes when the paint target changes (#7380) (#7464) * fix(remove): cancel waiting strokes when their target changes * fix(remove): store pending target identity with waiting strokes * test(remove): cover queued strokes across layer and channel switches * Tests: format retouch_ui.rs and drop the unreachable! arm clippy denies Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +-- crates/ui-egui/src/retouch_ui.rs | 81 ++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c23a29e36..5948bd4edf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -605,9 +605,9 @@ pub struct PhotocraftApp { /// Footprint trail of a retouching drag (see `stroke_trail`). trail: Option, /// Remove tool strokes waiting to be applied together (Remove after each stroke off), the - /// document they belong to, and their tint on the canvas (`retouch_ui`). + /// document, layer and channel they belong to, and their tint on the canvas (`retouch_ui`). pub(crate) remove_pending: Vec>, - pub(crate) remove_pending_doc: Option, + pub(crate) remove_pending_target: Option, pub(crate) remove_trail: Option, /// The open menus' items, reused between frames (`menus::ItemCache`). pub(crate) menu_cache: Option, @@ -879,7 +879,7 @@ impl PhotocraftApp { committed_stroke: None, trail: None, remove_pending: Vec::new(), - remove_pending_doc: None, + remove_pending_target: None, remove_trail: None, menu_cache: None, move_preview: None, diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 417df92b6e..d79a5385e3 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -166,15 +166,31 @@ fn remove_params(o: &crate::state::ToolOptions) -> Value { json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer}) } +/// The selection a queued Remove stroke was drawn against. A later selection must not +/// redirect the queued strokes to a different layer or colour component. +#[derive(Clone, PartialEq)] +pub(crate) struct RemoveTarget { + doc: photocraft_doc::DocId, + layer: Option, + channel: photocraft_engine::channel_cmds::ChannelTarget, + paint: Value, +} + +fn remove_target(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(RemoveTarget { doc: st.doc.id, layer: st.active_layer, channel: st.channel_view.target, paint: crate::canvas::paint_target(app) }) +} + /// Remove after each stroke off: a finished stroke waits with the others, tinted on the canvas, /// until ↩ or the options bar's ✓ applies them all as one removal (Esc or ⊘ drops them). pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { + let Some(target) = remove_target(app) else { return }; let Some(st) = app.session.active() else { return }; - let (doc, size) = (st.doc.id, [st.doc.size.width, st.doc.size.height]); - if app.remove_pending_doc != Some(doc) { + let size = [st.doc.size.width, st.doc.size.height]; + if app.remove_pending_target.as_ref() != Some(&target) { app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = Some(doc); + app.remove_pending_target = Some(target); } let diameter = app.session.tools.brush.size; app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed_stroke(points, diameter); @@ -185,12 +201,13 @@ pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { /// Apply the waiting strokes as one removal (one job, one undo step). False when none wait. pub fn remove_pending_commit(app: &mut PhotocraftApp) -> bool { - if app.remove_pending.is_empty() { + // Enter can arrive before the next canvas frame checks pending-stroke lifetime. + if !remove_pending_live(app) { return false; } let strokes = std::mem::take(&mut app.remove_pending); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; let mut p = remove_params(&app.ui.tool_options); p["strokes"] = json!(strokes); p["target"] = crate::canvas::paint_target(app); @@ -209,7 +226,7 @@ pub fn remove_pending_cancel(app: &mut PhotocraftApp) -> bool { } app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; app.ui.status.clear(); app.ui.status_error = false; true @@ -221,8 +238,7 @@ pub fn remove_pending_live(app: &mut PhotocraftApp) -> bool { if app.remove_pending.is_empty() { return false; } - let doc = app.session.active().map(|st| st.doc.id); - if app.ui.tool == Tool::Remove && doc.is_some() && app.remove_pending_doc == doc { + if app.ui.tool == Tool::Remove && remove_target(app).as_ref() == app.remove_pending_target.as_ref() { return true; } remove_pending_cancel(app); @@ -1181,6 +1197,55 @@ mod tests { assert!(!remove_pending_live(&mut app) && app.remove_trail.is_none()); } + #[test] + fn queued_remove_cannot_commit_after_layer_or_channel_target_changes() { + for change in ["layer", "color", "mask"] { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + if change == "color" { + app.run("channel.target", json!({"channel": "red"})).unwrap(); + } + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1); + assert!(remove_pending_live(&mut app)); + + match change { + "layer" => { + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + } + "color" => { + app.run("channel.target", json!({"channel": "blue"})).unwrap(); + } + _ => { + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + } + } + + let journal = app.session.journal.len(); + assert!(!remove_pending_commit(&mut app), "{change}: Enter must not retarget a queued stroke"); + assert_eq!(app.session.journal.len(), journal, "{change}: no Remove command ran"); + assert!(app.remove_pending.is_empty() && app.remove_trail.is_none(), "{change}: queue cleared"); + assert!(app.remove_pending_target.is_none()); + } + } + + #[test] + fn queued_remove_starts_a_new_queue_after_target_switch() { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + remove_pending_add(&mut app, &[[40.0, 10.0, 1.0], [44.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1, "strokes from different layers must not be combined"); + assert_eq!(app.remove_pending[0][0][0], 40.0); + assert!(remove_pending_live(&mut app)); + } + #[test] fn remove_strokes_waiting_are_dropped_when_the_tool_changes() { let mut app = app(); From 59c8bd3b263fea478a8eeae921dd4aabb858ede7 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:56:44 +0700 Subject: [PATCH 156/304] Fix/7381 action worker failure 20261018t2254jkt (#7465) * fix(actions): stop resumed playback when a dialog's worker fails * test(actions): reject suffix after failing background dialog filter * fix(actions): propagate the failed worker result through dialog confirmation * fix(dialogs): return action worker failures to callers * Collapse the nested if in actions::resume (clippy::collapsible_if) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/actions.rs | 53 ++++++++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 2 +- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index a5c2f15e9b..cdffc378dd 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -435,21 +435,29 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let Some(r) = fields.get(RESUME) else { return }; +pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { + let Some(r) = fields.get(RESUME) else { return Ok(()) }; // The dialog's command may run as a background job; the next step needs its result. if let Some(doc) = app.session.active().map(|d| d.doc.id) && let Some(job) = app.session.job_on(doc) + && let Err(e) = app.session.wait_job(job.id) { - let _ = app.session.wait_job(job.id); + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]); + app.ui.status_error = true; + return Err(e.to_string()); } let params = json!({"action": r.get("action").cloned().unwrap_or(Value::Null), "from": r.get("from").cloned().unwrap_or(Value::Null)}); match play(app, ¶ms) { - Ok(v) => report_play(app, &v), + Ok(v) => { + report_play(app, &v); + Ok(()) + } Err(e) => { let template = "Action playback failed: {error}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); app.ui.status_error = true; + Err(e) } } } @@ -1543,6 +1551,43 @@ mod tests { assert_eq!(app.session.actions.list[0].steps.len(), 3); } + #[test] + fn dialog_worker_failure_stops_action_before_following_steps() { + for (background, locked) in [(true, true), (false, true), (true, false)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 6, "background": "#203040"})).unwrap(); + let mut action = Action::new( + "Worker Result", + vec![ + ("layer.setProps".into(), json!({"name": "Prefix", "locks": {"pixels": locked}})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})), + ("layer.new.layer".into(), json!({"name": "Suffix"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + app.background_jobs = background; + let paused = play(&mut app, &json!({"action": "Worker Result"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let result = crate::dialogs::confirm(&mut app, dialog); + let suffix = app.session.active().unwrap().doc.walk().iter().any(|(_, _, layer)| layer.name == "Suffix"); + + assert_eq!(suffix, !locked, "background={background}, locked={locked}: {result:?}"); + if locked && background { + assert!(result.as_ref().is_err_and(|e| e.contains("locked")), "failed worker must fail the dialog confirmation: {result:?}"); + assert!(app.ui.status_error, "the action must report the worker failure"); + assert!(app.ui.status.contains("locked"), "{}", app.ui.status); + assert_eq!(app.session.jobs_with_recent()[0].state, "failed"); + } else if locked { + assert!(result.is_err(), "the foreground command rejects the locked layer"); + } else { + assert!(result.is_ok(), "the successful worker lets the action continue: {result:?}"); + } + assert_eq!(app.session.actions.playing, 0); + assert!(app.session.actions.playback_stack.is_empty()); + } + } + #[test] fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 81e04e570c..2e951e2ece 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -664,7 +664,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { if result.is_ok() { crate::filter_dialog::remember(app, &cmd, &d.fields); // A step dialog of a playing action: the action continues after it. - crate::actions::resume(app, &d.fields); + crate::actions::resume(app, &d.fields)?; } result } From 0807441e52ad3476573c134349ec5d57d99750c5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:56:48 +0200 Subject: [PATCH 157/304] Make .pcraft reload f64 coordinates bit-exact (#7498) Found while testing crates/format/Cargo.toml:19 (serde_json features), used for the manifest in crates/format/src/manifest.rs and store.rs Co-authored-by: Claude Haiku 5.5 --- crates/format/Cargo.toml | 2 +- crates/format/tests/float_roundtrip.rs | 27 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 crates/format/tests/float_roundtrip.rs diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 113d9ba026..1d272d1462 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,7 +16,7 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-codecs = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true, features = ["unbounded_depth"] } +serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] } thiserror = { workspace = true } flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. diff --git a/crates/format/tests/float_roundtrip.rs b/crates/format/tests/float_roundtrip.rs new file mode 100644 index 0000000000..da6e7626d1 --- /dev/null +++ b/crates/format/tests/float_roundtrip.rs @@ -0,0 +1,27 @@ +//! `.pcraft` is lossless for f64: every coordinate reloads bit-for-bit. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; + +#[test] +fn f64_coordinates_reload_bit_exact() { + let x = 11.183221215612905_f64; + let y = 0.1 + 0.2; + let mut doc = Document::new("Floats", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Shape", + LayerContent::Shape(photocraft_doc::ShapeLayer { + path: photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(x, y), (40.0, 10.0), (25.0, 40.0)])]), + ..Default::default() + }), + )); + doc.guides.horizontal.push(x); + doc.guides.vertical.push(y); + doc.ruler_origin = [x, y]; + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(back.guides.horizontal[0].to_bits(), x.to_bits()); + assert_eq!(back.guides.vertical[0].to_bits(), y.to_bits()); + assert_eq!(back.ruler_origin.map(f64::to_bits), [x, y].map(f64::to_bits)); + assert_eq!(back, doc); +} From 5095babf89216f0cc9f5fa7e0e048859cd2b6a31 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 02:26:53 +0530 Subject: [PATCH 158/304] perf(canvas): composite and upload an inline filter preview once (#7490) (#7501) The synchronous filter-preview path (web, and sessions with background jobs off) flattened each changed preview, uploaded it and recorded it, then flattened it again for publish_filter_preview, which uploaded and recorded it a second time. Flatten once, as the background worker does, and let publish_filter_preview do the upload and the perf record. This removes one full composite and one texture upload per changed preview, and the frame's refresh time no longer counts the preview twice. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 41 ++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a5f1452dd1..0d5fbe1b81 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1882,12 +1882,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } // Web and explicitly inline sessions keep the deterministic synchronous path (#1676): // computed inline, uploaded when a GPU texture exists, still inspectable without one. + // Flattened once, as the background worker does; `publish_filter_preview` uploads it (#7490). let _ = ctx; if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_for_target( + let computed = crate::filter_dialog::preview_document_for_target( &doc, request.active, &request.command, @@ -1898,19 +1899,11 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e &documents, &app.session.color.settings, ) - .map(std::sync::Arc::new); - if let Some(r) = &result { - let buf = photocraft_compose::flatten(r); - let t1 = crate::gpu_canvas::now_ms(); - // The preview's own profile: Assign and Convert to Profile change it (see `publish_filter_preview`). - let (display, _) = canvas_display(app, r, None); - if let Some(gpu) = app.gpu.as_ref() { - gpu.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); - } - app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); - } - let buffer = result.as_ref().map(|r| photocraft_compose::flatten(r)); - publish_filter_preview(app, idx, request, result.zip(buffer), crate::gpu_canvas::now_ms() - t0); + .map(|doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }); + publish_filter_preview(app, idx, request, computed, crate::gpu_canvas::now_ms() - t0); } let preview = app.filter_preview.as_ref()?; let result = preview.result.as_ref()?; @@ -6206,6 +6199,26 @@ mod tests { assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); } + /// #7490: the inline (web) preview path composites and uploads each changed preview once, as + /// the background worker does. It used to flatten and upload it twice and record it twice, + /// so the frame's refresh time counted the preview twice. + #[test] + fn inline_filter_preview_is_composited_and_recorded_once() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); + app.perf.frame_refresh_ms = 0.0; + let (_, _, size) = ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + assert_eq!(size, [320, 240]); + assert_eq!(app.perf.last_refresh, "filter-preview"); + assert_eq!(app.perf.last_refresh_px, 320 * 240); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms, "one record per preview"); + // Unchanged request: nothing is recomputed or recorded again. + ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("the same preview"); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms); + } + /// #6326: a filter's live preview is never coarser than the screen. On a document big enough /// for a reduced proxy, the view at 100 % previews at full resolution, and only a view zoomed /// out far enough uses the proxy. Through `ensure_filter_preview`, so the factor the canvas From 449b99a74e6cda89de5a57ff30186cd627f8b348 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sun, 18 Oct 2026 22:56:58 +0200 Subject: [PATCH 159/304] fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY (#7521) * fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY The positional match for , and . (so Shift+Cmd+. works on Nordic/German layouts) applied to every shortcut. On AZERTY, ; sits on the US comma key, so ; fired the command bound to , (brush hardness up/down did the same thing). Only shortcuts with Cmd now follow the key position. * contributors: credit Byxis by their GitHub profile name --- contributors/people.toml | 3 +++ crates/ui-egui/src/shortcut_dispatch.rs | 6 ++++-- crates/ui-egui/src/shortcut_dispatch/tests.rs | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 3948e2992b..102c69e66c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -169,3 +169,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.Byxis] +sync_github_name = true diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 4336315dba..ffc1a7f6e5 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -215,7 +215,9 @@ fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bo } /// Keys whose shortcuts follow the key's position, as in Photoshop: ⇧⌘. is ⇧⌘> on a US layout, -/// but types `:` on Nordic and German ones, so its character would never match. +/// but types `:` on Nordic and German ones, so its character would never match. Only ⌘ shortcuts +/// follow the position: a plain `,` or `;` binding (brush hardness on AZERTY, where `;` sits on the +/// US comma key) must match the character typed. const POSITIONAL: [Key; 2] = [Key::Period, Key::Comma]; /// Dispatch this frame's shortcut presses in the order they arrived (⌘Z then ⌘S undoes, then @@ -236,7 +238,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc egui::Event::Key { key, physical_key, pressed: true, modifiers, .. } => { let by_position = physical_key .filter(|p| POSITIONAL.contains(p)) - .and_then(|p| table.iter().find(|(_, sc)| sc.logical_key == p && key_matches(sc, p, *modifiers))); + .and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers))); by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab)) } _ => None, diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 2827671a84..00f1a120a3 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -804,3 +804,23 @@ fn type_size_keys_follow_key_position_and_the_typed_selection() { assert_eq!(logged(&h), ["type.decreaseSize"]); assert_eq!(sizes(&h), [(2, base), (3, base + 2.0)], "only the selected \"He\" shrinks"); } + +/// AZERTY: `;` sits on the US comma key and `,` on the US M key. Plain `,` / `;` bindings (brush +/// hardness) must match the character typed, not the key's position, or `;` fires `,`'s command. +#[test] +fn plain_comma_and_semicolon_bindings_follow_the_typed_character_on_azerty() { + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("tools.decreaseBrushHardness".into(), ",".into()); + p.shortcuts.insert("tools.increaseBrushHardness".into(), ";".into()); + }); + let tap = |h: &mut Harness<'_, PhotocraftApp>, key, physical| { + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + tap(&mut h, egui::Key::Semicolon, egui::Key::Comma); + assert_eq!(logged(&h), ["tools.increaseBrushHardness"]); + tap(&mut h, egui::Key::Comma, egui::Key::M); + assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]); +} From d77a989ef410914bf62dcc7f8ef0e5cd7a74f976 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 22:57:03 +0200 Subject: [PATCH 160/304] image.crop: accept whole-number float targetWidth/targetHeight (#7551) (#7552) The target size was read with as_u64 only, so 100.0 (json.dumps of a float, many agent clients) was refused as not a whole number, while the crop rectangle and image.imageSize accept it. Fractional, negative, zero and out-of-range values are still refused. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index c5c85aec89..92bbdd7463 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -549,8 +549,10 @@ fn crop_target(p: &Value) -> Result { let side = |key: &str| -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), + // Whole-number floats (`100.0`) count too, as for the crop rectangle. Some(v) => v .as_u64() + .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=f64::from(MAX_CROP_SIDE)).contains(f)).map(|f| f as u64)) .and_then(|n| u32::try_from(n).ok()) .filter(|n| (1..=MAX_CROP_SIDE as u32).contains(n)) .map(Some) @@ -1418,6 +1420,14 @@ mod tests { assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0)); } + #[test] + fn crop_target_accepts_whole_number_floats() { + // `json.dumps(100.0)` and many other JSON writers send whole numbers as floats. + let mut s = session(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 20, "height": 10, "targetWidth": 40.0, "targetHeight": 20.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(40, 20)); + } + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); @@ -1427,6 +1437,10 @@ mod tests { json!({"targetWidth": 0, "targetHeight": 10}), json!({"targetWidth": -5, "targetHeight": 10}), json!({"targetWidth": 10.5, "targetHeight": 10}), + json!({"targetWidth": -1.0, "targetHeight": 10}), + json!({"targetWidth": 0.0, "targetHeight": 10}), + json!({"targetWidth": 1e39, "targetHeight": 10}), + json!({"targetWidth": 300_001.0, "targetHeight": 10}), json!({"targetWidth": "10", "targetHeight": 10}), json!({"targetWidth": 300_001, "targetHeight": 10}), json!({"targetWidth": 4_294_967_306_u64, "targetHeight": 10}), From b48a916b138c370079f801818bbf26f38d8e72af Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:57:08 -0400 Subject: [PATCH 161/304] fix(algo): normalize nonpositive filter tile sizes (#7554) --- crates/algo/src/lib.rs | 3 +++ crates/algo/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index c2ff6d2e6c..5a034e4561 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -972,6 +972,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). +/// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( surface: &Surface, params: &FilterParams, @@ -1014,6 +1015,8 @@ fn apply_tiled_with_impl( ctl: &photocraft_raster::Interrupt, prepare_fast_paths: bool, ) -> Option { + // Every dispatch path needs a positive step, including Motion Blur's fallback. + let tile = tile.max(1); if ctl.cancelled() { return None; } diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 148a0723ec..06beabebbe 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() { assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 }); } +#[test] +fn nonpositive_tile_sizes_match_one_pixel_tiles() { + let area = Rect::new(0, 0, 4, 3); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = pattern(depth, area); + for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) { + let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area)); + for tile in [0, -64, i32::MIN] { + let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area)); + assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE) + .expect("a nonpositive tile size is normalized, not rejected"); + assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true); + assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none()); + } + } + assert_eq!(source.read_region(area), pattern(depth, area).read_region(area)); + } +} + #[test] fn every_filter_is_tile_independent() { let s = pattern(SampleType::U8, R); From 5225b592fe98e1243906b5ae896826a4157cf725 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 18 Oct 2026 22:57:14 +0200 Subject: [PATCH 162/304] Perspective Crop: draw the crop quad's side of the horizon (#7557) (#7558) When the crop quad's horizon crossed the layer and most of the layer (or its centre) lay beyond it, warp picked the source's majority side and skipped every output pixel, so the crop came out blank. A clip wholly on one side of the inverse horizon only ever samples that side, so draw it; a straddling clip keeps the majority rule (#4863). Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 23 +++++++++++++++++++++- crates/engine/src/perspective_crop_cmds.rs | 13 ++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 3e67f1cdee..91260bd285 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -333,7 +333,12 @@ fn warp( // A source straddling the horizon draws only its front side: the inverse w at a pixel has the // sign of the forward w at the source point it samples, and the other side would show as a // mirrored copy wrapped round infinity (#4863). - let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])); + // A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn + // (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557). + let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64))); + let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0))); + let front = + side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]))); let bounds = corner_bounds(&corners); let dst = match (clip, bounds) { // The warped corners only bound the result while the whole source is in front of the horizon. @@ -713,6 +718,22 @@ mod tests { assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum()); } + /// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across + /// the layer with most of it (and its centre) on the far side. The clip maps wholly into the + /// quad, so its side is the one drawn, not the source's majority. + #[test] + fn a_clip_wholly_on_one_side_draws_that_side() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]]; + let h = quad_to_rect(&q, 50.0, 39.0).unwrap(); + assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side"); + for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap(); + assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}"); + } + } + #[test] fn identity_and_translation_are_exact() { let mut s = rgba(); diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 776b2b4d07..0cd1f01604 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -311,6 +311,19 @@ mod tests { assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]); } + /// #7557: a quad low on a strongly converging photo (its horizon crosses the canvas, with the + /// canvas centre beyond it) is cropped, not left blank in the background colour. + #[test] + fn a_quad_below_a_horizon_across_the_canvas_keeps_its_content() { + let mut doc = Document::with_background("red", Size::new(100, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let mut s = Session::new(); + s.add_document(doc, None); + s.execute(CMD, json!({"corners": [[40, 70], [60, 70], [90, 95], [10, 95]], "width": 50, "height": 39})).unwrap(); + let px = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(25, 20); + assert_eq!(px, [1.0, 0.0, 0.0, 1.0]); + } + /// Bad quads and params are refused with an error, and the document is untouched. #[test] fn perspective_crop_rejects_bad_quads() { From 92721dd8c4d71007a8266ea388d42805e6fbdd0b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:57:19 +0200 Subject: [PATCH 163/304] Refuse strict .atn export of plain events whose target is not the layer pixels (#7564) Fixes #7272 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 9 ++++++++- crates/engine/src/atn_cmds/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index a32256920a..d5c688f0a6 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -881,11 +881,18 @@ macro_rules! ev { Some(m) => step($id, Value::Object(m)), None => Mapped::Unsupported { harmless: false }, }, - to: |_, p| fields_to(p, $fields, desc("null")), + to: |_, p| if pixel_target(p) { fields_to(p, $fields, desc("null")) } else { None }, } }; } +/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's +/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such +/// a step can't be exported faithfully. +fn pixel_target(p: &Value) -> bool { + p.get("target").is_none_or(|t| t.as_str() == Some("pixels")) +} + const INTERPOLATION: &[(&str, &str)] = &[ ("Bcbc", "bicubic"), ("Blnr", "bilinear"), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 335a3cbead..c4fe00eb95 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1068,3 +1068,24 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5)); assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); } + +/// A filter recorded against a layer mask must not export as a plain filter that edits the +/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. +#[test] +fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { + let masked = Action { + name: "Probe".into(), + steps: vec![("filter.noise.addNoise".into(), json!({"amount": 75, "seed": 123, "target": "mask"}))], + ..Default::default() + }; + assert!(export("Audit", &[&masked], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&masked], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "filter.noise.addNoise".into() }]); + for params in [json!({"amount": 75, "seed": 123}), json!({"amount": 75, "seed": 123, "target": "pixels"})] { + let plain = Action { name: "Plain".into(), steps: vec![("filter.noise.addNoise".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); + } +} From f8e4a1344fc9923dd0ca4cbc9bc01a83107de650 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:57:24 +0300 Subject: [PATCH 164/304] Print: the last print's settings survive a relaunch, as in Photoshop (#7410) (#7526) * fix(print): the last print's settings are kept in the preferences, so Print opens with them after a relaunch (#7410) * test(print): the last print's settings survive a relaunch for Print and Print One Copy (#7410) --- crates/engine/src/print_cmds.rs | 36 +++++++++++++++++++++-- crates/engine/src/print_cmds/tests.rs | 42 +++++++++++++++++++++++++++ crates/ui-egui/src/file_ui.rs | 22 ++++++++++++-- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 3cc99aa276..0a1701a166 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -654,7 +654,11 @@ fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { && page.label.as_deref().is_none_or(str::is_empty) } -/// Print One Copy repeats these. +/// Where the settings of the last print are kept in Preferences' remembered dialog settings. +pub const REMEMBERED: &str = "file.print"; + +/// Print One Copy repeats these, and Print opens with them. They are kept in the preferences as +/// well, so they survive a relaunch, as Photoshop's do (#7410). fn remember_print(s: &mut Session, p: &Value) { let mut remembered = p.clone(); if let Some(o) = remembered.as_object_mut() { @@ -663,9 +667,31 @@ fn remember_print(s: &mut Session, p: &Value) { // Output and dry-run choices are not remembered, so a send override must not survive them. o.remove("send"); } + let kept = remembered.clone(); + s.prefs.edit(|prefs| { + prefs.dialogs.insert(REMEMBERED.into(), kept); + }); s.file_menu.last_print = Some(remembered); } +/// Drops the `printer` of the settings `q` (and its driver settings) when the print server lists +/// printers but not that one, so the job goes to the default printer. An empty list can't tell. +fn drop_vanished_printer(q: &mut Value, listed: &[&str]) { + let Some(name) = q.get("printer").and_then(Value::as_str).filter(|n| !n.is_empty()) else { return }; + if listed.is_empty() || listed.iter().any(|l| l.eq_ignore_ascii_case(name)) { + return; + } + if let Some(o) = q.as_object_mut() { + o.remove("printer"); + o.remove("printerSettings"); + } +} + +/// The settings of the last print: this session's, else those remembered from an earlier one. +pub fn last_print(s: &Session) -> Option { + s.file_menu.last_print.clone().or_else(|| s.prefs().dialogs.get(REMEMBERED).filter(|v| v.is_object()).cloned()) +} + /// The `printer` parameter, or the system's default printer. fn printer_name(p: &Value) -> Result { if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { @@ -788,7 +814,13 @@ fn print(s: &mut Session, p: &Value) -> Result { } fn print_one_copy(s: &mut Session, p: &Value) -> Result { - let mut q = s.file_menu.last_print.clone().unwrap_or_else(|| json!({})); + let mut q = last_print(s).unwrap_or_else(|| json!({})); + // A printer remembered from an earlier session may be gone: then the default takes the job, + // as the Print dialog falls back (#7410). A list that can't be read decides nothing. + if let Ok(listed) = system_printers(s, &q) { + let names: Vec<&str> = listed["printers"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); + drop_vanished_printer(&mut q, &names); + } if let (Some(o), Some(extra)) = (q.as_object_mut(), p.as_object()) { for (k, v) in extra { o.insert(k.clone(), v.clone()); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index ddd1a65a16..c54dddf6f8 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -226,6 +226,48 @@ fn printer_profile_can_be_an_installed_icc_file() { let _ = std::fs::remove_dir_all(&dir); } +/// #7410: the settings of the last print survive a relaunch, as Photoshop's do. They are kept in +/// the preferences (without the output, dry-run and send choices), and a new session with those +/// preferences, before printing anything, repeats them with Print One Copy. +#[test] +fn the_last_print_settings_survive_a_relaunch() { + let mut s = session("rgb", 8); + assert!(last_print(&s).is_none()); + s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50, "intent": "perceptual"})).unwrap(); + let kept = s.prefs().dialogs.get(REMEMBERED).cloned().expect("kept in the preferences"); + assert_eq!((&kept["paper"], &kept["scale"], &kept["intent"]), (&json!("a5"), &json!(50), &json!("perceptual"))); + assert!(kept.get("dryRun").is_none() && kept.get("output").is_none() && kept.get("send").is_none(), "{kept}"); + // A relaunch: a new session with these preferences, nothing printed in it yet. + let mut t = session("rgb", 8); + t.prefs.edit(|p| { + p.dialogs.insert(REMEMBERED.into(), kept.clone()); + }); + assert_eq!(last_print(&t), Some(kept)); + let r = t.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); + assert_eq!(r["copies"], 1); + assert_eq!(r["paper"], json!([419.53, 595.28])); + assert_eq!(r["scale"], 50.0); +} + +/// #7410: a printer remembered from an earlier session that the print server no longer lists is +/// dropped before Print One Copy repeats the settings (with its driver settings), so the default +/// printer takes the job; a listed one (in any case) stays, and an empty list can't tell. +#[test] +fn a_vanished_remembered_printer_falls_back_to_the_default() { + let settings = || json!({"printer": "Office", "printerSettings": "00ff", "paper": "a5"}); + let mut q = settings(); + drop_vanished_printer(&mut q, &["Studio", "Lab"]); + assert_eq!(q, json!({"paper": "a5"}), "gone: the default printer takes it"); + for listed in [&["Studio", "office"][..], &[][..]] { + let mut q = settings(); + drop_vanished_printer(&mut q, listed); + assert_eq!(q, settings(), "{listed:?}"); + } + let mut q = json!({"printer": "", "paper": "a5"}); + drop_vanished_printer(&mut q, &["Studio"]); + assert_eq!(q, json!({"printer": "", "paper": "a5"}), "the default printer stays the default"); +} + #[test] fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 3f87f6339c..d1b2f0e551 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -76,7 +76,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V app.session.active()?; json!({"dialog": open_print(app)}) } - "file.printOneCopy" if app.session.file_menu.last_print.is_none() => { + "file.printOneCopy" if photocraft_engine::print_cmds::last_print(&app.session).is_none() => { app.session.active()?; json!({"dialog": open_print(app)}) } @@ -729,7 +729,8 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { // Asked once per opening: the Printer menu lists what the spooler knows now. let printers = app.session.execute("file.printers", json!({})).unwrap_or_default(); f.insert("__systemPrinters".into(), printers["printers"].clone()); - if let Some(Value::Object(m)) = app.session.file_menu.last_print.clone() { + // The settings of the last print, also from an earlier session (#7410). + if let Some(Value::Object(m)) = photocraft_engine::print_cmds::last_print(&app.session) { f.extend(m); } for (k, v) in [ @@ -1452,6 +1453,23 @@ mod tests { assert!(params(f).get("__printers").is_none()); } + /// #7410: File › Print opens with the settings of the last print, also those remembered from + /// an earlier session (the preferences), before anything was printed in this one. + #[test] + fn print_dialog_opens_with_the_settings_remembered_from_an_earlier_session() { + let (mut app, _) = app(); + app.session.prefs.edit(|p| { + p.dialogs.insert( + photocraft_engine::print_cmds::REMEMBERED.into(), + json!({"paper": "a5", "scale": 50, "orientation": "landscape", "intent": "perceptual"}), + ); + }); + let id = open_print(&mut app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((&f["paper"], &f["scale"], &f["orientation"], &f["intent"]), (&json!("a5"), &json!(50), &json!("landscape"), &json!("perceptual"))); + assert_eq!(f["copies"], 1, "what wasn't remembered keeps its default"); + } + #[test] fn print_dialog_does_not_inherit_scripted_send_override() { for send in [false, true] { From bbfeae98e4a4370f9e7f50e0a1a447a3b687ce52 Mon Sep 17 00:00:00 2001 From: Julien <15385135+jlange91@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:23:05 +0200 Subject: [PATCH 165/304] Type: right-click on committed text opens the type layer menu (#7324) (#7430) * Type: right-click on committed text opens the type layer menu (#7324) With the Type tool, a right-click only opened a menu while text was being edited; over committed type nothing opened. The menu was keyed on the edit session alone (`editing_text`). Over committed type, a Type tool (not a Type Mask tool) now opens TYPE_LAYER_MENU: the type edit menu's layer rows, without the edit rows (Undo, Cut, Copy, Paste, Select All), which would act on the document, and without Faux Bold/Italic, which act on the characters being edited. Type outside the selected layers becomes the target first, as a click with the Type tool makes it. The control channel's right button takes the same path. Co-Authored-By: Claude Opus 5.5 * Type layer menu: the clicked type becomes the active layer in a multiselection (#7324) The structural rows (Rasterize Type, Convert to Shape, Create Work Path, the paragraph/point conversions) act on the active layer. When the right-clicked type was already selected but another selected type layer was active, they acted on that other layer. Make the clicked type the active layer while keeping the selection (layer_multi_cmds::set_selection), so the formatting rows still act on every selected type layer. Found by CodeRabbit on #7430. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas_menu_tests.rs | 16 ++ crates/ui-egui/src/canvas_tool_menu.rs | 183 ++++++++++++++++++++- crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/transform_type_tests.rs | 1 + crates/ui-egui/src/type_tool.rs | 2 +- docs/context-menu-parity.md | 5 +- docs/control-protocol.md | 2 +- docs/scorecard.md | 2 +- scorecard/ui.toml | 2 +- 9 files changed, 204 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas_menu_tests.rs b/crates/ui-egui/src/canvas_menu_tests.rs index f1bbbe8d0c..839dc1f765 100644 --- a/crates/ui-egui/src/canvas_menu_tests.rs +++ b/crates/ui-egui/src/canvas_menu_tests.rs @@ -248,6 +248,22 @@ fn control_channel_opening_uses_the_same_dismissal_guard() { } } +/// #7324: an agent's right-click on committed type opens the type layer menu, as on the canvas. +#[test] +fn control_channel_right_click_on_committed_type_opens_the_type_layer_menu() { + let mut h = harness(Tool::Type, false, 1.0); + h.state_mut().run("type.create", json!({"text": "Hello", "size": 72, "x": 100, "y": 180})).unwrap(); + h.run_steps(3); + let at = fractional_point(&h, 1.0); + let before = document_state(&h); + control_tap(&mut h, at, Popup::Tool); + let menu = h.state().ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.type_layer && !menu.text); + assert!(h.state().ui.text_edit.is_none()); + // The new type is already the active layer, so the menu selects nothing either. + assert!(document_state(&h) == before, "opening the menu changes nothing"); +} + #[test] fn brush_picker_taps_preserve_pixels_selection_and_history_after_reopening() { for tool in [Tool::Brush, Tool::Eraser, Tool::QuickSelection] { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index a02859c5cd..f7f9892d2f 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,5 +1,6 @@ //! Canvas context menus for the selection tools, the Pen, the Zoom and Hand tools, an active Free -//! Transform box, text being edited with a Type tool, the pasteboard (outside the image) and a guide. +//! Transform box, text being edited with a Type tool, committed type under a Type tool, the +//! pasteboard (outside the image) and a guide. use egui::Context; use photocraft_doc::LayerContent; @@ -31,6 +32,9 @@ pub struct CanvasToolMenu { /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, + /// Opened with a Type tool over committed type (no edit running): the type layer rows. + #[serde(default)] + pub type_layer: bool, /// Opened on a guide: the guide menu (`guide_select`) instead of the tool's actions. #[serde(default)] pub guide: bool, @@ -137,6 +141,33 @@ pub const TYPE_EDIT_MENU: &[Row] = &[ Some(("Warp Text…", "type.warpText")), ]; +/// Right-click with a Type tool over type that isn't being edited (#7324): the type edit menu's +/// layer rows. Its edit rows would act on the document instead of the text, and Faux Bold/Italic +/// on characters being edited, so they are left out. +pub const TYPE_LAYER_MENU: &[Row] = &[ + Some(("Check Spelling…", "edit.checkSpelling")), + Some(("Find and Replace Text…", "edit.findAndReplaceText")), + None, + Some(("Rasterize Type", "type.rasterizeTypeLayer")), + Some(("Create Work Path", "type.createWorkPath")), + Some(("Convert to Shape", "type.convertToShape")), + None, + Some(("Horizontal", "type.orientation.horizontal")), + Some(("Vertical", "type.orientation.vertical")), + None, + Some(("None", "type.antiAlias.none")), + Some(("Sharp", "type.antiAlias.sharp")), + Some(("Crisp", "type.antiAlias.crisp")), + Some(("Strong", "type.antiAlias.strong")), + Some(("Smooth", "type.antiAlias.smooth")), + Some(("Windows LCD", "type.antiAlias.windowsLcd")), + Some(("Windows", "type.antiAlias.windows")), + None, + Some(("Convert to Paragraph Text", "type.convertToParagraphText")), + Some(("Convert to Point Text", "type.convertToPointText")), + Some(("Warp Text…", "type.warpText")), +]; + /// The type edit menu's Faux Bold and Faux Italic rows. pub const FAUX_BOLD: &str = "type.fauxBold"; pub const FAUX_ITALIC: &str = "type.fauxItalic"; @@ -316,6 +347,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { VIEW_MENU } else if menu.text { TYPE_EDIT_MENU + } else if menu.type_layer { + TYPE_LAYER_MENU } else if path_menu(menu.tool) { PEN_MENU } else { @@ -338,7 +371,7 @@ pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: if menu.pasteboard { return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color); } - if menu.text { + if menu.text || menu.type_layer { return type_checked(app, command); } menu.transform @@ -364,6 +397,32 @@ pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool { tool.is_type() && app.ui.text_edit.is_some() } +/// The committed type layer under document point `doc` when a right-click there with a Type tool +/// opens the type layer menu (#7324). The Type Mask tools are left out: to them existing type is +/// just pixels. +pub fn type_layer_at(app: &mut PhotocraftApp, tool: Tool, doc: [f64; 2]) -> Option { + if !tool.is_type() || tool.is_type_mask() || app.ui.text_edit.is_some() || !doc.iter().all(|v| v.is_finite()) { + return None; + } + crate::type_tool::hit_layer(app, doc[0], doc[1]) +} + +/// Make the right-clicked type layer `id` the active layer, which the structural rows (Rasterize +/// Type, Convert to Shape…) act on and the checks show. Inside a multiselection the selection stays, +/// so the formatting rows still act on every selected type layer, as the Type menu's do; outside it +/// the type replaces the selection, as a click with the Type tool does. +fn target_type_layer(app: &mut PhotocraftApp, id: photocraft_doc::LayerId) -> bool { + let Some(st) = app.session.active() else { return false }; + let selected = st.selected_layers(); + if st.active_layer == Some(id) { + true + } else if selected.contains(&id) { + photocraft_engine::layer_multi_cmds::set_selection(&mut app.session, selected, Some(id), Some(id)).is_ok() + } else { + app.session.select_layer(id).is_ok() + } +} + /// The misspelled word of the edited text under document point `doc`, with its suggestions. fn spelling_at(app: &mut PhotocraftApp, doc: [f64; 2]) -> Option { let layer = app.ui.text_edit.as_ref()?.layer; @@ -424,6 +483,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }); @@ -497,6 +557,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }); @@ -525,6 +586,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui pasteboard: false, anchor: None, text: false, + type_layer: false, guide: true, spelling: None, }); @@ -578,6 +640,9 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; } + if menu.type_layer { + return TYPE_LAYER_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command); + } if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } @@ -621,10 +686,15 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - } /// [`open`] for a right-click at document point `doc`: over a misspelled word of the text being -/// edited, the menu starts with its spelling suggestions. +/// edited, the menu starts with its spelling suggestions; over committed type with a Type tool, the +/// type layer menu. pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2], doc: Option<[f64; 2]>) -> bool { let text = editing_text(app, tool); - if !(applies(tool) || text) || !pos.iter().all(|v| v.is_finite()) { + if !pos.iter().all(|v| v.is_finite()) { + return false; + } + let type_layer = !text && doc.and_then(|d| type_layer_at(app, tool, d)).is_some_and(|id| target_type_layer(app, id)); + if !(applies(tool) || text || type_layer) { return false; } app.ui.brush_picker = None; @@ -633,8 +703,20 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; - app.ui.canvas_tool_menu = - Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, guide: false, spelling }); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection, + has_path, + path_name, + transform: false, + pasteboard: false, + anchor: None, + text, + type_layer, + guide: false, + spelling, + }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -657,6 +739,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + type_layer: false, guide: false, spelling: None, }); @@ -703,7 +786,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_view(app, ctx, &menu, command) } else if menu.text && is_spelling(&menu, command) { choose_spelling(app, &menu, command) - } else if menu.text { + } else if menu.text || menu.type_layer { choose_type(app, ctx, command) } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) @@ -840,6 +923,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || app.ui.tool != menu.tool || (menu.transform && app.ui.transform.is_none()) || (menu.text && app.ui.text_edit.is_none()) + || (menu.type_layer && app.ui.text_edit.is_some()) { app.ui.canvas_tool_menu = None; return; @@ -1055,6 +1139,91 @@ mod tests { assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now"); } + /// A committed type layer reading "Hello" over the middle of the canvas, no edit running. + fn committed_type(app: &mut PhotocraftApp) -> u64 { + app.ui.tool = Tool::Type; + let id = app.run("type.create", json!({"text": "Hello", "size": 14, "x": 1, "y": 22})).unwrap()["layer"].as_u64().unwrap(); + assert!(app.ui.text_edit.is_none()); + id + } + + #[test] + fn right_click_on_committed_type_opens_the_type_layer_menu() { + use photocraft_doc::text::Orientation; + let mut app = app(); + let id = committed_type(&mut app); + assert_eq!(crate::type_tool::hit_layer(&mut app, 16.0, 16.0).map(|l| l.0), Some(id), "the canvas centre is on the type"); + let mut h = harness(app); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click on committed type opens a menu (#7324)"); + assert!(menu.type_layer && !menu.text && !menu.pasteboard); + assert_eq!(rows(&menu), TYPE_LAYER_MENU); + assert!(h.state().ui.text_edit.is_none(), "the right-click doesn't start an edit"); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the menu stays open without an edit"); + // An edit started some other way (here, as an agent's Type › Edit Text would) closes the + // menu, which has no text edit rows. + crate::menus::invoke(h.state_mut(), &Context::default(), "type.editText", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_none(), "an edit closes the type layer menu"); + crate::type_tool::commit(h.state_mut()); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("committed again, the menu opens again"); + assert!(menu.type_layer && mode_checked(h.state(), &menu, "type.orientation.horizontal")); + choose(h.state_mut(), &Context::default(), "type.orientation.vertical"); + assert_eq!(text_of(h.state(), id).unwrap().orientation, Orientation::Vertical); + } + + #[test] + fn type_layer_menu_targets_the_type_under_the_pointer() { + let mut app = app(); + let id = committed_type(&mut app); + app.run("layer.new.layer", json!({})).unwrap(); + let ctx = Context::default(); + let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.map(|l| l.0); + assert_ne!(active(&app), Some(id)); + // Away from the type, or with a Type Mask tool, nothing opens and nothing is selected. + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([30.0, 2.0]))); + assert!(!open_at(&mut app, &ctx, Tool::HorizontalTypeMask, [10.0, 10.0], Some([16.0, 16.0]))); + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([f64::NAN, 16.0]))); + assert_ne!(active(&app), Some(id)); + // Over the type: its layer becomes the target, and the menu has no text edit rows. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([16.0, 16.0]))); + assert_eq!(active(&app), Some(id)); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + for edit_row in ["edit.undo", "edit.cut", "edit.copy", "edit.paste", "select.all", FAUX_BOLD, FAUX_ITALIC] { + assert!(!available(&app, &menu, edit_row), "{edit_row} belongs to the edit menu only"); + } + assert!(entry_enabled(&app, &menu, "type.convertToParagraphText") && !entry_enabled(&app, &menu, "type.convertToPointText")); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, id).is_none(), "the type layer is pixels now"); + assert!(app.ui.text_edit.is_none()); + } + + /// Two selected type layers: the one right-clicked becomes active, and the selection stays. + #[test] + fn type_layer_menu_in_a_multiselection_acts_on_the_clicked_type() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::Type; + let top = app.run("type.create", json!({"text": "Top", "size": 30, "x": 10, "y": 40})).unwrap()["layer"].as_u64().unwrap(); + let bottom = app.run("type.create", json!({"text": "Low", "size": 30, "x": 10, "y": 90})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": bottom})).unwrap(); + app.run("layer.select", json!({"layer": top, "mode": "add"})).unwrap(); + let at = [25.0, 80.0]; + assert_eq!(crate::type_tool::hit_layer(&mut app, at[0], at[1]).map(|l| l.0), Some(bottom)); + assert_eq!(app.session.active().unwrap().active_layer.map(|l| l.0), Some(top)); + let ctx = Context::default(); + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some(at))); + let st = app.session.active().unwrap(); + assert_eq!(st.active_layer.map(|l| l.0), Some(bottom), "the clicked type is the target"); + assert_eq!(st.selected_layers().len(), 2, "the multiselection stays"); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, bottom).is_none(), "the clicked type is rasterized"); + assert!(text_of(&app, top).is_some(), "not the other selected type"); + } + #[test] fn type_edit_menu_lists_spelling_suggestions_for_the_word_under_the_pointer() { let mut app = app(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8cddc60b23..05540339a5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1093,7 +1093,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } - if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { + if crate::canvas_tool_menu::applies(app.ui.tool) + || crate::canvas_tool_menu::editing_text(app, app.ui.tool) + || crate::canvas_tool_menu::type_layer_at(app, app.ui.tool, [x, y]).is_some() + { if down { crate::canvas_tool_menu::open_at(app, ctx, app.ui.tool, screen_point(app, x, y), Some([x, y])); } diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index 0ed51de669..457c03c1cb 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -52,6 +52,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { pasteboard: false, anchor: None, text: false, + type_layer: false, guide: false, spelling: None, }; diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 325e5122b4..d07b8bb161 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -54,7 +54,7 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { /// Topmost shown type layer (it and every group around it visible, #4320) whose laid-out text /// contains the document point. -fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { +pub(crate) fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.point_zoom().max(0.01); // A Type Mask session's text is a hidden layer; it is still the text being edited. diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 1e8abf9a67..53b6d99bd9 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** minor (Path Selection canvas menu, #1056) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (Type tool menu over committed type, #7324) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -27,7 +27,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 | | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | -| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | +| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370). Over committed type (no edit running, `TYPE_LAYER_MENU`, #7324): the same rows without Undo, Cut, Copy, Paste, Select All and Faux Bold/Italic; the clicked type becomes the active layer first (a layer multiselection stays). The Type Mask tools get no menu. Ignore / Ignore All remain open | P1; editing and nonediting states are separate; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | | Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. The Path Selection and Direct Selection tools open the same path menu for the selected path (#1056, 2026-10-15); node- and segment-specific rows remain open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | @@ -100,6 +100,7 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Type tool menu over committed type (#7324) | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..c62e9046d2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,7 +73,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing diff --git a/docs/scorecard.md b/docs/scorecard.md index 3b36e7c611..a02e8ed210 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -192,7 +192,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs | | UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move | | UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay | -| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | +| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 4ac0ce6386..908efc6b32 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -59,7 +59,7 @@ id = "UI-217-8" target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields" status = "partial" issue = 217 -note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" +note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" [[item]] id = "UI-217-9" From 0eb0387d53446f5341d46e708f58ebf13711dfb5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:23:10 +0200 Subject: [PATCH 166/304] Save for Web and Quick Export: "All" keeps the photo's EXIF (fixes #7436) (#7471) --- crates/codecs/src/web.rs | 26 ++- crates/engine/examples/bench_web_palette.rs | 4 +- crates/engine/src/web_cmds.rs | 65 +++++-- .../engine/src/web_cmds/color_table_tests.rs | 8 +- crates/engine/src/web_cmds/tests.rs | 169 +++++++++++++++++- crates/ui-egui/src/file_ui.rs | 2 +- crates/ui-egui/src/file_ui/web_preview.rs | 6 +- 7 files changed, 245 insertions(+), 35 deletions(-) diff --git a/crates/codecs/src/web.rs b/crates/codecs/src/web.rs index 584ceb9d6e..dfd0cd9b90 100644 --- a/crates/codecs/src/web.rs +++ b/crates/codecs/src/web.rs @@ -285,7 +285,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { } /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and -/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP. +/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and +/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]). #[allow(clippy::too_many_arguments)] pub fn encode_jpeg_rgb8( width: u32, @@ -296,6 +297,7 @@ pub fn encode_jpeg_rgb8( optimized: bool, icc: Option<&[u8]>, dpi: Option, + exif: Option<&[u8]>, xmp: Option<&str>, ) -> Result, CodecError> { let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e); @@ -319,9 +321,14 @@ pub fn encode_jpeg_rgb8( let v = d.round().min(65535.0) as u16; enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v }); } + let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d)); + // Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it. + if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) { + enc.add_app_segment(1, &seg).map_err(e)?; + } if let Some(x) = xmp { // Its `tiff:` resolution, if any, says what the JFIF density says (#1691). - let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))); + let x = crate::resolution::export_xmp(x, ppi); let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec(); seg.extend_from_slice(x.as_bytes()); enc.add_app_segment(1, &seg).map_err(e)?; @@ -333,6 +340,11 @@ pub fn encode_jpeg_rgb8( Ok(out) } +/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment). +pub fn jpeg_exif_fits(exif: &[u8], dpi: Option) -> bool { + crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -359,7 +371,7 @@ mod tests { let rgb: Vec = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect(); for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] { for progressive in [false, true] { - let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap(); assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}"); } } @@ -428,8 +440,8 @@ mod tests { #[test] fn progressive_jpeg_decodes() { let rgb: Vec = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect(); - let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("")).unwrap(); - let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap(); + let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("")).unwrap(); + let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap(); assert_ne!(base, prog); assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("")); // SOF2 marks a progressive frame. @@ -442,7 +454,7 @@ mod tests { fn web_jpeg_xmp_resolution_follows_the_density() { let rgb = vec![100u8; 8 * 8 * 3]; let xmp = r#""#; - let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap(); + let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap(); let img = crate::decode(&out).unwrap(); assert_eq!(img.meta.dpi, Some((72.0, 72.0))); assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0))); @@ -461,7 +473,7 @@ mod subsampling_tests { let rgb: Vec = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect(); for q in [30, 60, 95] { for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] { - let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap(); let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8); let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap(); assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}"); diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs index 5ed1c3a3b0..4b8b87616f 100644 --- a/crates/engine/examples/bench_web_palette.rs +++ b/crates/engine/examples/bench_web_palette.rs @@ -18,13 +18,13 @@ fn main() -> Result<(), Box> { let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?; let rect = Rect::new(0, 0, w as i32, h as i32); let started = Instant::now(); - let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?; + let original = optimize(&pixels, w, rect, &settings, None, None, None, 72.0, false)?; println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0); let mut edited = settings; edited.color_table = Some(original.color_table); edited.color_table_edits.insert(0, [0, 255, 0]); let started = Instant::now(); - let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?; + let result = optimize(&pixels, w, rect, &edited, None, None, None, 72.0, false)?; println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len()); Ok(()) } diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 9f0e25574d..29237fd5ad 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -70,6 +70,9 @@ pub enum WebMetadata { None, Copyright, CopyrightAndContact, + /// All the XMP, none of the photo's EXIF (camera, date taken, GPS). + AllExceptCameraInfo, + /// All the XMP and the photo's EXIF. All, } @@ -203,8 +206,9 @@ impl WebSettings { "none" => WebMetadata::None, "copyright" => WebMetadata::Copyright, "copyrightAndContact" | "contact" => WebMetadata::CopyrightAndContact, - "all" | "allExceptCameraInfo" => WebMetadata::All, - m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), + "allExceptCameraInfo" => WebMetadata::AllExceptCameraInfo, + "all" => WebMetadata::All, + m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|allExceptCameraInfo|all)"))), }; } if let Some(table) = p.get("colorTable") { @@ -262,6 +266,7 @@ impl WebSettings { WebMetadata::None => "none", WebMetadata::Copyright => "copyright", WebMetadata::CopyrightAndContact => "copyrightAndContact", + WebMetadata::AllExceptCameraInfo => "allExceptCameraInfo", WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); @@ -387,7 +392,7 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { let xmp = doc.metadata.xmp.as_deref()?; match m { WebMetadata::None => None, - WebMetadata::All => Some(xmp.to_string()), + WebMetadata::All | WebMetadata::AllExceptCameraInfo => Some(xmp.to_string()), WebMetadata::Copyright | WebMetadata::CopyrightAndContact => { let info = crate::file_cmds::read_file_info(Some(xmp)); let mut keep = serde_json::Map::new(); @@ -405,10 +410,33 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { } } +/// The photo's EXIF to embed for the metadata choice: only "All" carries it (#7436). +pub fn web_exif(doc: &Document, m: WebMetadata) -> Option<&[u8]> { + doc.metadata.exif.as_deref().map(Vec::as_slice).filter(|_| m == WebMetadata::All) +} + +/// The response's warnings: EXIF that a JPEG couldn't hold (one segment, about 64 KB). +fn web_warnings(st: &WebSettings, exif: Option<&[u8]>, dpi: f32) -> Vec { + exif.filter(|x| st.format == WebFormat::Jpeg && !photocraft_codecs::web::jpeg_exif_fits(x, Some(dpi))) + .map(|x| photocraft_codecs::FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: x.len() }.to_string()) + .into_iter() + .collect() +} + /// Optimises `rect` of `px` (straight RGBA, `bw` wide) with `st`. `preview` also returns what /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] -pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result { +pub fn optimize( + px: &[[f32; 4]], + bw: usize, + rect: Rect, + st: &WebSettings, + icc: Option<&[u8]>, + exif: Option<&[u8]>, + xmp: Option<&str>, + dpi: f32, + preview: bool, +) -> Result { if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) { return Err(other("slice is outside the pixel buffer")); @@ -460,8 +488,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } // Save for Web's 0–100 quality → encoder 1–100. let q = st.quality.max(1); - let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), xmp) - .map_err(e)?; + let bytes = + photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), exif, xmp) + .map_err(e)?; if preview { // The preview shows the compression artefacts. look = match photocraft_codecs::decode(&bytes) { @@ -873,7 +902,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u let bw = wdoc.size.width as usize; let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let dpi = wdoc.resolution_dpi; + let warnings = web_warnings(&st, exif, dpi); let all = slices::resolve(&doc); let pick = p.get("slices").and_then(Value::as_str).unwrap_or("all"); let numbers: Option> = p.get("numbers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); @@ -895,18 +926,18 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u } // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; return Ok( json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); + return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings})); } let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); @@ -961,7 +992,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u cells.push((piece, bg, text)); continue; } - let o = optimize(&buf.px, bw, piece, &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; let stem = if split { format!("{}_{:02}", slice_file(&r.name), k + 1) } else { slice_file(&r.name) }; let name = unique(&stem, o.ext); let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; @@ -997,7 +1028,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) + Ok( + json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height, "warnings": warnings}), + ) } // ---------- Export Preferences / Quick Export ---------- @@ -1108,19 +1141,21 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { let (wdoc, _, _) = web_document(&doc, &json!({}), &st)?; let buf = photocraft_compose::flatten(&wdoc); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let opt = optimize( &buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, wdoc.icc_profile.as_deref().map(|v| v.as_slice()), + exif, xmp.as_deref(), wdoc.resolution_dpi, false, )?; write_file(&path, &opt.bytes)?; crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len()})) + Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len(), "warnings": web_warnings(&st, exif, wdoc.resolution_dpi)})) } // ---------- Generate › Image Assets ---------- @@ -1430,7 +1465,7 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); - let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, wdoc.resolution_dpi, false)?; + let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, None, wdoc.resolution_dpi, false)?; let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; written.insert(key); @@ -1504,7 +1539,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, has_doc, save_for_web ), @@ -1522,7 +1557,7 @@ pub fn specs() -> Vec { "Quick Export", &[], None, - r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes}"##, + r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes, warnings}"##, native_doc, quick_export ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs index 1d319cb382..0c25ce0d24 100644 --- a/crates/engine/src/web_cmds/color_table_tests.rs +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -7,13 +7,13 @@ fn color_replacement_preserves_pixel_membership_and_transparency() { for dither in ["none", "diffusion", "pattern", "noise"] { let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"}); let original = WebSettings::from_params(&p, "test").unwrap(); - let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap(); + let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, None, 72.0, true).unwrap(); let mut edited = p.clone(); // Replace red with blue: re-quantization would now send all opaque pixels to blue. edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"}); let settings = WebSettings::from_params(&edited, "test").unwrap(); assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings); - let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap(); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, None, 72.0, true).unwrap(); for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { if old[3] == 0 { assert_eq!(new[3], 0); @@ -81,8 +81,8 @@ fn malformed_tables_and_out_of_bounds_slices_return_errors() { } let settings = WebSettings::default(); for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] { - assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err()); + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, None, 72.0, false).is_err()); } let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap(); - assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err()); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, None, 72.0, true).is_err()); } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index bd55a549cb..5791e5f27c 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -735,7 +735,7 @@ fn previews_show_the_optimised_pixels() { let buf = photocraft_compose::flatten(&wd); for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap(); - let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); // Red square centre stays red (WBMP: dark). let i = (16 * 64 + 16) * 4; @@ -745,7 +745,7 @@ fn previews_show_the_optimised_pixels() { } else { assert!(px[0] > 200 && px[1] < 60 && px[2] < 60, "{f}: {px:?}"); } - let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, false).unwrap(); + let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, false).unwrap(); assert!(none.preview.is_empty()); assert_eq!(none.bytes, o.bytes); } @@ -756,7 +756,7 @@ fn jpeg_preview_at_odd_sizes() { for (w, h, q) in [(640usize, 431usize, 90), (640, 430, 60), (333, 222, 30)] { let px: Vec<[f32; 4]> = (0..w * h).map(|i| [((i % w) as f32 / w as f32), 0.3, ((i / w) as f32 / h as f32), 1.0]).collect(); let st = WebSettings::from_params(&json!({"format": "jpeg", "quality": q}), "x").unwrap(); - let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, None, 72.0, true).unwrap(); for &(x, y) in &[(10usize, 10usize), (w - 10, h / 2), (w / 2, h - 5)] { let i = (y * w + x) * 4; let want = [(x as f32 / w as f32 * 255.0) as i32, 76, (y as f32 / h as f32 * 255.0) as i32]; @@ -814,3 +814,166 @@ fn overlapping_slices_keep_the_later_slice_in_html() { #[path = "color_table_tests.rs"] mod color_table_tests; + +/// A little-endian camera EXIF: IFD0 with Make, Orientation 6, 300 ppi and pointers to an Exif +/// IFD (DateTimeOriginal) and a GPS IFD (GPSLatitudeRef); IFD0 links to an IFD1 thumbnail. +fn camera_exif() -> Vec { + let entry = |v: &mut Vec, tag: u16, ty: u16, count: u32, value: u32| { + v.extend_from_slice(&tag.to_le_bytes()); + v.extend_from_slice(&ty.to_le_bytes()); + v.extend_from_slice(&count.to_le_bytes()); + v.extend_from_slice(&value.to_le_bytes()); + }; + let (ifd0, data0) = (8u32, 8 + 2 + 7 * 12 + 4); + let (make, xres, yres) = (data0, data0 + 10, data0 + 18); + let exif_ifd = data0 + 26; + let date = exif_ifd + 2 + 12 + 4; + let gps_ifd = date + 20; + let ifd1 = gps_ifd + 2 + 12 + 4; + let mut v = b"II*\0".to_vec(); + v.extend_from_slice(&ifd0.to_le_bytes()); + v.extend_from_slice(&7u16.to_le_bytes()); + entry(&mut v, 0x010F, 2, 9, make); + entry(&mut v, 0x0112, 3, 1, 6); + entry(&mut v, 282, 5, 1, xres); + entry(&mut v, 283, 5, 1, yres); + entry(&mut v, 296, 3, 1, 2); + entry(&mut v, 0x8769, 4, 1, exif_ifd); + entry(&mut v, 0x8825, 4, 1, gps_ifd); + v.extend_from_slice(&ifd1.to_le_bytes()); + v.extend_from_slice(b"TestMake\0\0"); + for _ in 0..2 { + v.extend_from_slice(&300u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + } + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x9003, 2, 20, date); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(b"2024:05:01 10:30:00\0"); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0001, 2, 2, u32::from_le_bytes(*b"N\0\0\0")); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0103, 3, 1, 6); + v.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(v.len(), ifd1 as usize + 18); + v +} + +/// The payload (after `Exif\0\0`) of the file's EXIF APP1 segment, read from the markers. +fn exif_app1(bytes: &[u8]) -> Option> { + let mut i = 2; + while i + 4 <= bytes.len() && bytes[i] == 0xFF && bytes[i + 1] != 0xDA { + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + let body = &bytes[i + 4..i + 2 + len]; + if bytes[i + 1] == 0xE1 && body.starts_with(b"Exif\0\0") { + return Some(body[6..].to_vec()); + } + i += 2 + len; + } + None +} + +/// IFD0's entries (tag → inline value) and its link to the next IFD, little-endian only. +fn ifd0(tiff: &[u8]) -> (std::collections::HashMap, u32) { + let u16_at = |o: usize| u16::from_le_bytes([tiff[o], tiff[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes([tiff[o], tiff[o + 1], tiff[o + 2], tiff[o + 3]]); + assert_eq!(&tiff[..4], b"II*\0"); + let at = u32_at(4) as usize; + let n = u16_at(at) as usize; + let tags = (0..n) + .map(|k| { + let e = at + 2 + k * 12; + let value = if u16_at(e + 2) == 3 { u32::from(u16_at(e + 8)) } else { u32_at(e + 8) }; + (u16_at(e), value) + }) + .collect(); + (tags, u32_at(at + 2 + n * 12)) +} + +fn photo_session(exif: Vec) -> Session { + let mut s = session(8); + s.execute("file.fileInfo", json!({"copyright": "© Example"})).unwrap(); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(exif)); + Ok(()) + }) + .unwrap(); + s +} + +/// #7436: Save for Web's "All" writes the photo's EXIF, as Save a Copy does: upright +/// (Orientation 1), without the source's thumbnail and at the exported resolution. Every other +/// choice, "All Except Camera Info" included, leaves the camera data (and GPS) out. +#[test] +fn save_for_web_all_writes_the_photos_exif() { + let dir = tmp("exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": metadata, "path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + let exif = exif_app1(&all).expect("an EXIF APP1 segment"); + let camera = photocraft_algo::exif::read(&exif); + assert_eq!(camera.make.as_deref(), Some("TestMake")); + assert_eq!(camera.date_taken.as_deref(), Some("2024:05:01 10:30:00")); + let (tags, next) = ifd0(&exif); + assert_eq!(tags.get(&0x0112), Some(&1), "the exported pixels are already upright"); + assert!(tags.contains_key(&0x8825), "GPS kept"); + assert_eq!(next, 0, "the source's thumbnail is not linked"); + assert_eq!(photocraft_codecs::exif_resolution(&exif), Some((72.0, 72.0)), "the document's resolution, not the source's 300"); + assert!(decode(&format!("{dir}/all.jpg")).meta.xmp.unwrap_or_default().contains("Example")); + for m in ["none", "copyright", "copyrightAndContact", "allExceptCameraInfo"] { + let (bytes, r) = export(&mut s, m); + assert_eq!(exif_app1(&bytes), None, "{m}"); + assert_eq!(r["warnings"], json!([]), "{m}"); + } + assert_eq!(r["warnings"], json!([]), "it fits"); + let xmp = decode(&format!("{dir}/allExceptCameraInfo.jpg")).meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example"), "all the XMP"); + assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); +} + +#[test] +fn quick_export_all_writes_the_photos_exif() { + let dir = tmp("quick_exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": metadata})).unwrap(); + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.quickExport", json!({"path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + assert_eq!(photocraft_algo::exif::read(&exif_app1(&all).expect("EXIF")).make.as_deref(), Some("TestMake")); + assert_eq!(r["warnings"], json!([])); + assert_eq!(exif_app1(&export(&mut s, "copyright").0), None); + assert_eq!(exif_app1(&export(&mut s, "none").0), None); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +/// EXIF too large for its one segment is left out and said so; a truncated one doesn't panic. +#[test] +fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { + let dir = tmp("exif_bad"); + let mut big = camera_exif(); + big.resize(70_000, 0); + let mut s = photo_session(big); + let path = format!("{dir}/big.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(exif_app1(&std::fs::read(&path).unwrap()), None); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF (70000 bytes)")), "{r}"); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": "all"})).unwrap(); + let r = s.execute("file.export.quickExport", json!({"path": format!("{dir}/quick.jpg")})).unwrap(); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF")), "{r}"); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); + + for cut in [3, 9, 40, 100] { + let mut s = photo_session(camera_exif()[..cut].to_vec()); + let path = format!("{dir}/cut{cut}.jpg"); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(decode(&path).dimensions(), (64, 48)); + } +} diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index d1b2f0e551..a84bfe7779 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -1674,7 +1674,7 @@ mod web_playback_tests { let doc = web_frame_document(&app.session.active().unwrap().doc, Some(frame)).unwrap(); let (proxy, _, _) = web_cmds::web_document(&doc, &json!({"width":16,"height":12}), &settings).unwrap(); let pixels = photocraft_compose::flatten(&proxy); - let optimized = web_cmds::optimize(&pixels.px, 16, photocraft_geom::Rect::new(0, 0, 16, 12), &settings, None, None, 72.0, true).unwrap(); + let optimized = web_cmds::optimize(&pixels.px, 16, photocraft_geom::Rect::new(0, 0, 16, 12), &settings, None, None, None, 72.0, true).unwrap(); assert_eq!(&optimized.preview[..4], expected, "depth={depth} frame={frame}"); } } diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs index a9ee7a933c..b603584573 100644 --- a/crates/ui-egui/src/file_ui/web_preview.rs +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -60,8 +60,8 @@ pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result< let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?; f.insert("__paletteApproximate".into(), json!(scale < 1.0)); let buf = photocraft_compose::flatten(&doc); - let mut optimized = - web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, doc.resolution_dpi, false).map_err(|e| e.to_string())?; + let mut optimized = web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, None, doc.resolution_dpi, false) + .map_err(|e| e.to_string())?; // Tiny transparent features may disappear in the proxy; keep room for their index. if settings.transparency { optimized.color_table.truncate(255); @@ -137,7 +137,7 @@ pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, p } else { let settings = WebSettings::from_params(&base, "preview").map_err(|e| e.to_string())?; let encoded = Arc::new( - web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, 72.0, true) + web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, None, 72.0, true) .map_err(|e| e.to_string())?, ); ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone()))); From 8736098d38f6b5917f20996a4eb8bc8a4ccde9c4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:23:16 +0200 Subject: [PATCH 167/304] Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (#7473) * Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (part of #7325) With Image > Adjustments > Curves open, a Cmd-click (Ctrl-click off the Mac) on the image adds a point to the channel shown in the dialog, at the pixel's tone and at the curve's current output there; Shift adds one point on each of Red, Green and Blue. Only the press adds, an armed eyedropper still wins, and the control channel's ui.pointer does the same with command: true. * Curves: Cmd/Ctrl-click adds the point with the Hand tool too; Space and middle button still pan The Hand tool took the Cmd-click press for a pan, so no point was added; the press now counts before the Hand. On the control channel, ui.pointer with space or the middle button pans as the mouse does instead of adding a point. * Curves: the Cmd/Ctrl-click point's comment says where the curve can still change --- crates/ui-egui/src/adjust_dialog.rs | 228 +++++++++++++++++++++++++++ crates/ui-egui/src/adjust_editors.rs | 2 +- crates/ui-egui/src/canvas.rs | 21 ++- crates/ui-egui/src/control.rs | 7 + crates/ui-egui/src/input_tests.rs | 50 ++++++ docs/control-protocol.md | 1 + 6 files changed, 307 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index be9acde2e8..fe483caef9 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -79,6 +79,101 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { true } +/// Whether the top modal is the Curves dialog. +pub fn curves_on_top(app: &PhotocraftApp) -> bool { + app.ui.dialogs.last().is_some_and(|d| d.fields.get("__adjust").and_then(Value::as_str) == Some("curves")) +} + +/// ⌘/Ctrl-click on the image with Curves on top (#7325): a point on the curve shown in the dialog +/// at the clicked pixel's tone, at the output the curve gives there rounded to a whole level, so the +/// image barely changes (where the curve is clipped at 0 or 255 the point re-shapes it, as a click on +/// the graph there does); with `every_channel` (Shift) one on each colour channel instead, at that +/// channel's value. +/// The pixel comes from the same merged composite as the eyedroppers. Returns true when Curves is +/// on top: the click is the dialog's even when nothing is added (pencil curve, Lab or CMYK, a mask +/// or saved channel target, outside the image, a full curve). +pub fn add_point_at(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, every_channel: bool) -> bool { + if !curves_on_top(app) { + return false; + } + let Some((id, mut values)) = app.ui.dialogs.last().map(|d| (d.id, crate::filter_dialog::params_of(&d.fields))) else { return false }; + if values.get("pencil").and_then(Value::as_bool) == Some(true) { + return true; + } + let Some(mode) = app.session.active().map(|s| s.doc.mode) else { return true }; + if !matches!(mode, photocraft_doc::ColorMode::Rgb | photocraft_doc::ColorMode::Grayscale) { + app.ui.status = tl!("Unavailable in this color mode").into(); + return true; + } + // A mask, saved channel or Quick Mask holds its own gray levels, not the composite's. + let isolated = isolated(app); + if isolated.as_ref().is_some_and(|c| c.gray.is_some()) { + return true; + } + let Some(sample) = crate::canvas::composite_color(app, x, y) else { return true }; + let gray = adjust_editors::is_gray(mode); + let (shown, shown_key) = match &isolated { + Some(c) => (0, c.key), + None => { + let ch: usize = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves-ch"))).unwrap_or(0); + let chans = adjust_editors::tone_channels(adjust_editors::space_of(&values), gray); + (ch, chans.get(ch).map_or("", |c| c.key)) + } + }; + let keys: &[&str] = if every_channel && !gray && isolated.is_none() { &["red", "green", "blue"] } else { &[shown_key] }; + let mut selected = None; + let mut changed = false; + for &key in keys { + let level = match key { + "red" => sample[0], + "green" => sample[1], + "blue" => sample[2], + // The composite's tone: Gray's mean, as the Grayscale eyedroppers read it, and RGB's + // Rec. 709 luma, as the Neutral Gray eyedropper does (`adjust_params::rgb_luma`). + _ if gray => (sample[0] + sample[1] + sample[2]) / 3.0, + _ => sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722, + }; + let input = (level.clamp(0.0, 1.0) * 255.0).round(); + let field = if key.is_empty() { "points" } else { key }; + let mut pts = adjust_editors::read_curve(&values, field); + let curve: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&curve); + let at = (input / 255.0 * lut.len().saturating_sub(1) as f32).round() as usize; + let Some(output) = lut.get(at).map(|o| (o * 255.0).round()) else { continue }; + let before = pts.len(); + let Some(i) = crate::point_curve::insert(&mut pts, [input, output]) else { continue }; + if pts.len() > before { + // Whole levels, as the editor writes a point clicked on the graph. + values[field] = json!(pts.iter().map(|q| [q[0].round(), q[1].round()]).collect::>()); + changed = true; + } + if key == shown_key { + selected = Some(i); + } + } + if let Some(i) = selected { + let state = dialog_mem("curves").with("curves"); + ctx.data_mut(|d| { + let mut st: crate::state::CurvesEditorState = d.get_temp(state).unwrap_or_default(); + st.channel = shown; + st.gesture = crate::state::PointCurveState { selected: Some(i), drag: None }; + d.insert_temp(state, st); + }); + } + if changed + && let Some(dialog) = app.ui.dialog_mut(id) + && let Value::Object(v) = values + { + dialog.fields.retain(|k, _| k.starts_with("__")); + dialog.fields.extend(v); + if picker(dialog.fields.get(CURVE_PICKER)).is_some() { + let base = crate::filter_dialog::params_of(&dialog.fields); + dialog.fields.insert(CURVE_PICKER_BASE.into(), base); + } + } + true +} + pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, tooltip: &str, color: egui::Color32) -> egui::Response { let response = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, "pipette", 28.0, selected, tooltip)).inner; let tokens = crate::theme::Tokens::get(ui.ctx()); @@ -767,6 +862,139 @@ mod tests { } } + fn pointer_down(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, modifiers: Value) { + let (request, _receiver) = + crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": x, "y": y}], "modifiers": modifiers})); + let _ = crate::control::handle(app, ctx, &request); + } + + /// The Curves curve `pts` (0-255 levels) as a 256-entry 0-255 map. + fn curve_levels(pts: &[[f64; 2]]) -> Vec { + let cp: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] as f32 / 255.0, output: q[1] as f32 / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&cp); + (0..256).map(|k| lut[(k as f32 / 255.0 * (lut.len() - 1) as f32).round() as usize] * 255.0).collect() + } + + /// #7325: ⌘/Ctrl-click on the image puts a point on the shown curve at the pixel's tone + /// (#b04020: Rec. 709 luma 85.5 → level 86) and at the curve's own output there, so the + /// image does not change; the new point is the selected one, and the document waits for OK. + #[test] + fn command_click_on_the_image_adds_a_point_on_the_composite_curve() { + let mut app = app_with_image(); + let original = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let base = [[0.0, 0.0], [128.0, 200.0], [255.0, 255.0]]; + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(base)); + let ctx = egui::Context::default(); + let sample = crate::canvas::composite_color(&mut app, 8.0, 8.0).unwrap(); + let level = ((sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722) * 255.0).round(); + assert_eq!(level, 86.0); + let output = f64::from(curve_levels(&base)[86].round()); + assert_ne!(output, 86.0, "the base curve is not the identity at the sampled level"); + + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + let points = channel_points(&app, "points"); + assert_eq!(points, vec![base[0], [86.0, output], base[1], base[2]]); + let (before, after) = (curve_levels(&base), curve_levels(&points)); + assert!(before.iter().zip(&after).all(|(a, b)| (a - b).abs() <= 0.5), "the curve keeps its shape:\n{before:?}\n{after:?}"); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (0, Some(1)), "the new point is selected"); + for key in ["red", "green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + } + + /// #7325: Shift+⌘-click puts a point on each colour channel at that channel's own value + /// (176, 64, 32), and leaves the composite curve alone. + #[test] + fn shift_command_click_adds_a_point_on_each_colour_channel() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true, "shift": true})); + for (key, level) in [("red", 176.0), ("green", 64.0), ("blue", 32.0)] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [level, level], [255.0, 255.0]], "{key}"); + } + assert_eq!(channel_points(&app, "points"), composite); + } + + /// #7325: with Red shown in the Channel popup the point goes on the red curve, at red's value. + #[test] + fn command_click_adds_the_point_on_the_channel_shown_in_the_dialog() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_temp(dialog_mem("curves").with("curves-ch"), 1usize)); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "red"), vec![[0.0, 0.0], [176.0, 176.0], [255.0, 255.0]]); + for key in ["green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert_eq!(channel_points(&app, "points"), composite); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (1, Some(1))); + } + + /// #7325: a plain click, a full curve and an armed eyedropper add nothing. + #[test] + fn command_click_adds_nothing_without_the_modifier_on_a_full_curve_or_with_an_eyedropper() { + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let before = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({})); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "no modifier, no point"); + // Space and the middle button pan, as with the mouse. + for extra in [json!({"modifiers": {"command": true, "space": true}}), json!({"command": true, "button": "middle"})] { + let mut params = json!({"events": [{"kind": "down", "x": 8, "y": 8}]}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let (request, _receiver) = crate::control::ControlRequest::new("ui.pointer", params); + let _ = crate::control::handle(&mut app, &ctx, &request); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "{extra}"); + } + + // Sixteen points (the most a curve holds), none within reach of level 86. + let full: Vec<[f64; 2]> = + [0, 10, 20, 30, 40, 50, 60, 70, 80, 100, 120, 140, 160, 180, 200, 255].iter().map(|&i| [f64::from(i), f64::from(i)]).collect(); + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(full)); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points"), full, "a full curve takes no more points and moves none"); + + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + arm(&mut app, id, "black"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points").len(), 2, "the armed eyedropper samples instead"); + assert_eq!(channel_points(&app, "red")[0], [176.0, 0.0]); + } + + /// #7325: Lab and CMYK curves and a targeted mask are left to a later change: nothing is added. + #[test] + fn command_click_adds_nothing_in_lab_or_on_a_targeted_mask() { + let ctx = egui::Context::default(); + let mut lab = app_in_mode("lab"); + lab.run("edit.fill", json!({"color": "#b04020"})).unwrap(); + let id = open(&mut lab, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut lab, &ctx, 4.0, 4.0, false)); + assert_eq!(crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields), before); + + let mut masked = app_with_image(); + masked.run("layer.layerMask.revealAll", json!({})).unwrap(); + masked.run("channel.target", json!({"channel": "mask"})).unwrap(); + let id = open(&mut masked, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut masked, &ctx, 8.0, 8.0, false)); + assert_eq!(crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields), before); + } + #[test] fn ui_pointer_drives_an_armed_curves_picker() { let mut app = app_with_image(); diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 87a81b8c35..fa32a3b392 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -362,7 +362,7 @@ fn curve_graph(full: Rect, side: f32) -> Rect { Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING)) } -fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { +pub(crate) fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { let pts: Vec<[f32; 2]> = v .get(key) .and_then(Value::as_array) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0d5fbe1b81..2c04534962 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3438,7 +3438,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701). let shadow_drag = !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag; + // ⌘/Ctrl-click with Curves open: a point at the pixel's tone (#7325), whatever the tool. The + // press's own modifiers decide, and only the press adds: a drag would add one per frame. + let curve_point_press = (primary && !middle && crate::adjust_dialog::curves_on_top(app)) + .then(|| { + ctx.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + }) + }) + .flatten() + .map(|m| crate::workspace_ui::sticky_mods(app, m)) + .filter(|m| m.command); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none(); if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3483,6 +3497,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let press = crate::dialogs::free_press(&ctx, rect).map(|q| xf.to_doc(q)); crate::select_mask_view::canvas_brush(app, &painter, p, press, xf.zoom_x()); + } else if let Some(mods) = curve_point_press + && let Some(p) = crate::dialogs::free_press(&ctx, rect) + { + let d = xf.to_doc(p); + crate::adjust_dialog::add_point_at(app, &ctx, d[0], d[1], mods.shift); } else if primary && !middle && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect).filter(|p| img_rect.contains(*p)) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 05540339a5..581925f83c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1043,6 +1043,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } + // ⌘/Ctrl-click with Curves open adds a point at the pixel's tone, on press only (#7325). + if !secondary && !space && button != "middle" && mods.command && crate::adjust_dialog::curves_on_top(app) { + if matches!(ev, ToolEvent::Down { .. }) { + crate::adjust_dialog::add_point_at(app, ctx, x, y, mods.shift); + } + continue; + } // A Properties Levels/Curves eyedropper owns the primary button the same way. if app.ui.dialogs.is_empty() && !matches!(s("button"), Some("secondary" | "right")) && crate::layer_picker::armed(app).is_some() { if matches!(ev, ToolEvent::Down { .. }) { diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..e06182870c 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -317,6 +317,56 @@ fn curves_picker_samples_document_coordinates_through_the_view_transform() { assert!((red - 32.0).abs() < 0.6, "sampled document-right blue patch through the flipped 400% view: {red}"); } +/// #7325: ⌘/Ctrl-click on the image with Curves open adds a point at the pixel's tone. The +/// modifier is the press's own, a drag onto another tone adds nothing more, and the Hand tool +/// does not take the press for a pan. +#[test] +fn command_click_under_curves_adds_one_point_per_press_not_per_frame() { + // The Hand tool too: the press is the dialog's, not the start of a pan. + for hand in [false, true] { + let mut h = harness(); + if hand { + h.state_mut().ui.tool = crate::state::Tool::Hand; + } + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 80, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#804020"})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 80, "y": 0, "width": 320, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#20a0e0"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + let view = &mut h.state_mut().ui.views[0]; + (view.zoom, view.center, view.fit_pending) = (4.0, [200.0, 150.0], false); + let dialog = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(3); + let points = |h: &Harness<'static, PhotocraftApp>| { + let d = h.state().ui.dialogs.iter().find(|d| d.id == dialog).unwrap(); + d.fields.get("points").and_then(serde_json::Value::as_array).map_or(0, Vec::len) + }; + let canvas = h.state().last_canvas_rect; + // Document x 65 (the brown strip) and 100 (the blue), both left of the centred dialog. + let (brown, blue) = (pos2(canvas.left() + 60.0, canvas.center().y), pos2(canvas.left() + 200.0, canvas.center().y)); + h.hover_at(brown); + h.run_steps(1); + press(&mut h, brown, true); + press(&mut h, brown, false); + assert_eq!(points(&h), 2, "a plain click adds no point (hand: {hand})"); + + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::COMMAND }; + // ⌘ stays held through the drag, so only the press can tell the frames apart. + h.event(egui::Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(button(brown, true)); + h.run_steps(1); + assert_eq!(points(&h), 3, "the press adds the point (hand: {hand})"); + for t in [0.25, 0.5, 0.75, 1.0] { + h.event(egui::Event::PointerMoved(brown + (blue - brown) * t)); + h.run_steps(1); + } + h.event(button(blue, false)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(points(&h), 3, "dragging onto another tone adds nothing more (hand: {hand})"); + } +} + /// Type tool (#206): Alt+←/→ at a collapsed caret kerns the pair before it by 20/1000 em (100 /// with ⌘/Ctrl), one history step per press; ⌘/Ctrl+←/→ moves by word; Alt+Shift+→ extends /// the selection by a word. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c62e9046d2..e6d5c9591d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -75,6 +75,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. + - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988) From 72a3423a7e7ca227e4eb11a65218596b03ca3299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Minh=20Long?= Date: Mon, 19 Oct 2026 04:23:21 +0700 Subject: [PATCH 168/304] Preferences: Enable Gestures off ignores touch-screen pinch and twist (#204) (#7478) Edit > Preferences > Enhanced Controls > Enable Gestures (enhancedControls.touchGestures) was saved but read by nothing, so it was hidden from the dialog. Now, with it off, two fingers on a touch screen no longer zoom the canvas or the Navigator, nor turn the view. The mouse and trackpad behave as before, including a trackpad pinch or Rotate event that arrives while fingers rest on the screen. - wheel_nav: pinch_factor() drops egui's multi-touch zoom when gestures are off and keeps the trackpad's own Event::Zoom (Windows' folded Ctrl + wheel pinch included); read_with() takes the preference. - rotate_view: trackpad_delta() only falls back to the multi-touch rotation while gestures are on; Rotate events always count. - canvas, Navigator: pass the preference through. - prefs: touchGestures leaves HIDDEN_UNTIL_IMPLEMENTED, so the dialog shows it. - Tests: synthetic two-finger pinch and twist, on and off, and the trackpad events with gestures off. - docs/scorecard.md regenerated: settings that do nothing 26 -> 25 of 168. --- crates/engine/src/prefs.rs | 1 - crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/prefs_ui.rs | 6 +- crates/ui-egui/src/rotate_view.rs | 48 ++++++++++++++-- crates/ui-egui/src/wheel_nav.rs | 91 +++++++++++++++++++++++++++++-- docs/scorecard.md | 6 +- 7 files changed, 144 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2c2507226d..3e87d43fc7 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1040,7 +1040,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "type.textEngine", "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", - "enhancedControls.touchGestures", "rawDefaults.colorSpace", "rawDefaults.bitDepth", "rawDefaults.resolution", diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c04534962..60e9fbaedf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3327,6 +3327,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). + // Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor + // turn the view (#204); the mouse and trackpad are unaffected. + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; let wheel = crate::wheel_nav::read_with( &ctx, app.session.prefs().general.zoom_with_scroll_wheel, @@ -3334,6 +3337,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: response.id.with("wheel"), nav_hovered, app.session.prefs().interface.enable_animations, + touch_gestures, ); if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -3366,7 +3370,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: _ => {} } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; - if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad) { + if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad, touch_gestures) { let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f2961fd7b6..9a1b07f2b7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1980,8 +1980,10 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Use the canvas wheel classifier to honour one ×1.1 step per notch without // applying egui's smoothed wheel tail a second time. let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; if resp.hovered() - && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true) + && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = + crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true, true, touch_gestures) { let view = &mut app.ui.views[idx]; let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 7213407024..4156e380cd 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3506,11 +3506,13 @@ mod tests { assert!(!prefs::is_hidden("type.useEscToCommit")); assert!(!prefs::is_hidden("type.smartQuotes")); assert!(!prefs::is_hidden("type.showFontNamesInEnglish")); - // Rotate View with Trackpad and the pinch zoom are live; the other Enhanced Controls rows - // stay hidden. + // Rotate View with Trackpad, the pinch zoom and Enable Gestures are live; the other + // Enhanced Controls rows stay hidden. assert!(has_visible_fields(&values, "enhancedControls")); assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad")); assert!(!prefs::is_hidden("enhancedControls.zoomWithTrackpadPinch")); + assert!(!prefs::is_hidden("enhancedControls.touchGestures")); + assert!(prefs::is_hidden("enhancedControls.scrubbySliderAcceleration")); // Camera Raw Defaults shows only "Open in Camera Raw" so far. assert!(has_visible_fields(&values, "rawDefaults")); assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 5e173da5dc..5137b9d312 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -4,7 +4,7 @@ //! decreases it in a flipped view). Shift snaps to 15°. Reset View, //! Escape, a double-click, or the on-canvas compass put the angle back to 0. History is not //! recorded. Trackpad rotate is gated by Preferences › Enhanced Controls › Rotate View with -//! Trackpad. +//! Trackpad; a two-finger twist on a touch screen also needs Enable Gestures (#204). use egui::{Align2, Pos2, Rect, Stroke, pos2, vec2}; use serde_json::{Value, json}; @@ -137,7 +137,9 @@ pub fn apply_trackpad(current: f32, enabled: bool, delta_rad: f32) -> Option Option { +/// The trackpad's own `Rotate` events always count; a touch screen's two-finger twist (egui's +/// multi-touch rotation) only while `touch_gestures` (Enhanced Controls › Enable Gestures) is on. +pub fn trackpad_delta(ctx: &egui::Context, enabled: bool, touch_gestures: bool) -> Option { if !enabled { return None; } @@ -150,7 +152,13 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool) -> Option { _ => None, }) .sum(); - if from_events != 0.0 { from_events } else { i.rotation_delta() } + if from_events != 0.0 { + from_events + } else if touch_gestures { + i.rotation_delta() + } else { + 0.0 + } }); apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0) } @@ -324,8 +332,8 @@ mod tests { let mut disabled = None; let raw = egui::RawInput { events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], ..Default::default() }; let mut output = ctx.run_ui(raw, |ui| { - enabled = trackpad_delta(ui.ctx(), true); - disabled = trackpad_delta(ui.ctx(), false); + enabled = trackpad_delta(ui.ctx(), true, true); + disabled = trackpad_delta(ui.ctx(), false, true); }); output.textures_delta.clear(); @@ -333,6 +341,36 @@ mod tests { assert_eq!(disabled, None); } + /// Preferences › Enhanced Controls › Enable Gestures (#204): off, a two-finger twist on a + /// touch screen doesn't turn the view; the trackpad's `Rotate` event still does. + #[test] + fn enable_gestures_switches_touch_screen_rotation() { + use egui::TouchPhase::{Move, Start}; + let touch = |id: u64, phase, pos| Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos, force: None }; + let twist = |touch_gestures: bool, extra: Vec| { + let ctx = egui::Context::default(); + let frames = [ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(pos2(50.0, 50.0))], + vec![touch(1, Start, pos2(40.0, 50.0)), touch(2, Start, pos2(60.0, 50.0))], + // A quarter turn about the centre between the fingers. + [vec![touch(1, Move, pos2(50.0, 40.0)), touch(2, Move, pos2(50.0, 60.0))], extra].concat(), + ]; + let mut last = None; + for (n, events) in (0u32..).zip(frames) { + let raw = egui::RawInput { time: Some(f64::from(n) / 60.0), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| last = trackpad_delta(ui.ctx(), true, touch_gestures)); + out.textures_delta.clear(); + } + last + }; + let on = twist(true, Vec::new()); + assert!(on.is_some_and(|d| (d.abs() - 90.0).abs() < 1e-3), "on: the fingers turn the view: {on:?}"); + assert_eq!(twist(false, Vec::new()), None, "off: the fingers don't"); + let trackpad = twist(false, vec![Event::Rotate(std::f32::consts::FRAC_PI_6)]); + assert!(trackpad.is_some_and(|d| (d - 30.0).abs() < 1e-3), "off: a trackpad rotate still turns: {trackpad:?}"); + } + #[test] fn trackpad_pref_gates_rotation() { assert_eq!(apply_trackpad(10.0, false, 0.5), None); diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index bde5e71ae3..68a2321c44 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -8,6 +8,9 @@ //! - A trackpad pinch zooms around the pointer, unless Preferences › Enhanced Controls › //! Zoom with Trackpad Pinch is off (then a pinch does nothing, and Windows' Ctrl + wheel //! fractions scroll sideways like the wheel, because the fold below is skipped). +//! - A two-finger pinch on a touch screen zooms the same way, unless Preferences › Enhanced +//! Controls › Enable Gestures is off (#204): then the fingers leave the view alone, and a +//! trackpad pinch still zooms ([`pinch_factor`]). //! //! [`configure`] makes egui fold ⌘/Ctrl + wheel into a sideways scroll as it does ⇧ + wheel, so //! only a real pinch (egui's `Event::Zoom`, or a touch screen) reaches `zoom_delta`. macOS and @@ -23,7 +26,7 @@ //! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next //! wheel event: releasing Alt while the tail is still settling must not turn it into a pan. -use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, Vec2}; +use egui::{Context, Event, Id, InputState, Modifiers, MouseWheelUnit, RawInput, Vec2}; /// Zoom factor of one wheel notch: every step is exactly ×1.1. pub const NOTCH: f32 = 1.1; @@ -198,13 +201,15 @@ const SNAP_IDLE_TIMEOUT: f64 = 0.5; /// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving /// navigation may add raw input to the snap residual. pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { - read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true) + read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true, true) } /// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's /// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch /// in the frame the wheel turns and stops there (#5721), as scroll areas already do. -pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool) -> Option { +/// `touch_gestures` is Preferences › Enhanced Controls › Enable Gestures: off, a touch screen's +/// two-finger pinch doesn't zoom ([`pinch_factor`]). +pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool, touch_gestures: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); // --- Modifier tracking (always runs for all callers) --- @@ -225,7 +230,7 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: _ => None, }) .collect(); - (i.smooth_scroll_delta, i.zoom_delta(), latest_alt, latest_shift, wheel, i.content_rect().height()) + (i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height()) }); let alt = match latest_alt { Some(a) => { @@ -329,6 +334,24 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: } } +/// This frame's pinch zoom factor (1 = none). While two fingers are down on a touch screen, egui's +/// `zoom_delta` reports their pinch in place of the trackpad's `Event::Zoom`. With Preferences › +/// Enhanced Controls › Enable Gestures off (`touch_gestures` false) the fingers are ignored and only +/// the trackpad's own pinch events count (Windows' Ctrl + wheel pinch included: [`fold_legacy_pinch`] +/// has already turned it into `Event::Zoom`). +pub fn pinch_factor(i: &InputState, touch_gestures: bool) -> f32 { + if touch_gestures || i.multi_touch().is_none() { + return i.zoom_delta(); + } + i.events + .iter() + .filter_map(|e| match e { + Event::Zoom(f) if f.is_finite() && *f > 0.0 => Some(*f), + _ => None, + }) + .product() +} + #[cfg(test)] mod tests { use egui::TouchPhase; @@ -393,7 +416,7 @@ mod tests { let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; let mut pan = Vec2::ZERO; let mut out = ctx.run_ui(raw, |ui| { - if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth) { + if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth, true) { pan = d; } }); @@ -922,4 +945,62 @@ mod tests { let (_, _, snaps) = gesture(vec![vec![wheel(Vec2::new(0.0, -1.0), Modifiers::SHIFT)]], true, false); assert_eq!(snaps, -1, "Line-unit notch down produces −1 step"); } + + /// Two fingers on a touch screen: down 20 points apart, spread to 40 (a ×2 pinch), lifted. + fn touch_pinch_frames() -> Vec> { + let touch = |id: u64, phase: TouchPhase, x: f32| Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(id), + phase, + pos: egui::pos2(x, 50.0), + force: None, + }; + vec![ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(egui::pos2(40.0, 50.0))], + vec![touch(1, TouchPhase::Start, 40.0), touch(2, TouchPhase::Start, 60.0)], + vec![touch(1, TouchPhase::Move, 30.0), touch(2, TouchPhase::Move, 70.0)], + vec![touch(1, TouchPhase::End, 30.0), touch(2, TouchPhase::End, 70.0)], + ] + } + + /// The total zoom [`read_with`] reports over `frames`, Enable Gestures set to `touch_gestures`. + fn touch_zoom(frames: Vec>, touch_gestures: bool) -> f32 { + let owner = Id::new("test-touch"); + let ctx = Context::default(); + configure(&ctx); + let mut zoom = 1.0; + for (n, events) in (0u32..).zip(frames) { + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::Zoom(f)) = read_with(ui.ctx(), false, true, owner, true, true, touch_gestures) { + zoom *= f; + } + }); + out.textures_delta.clear(); + } + zoom + } + + /// Preferences › Enhanced Controls › Enable Gestures (#204): on (the default) a two-finger + /// pinch on a touch screen zooms; off, it leaves the view alone. + #[test] + fn enable_gestures_switches_touch_screen_pinch_zoom() { + let on = touch_zoom(touch_pinch_frames(), true); + assert!((on - 2.0).abs() < 1e-4, "on: the fingers zoom ×2: {on}"); + let off = touch_zoom(touch_pinch_frames(), false); + assert_eq!(off, 1.0, "off: the fingers don't zoom"); + } + + /// Enable Gestures off only silences the touch screen: a trackpad pinch still zooms, also in a + /// frame where two fingers rest on the screen (egui's `zoom_delta` would report theirs instead). + #[test] + fn enable_gestures_off_keeps_the_trackpad_pinch() { + let zoom = touch_zoom(vec![vec![Event::Zoom(1.25)]], false); + assert!((zoom - 1.25).abs() < 1e-4, "no fingers down: {zoom}"); + let mut frames = touch_pinch_frames(); + frames[2] = vec![Event::Zoom(1.25)]; + let zoom = touch_zoom(frames, false); + assert!((zoom - 1.25).abs() < 1e-4, "fingers resting: {zoom}"); + } } diff --git a/docs/scorecard.md b/docs/scorecard.md index a02e8ed210..c8bfb03f3a 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -179,9 +179,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 25 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From ca69059dd09d594875255a862cf2b5e74cac0bdc Mon Sep 17 00:00:00 2001 From: Brian T Date: Sun, 18 Oct 2026 14:23:26 -0700 Subject: [PATCH 169/304] Edit: Copy with no selection keeps fill and adjustment layers (#2344) (#7497) Copy refused fill and adjustment layers ("the active layer has no pixels"). With no pixel selection they are now taken whole, like smart objects, type, shape and group layers, and Paste recreates them in this or another document. An adjustment layer's clip spans the canvas, so paste placement and the OS-clipboard mirror stay consistent. Scorecard UI-217-23 note, gaps.md G9 and target-app-parity.md updated; the row stays partial (a single pixel layer still copies pixels only). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/edit_cmds.rs | 74 +++++++++++++++++++++++++++++----- docs/gaps.md | 7 ++-- docs/scorecard.md | 2 +- docs/target-app-parity.md | 5 ++- scorecard/ui.toml | 2 +- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..7cdb595212 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -35,15 +35,14 @@ pub struct Clip { /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) -/// (#2670), so Paste recreates it instead of its pixels. Pixel layers still copy their pixels; -/// several selected layers are [`multi_copy_ids`]. +/// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no +/// pixels of their own, so they are taken whole too rather than refused (#2344). Pixel layers +/// still copy their pixels; several selected layers are [`multi_copy_ids`]. fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { d.doc.selection.is_none() && d.selected_layers().len() <= 1 && crate::layer_copy_cmds::layered(d.doc.mode) - && d.doc - .layer(id) - .is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_))) + && d.doc.layer(id).is_some_and(|l| !matches!(l.content, LayerContent::Raster(_))) } /// A scratch document in the source's colour, to hold copied layers. @@ -280,13 +279,18 @@ fn copy(s: &mut Session, merged: bool) -> Result { } match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) { (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, - // A group has no pixels of its own: the clipboard pixels are what it shows. + // A group or fill layer has no pixels of its own: the clipboard pixels are what it + // shows. Adjustment layers show nothing by themselves, so their clip spans the canvas + // they apply to: Paste places them as a full-canvas layer, and the UI still mirrors + // the clip to the OS clipboard (an empty clip would let a stale OS image replace it). (None, Some(copies)) => { let mut shown = (**copies).clone(); for l in &mut shown.layers { l.visible = true; } - Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? } + let clip = lift(&merged_surface(&shown), None, canvas)?; + let bounds = if clip.bounds.is_empty() { canvas } else { clip.bounds }; + Clip { layers, bounds, ..clip } } (None, None) => return Err(EngineError::Other("the active layer has no pixels".into())), } @@ -1454,12 +1458,12 @@ mod tests { assert!(s.clipboard.is_none(), "a refused copy leaves the clipboard alone"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.execute("edit.copy", json!({"target": "mask"})).is_ok()); - // An adjustment layer has no pixels of its own: Copy is only enabled while its mask is - // targeted (#2928). + // An adjustment layer has no pixels of its own: Copy takes the layer whole (#2344), and + // its mask while that is targeted (#2928). s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.select", json!({"layer": a})).unwrap(); - assert!(!s.is_enabled("edit.copy"), "no pixels to copy"); + assert!(s.is_enabled("edit.copy"), "the layer itself can be copied"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.is_enabled_with("edit.copy", &json!({"target": "mask"})), "the mask can be copied"); } @@ -1916,6 +1920,56 @@ mod tests { assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); } + /// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy + /// takes them whole and Paste recreates them, in the same or another document, in one step. + #[test] + fn copy_paste_keeps_fill_and_adjustment_layers() { + for (depth, other) in [(8, 16), (16, 32), (32, 8)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 40, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff", "angle": 0})).unwrap(); + let LayerContent::Fill(fill) = active_content(&s) else { panic!("a fill layer") }; + let src = s.active().unwrap().active_layer.unwrap(); + assert!(s.is_enabled("edit.copy"), "{depth}-bit: a fill layer can be copied"); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: what it shows"); + let clip = s.clipboard.clone().unwrap(); + assert!(clip.surface.rgba(59, 20)[0] > clip.surface.rgba(0, 20)[0], "{depth}-bit: the clip holds the gradient's pixels"); + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}-bit: one history step"); + assert_eq!(active_content(&s), LayerContent::Fill(fill.clone()), "{depth}-bit: the paste is the same fill layer"); + assert_ne!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: a new layer"); + s.undo(); + assert_eq!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: undo removes the paste"); + s.redo(); + assert!(matches!(active_content(&s), LayerContent::Fill(_)), "{depth}-bit: redo brings it back"); + + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + let LayerContent::Adjustment(levels) = active_content(&s) else { panic!("an adjustment layer") }; + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: it applies to the canvas"); + s.execute("file.new", json!({"width": 30, "height": 30, "depth": other})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(active_content(&s), LayerContent::Adjustment(levels), "{depth}-bit: the same Levels layer in the {other}-bit document"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "{depth}-bit: pasted above the Background"); + } + } + + /// #2344: several adjustment layers, which show nothing alone, still copy and paste together. + #[test] + fn several_adjustment_layers_copy_together() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 40, 40])); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 5, "both are pasted"); + assert!(d.layers[3..].iter().all(|l| matches!(l.content, LayerContent::Adjustment(_)))); + } + #[test] fn merge_visible_keeps_hidden_layers() { let mut s = session(); diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..4457a87dde 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -199,8 +199,8 @@ what an agent can't finish alone. with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and - slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard - (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen + slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); a single pixel layer + copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections and Pen ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. - **Estimate:** 150–250 h. @@ -270,6 +270,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index c8bfb03f3a..b320028936 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -207,7 +207,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | | UI-217-22 | Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals | done | [#217](https://github.com/storytold/photocraft/issues/217) | stamp_cmds.rs layer.stampVisible (Cmd+Alt+Shift+E) merges every visible layer into a new layer above the active one and keeps the originals; layer.stampDown (Cmd+Alt+E) too. As in Photoshop they have no menu item of their own: Alt + Layer > Merge Visible / Merge Down / Merge Layers run them (stamp_cmds::alt_variant, menus.rs); stamp_tests.rs alt_click_on_merge_items_stamps and cmd_alt_shift_e_stamps_visible_and_can_be_rebound | -| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard | +| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Edit › Copy with no pixel selection takes layers whole and Paste recreates them in this or another open document, converted to its profile and depth, as one history step (edit_cmds.rs takes_whole, paste_layers): smart objects (#2428), type, shape and group layers (#2670), fill and adjustment layers (#2344), and several selected layers with their masks and styles (#2478). layer.copyToDocument is the drag between documents (layer_copy_cmds.rs, #589). Missing: a single pixel layer copies its pixels only, leaving its mask and styles behind | ## Type diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c798116f89..3eca518c27 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -167,7 +167,7 @@ parity for the area and are already contained in the dimension rows above. | Area | % | Hours | Notes | |---|---:|---:|---| -| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: layer clipboard between documents (UI-217-23: `layer.copyToDocument` exists, no copy/paste), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | | Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 908efc6b32..7eff90c3ca 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -164,4 +164,4 @@ id = "UI-217-23" target = "Layer clipboard: copy/paste whole layers (masks, styles, type) between documents" status = "partial" issue = 217 -note = "layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard" +note = "Edit › Copy with no pixel selection takes layers whole and Paste recreates them in this or another open document, converted to its profile and depth, as one history step (edit_cmds.rs takes_whole, paste_layers): smart objects (#2428), type, shape and group layers (#2670), fill and adjustment layers (#2344), and several selected layers with their masks and styles (#2478). layer.copyToDocument is the drag between documents (layer_copy_cmds.rs, #589). Missing: a single pixel layer copies its pixels only, leaving its mask and styles behind" From f3fb5781ef432aaeff42656865f9dcc9c94d17b3 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:23:32 +0300 Subject: [PATCH 170/304] Lens Blur: an alpha channel as the depth map source, as in Photoshop (#7400) (#7508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(filters): Lens Blur reads its depth from an alpha channel, listed in Depth Map › Source as in Photoshop (#7400) * test(filters): Lens Blur depth from an alpha channel by name or index, its errors, and the Source menu (#7400) --- crates/algo/src/blur2.rs | 2 +- crates/algo/src/lib.rs | 3 +- crates/algo/src/params_ext.rs | 3 + crates/engine/src/edit_menu_cmds.rs | 2 +- crates/engine/src/filters.rs | 5 ++ crates/engine/src/filters/validation.rs | 8 ++- crates/engine/src/filters_ext.rs | 16 ++++- crates/engine/src/filters_ext/tests.rs | 45 ++++++++++++ crates/ui-egui/src/filter_dialog.rs | 92 ++++++++++++++++++++++++- crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 12 files changed, 170 insertions(+), 9 deletions(-) diff --git a/crates/algo/src/blur2.rs b/crates/algo/src/blur2.rs index e917405083..bb6b47656c 100644 --- a/crates/algo/src/blur2.rs +++ b/crates/algo/src/blur2.rs @@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V } src.get(x, y, n - 1) } - DepthSource::LayerMask => spec.depth_map?.get(x, y, 0), + DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0), }; Some(if spec.invert { 1.0 - d } else { d }) }; diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 5a034e4561..ac4b3b1fcc 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -482,7 +482,8 @@ pub enum FilterParams { distribution: Distribution, monochromatic: bool, seed: u32, - /// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller. + /// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document + /// coordinates), supplied by the caller. #[serde(skip)] depth_map: Option>, }, diff --git a/crates/algo/src/params_ext.rs b/crates/algo/src/params_ext.rs index 3a9572fb46..2349604196 100644 --- a/crates/algo/src/params_ext.rs +++ b/crates/algo/src/params_ext.rs @@ -182,6 +182,9 @@ pub enum DepthSource { None, Transparency, LayerMask, + /// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the + /// caller like the layer mask. + Channel, } /// Built-in Shape Blur kernels. diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 6f86413d13..f9fab33e37 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -331,7 +331,7 @@ fn can_caf(s: &Session) -> std::result::Result<(), String> { } /// Mask surface (gray) of an alpha channel by index or name. -fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { +pub(crate) fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { match v { Value::Number(n) => doc.channels.get(n.as_u64()? as usize).map(|c| &c.surface), Value::String(name) => doc.channels.iter().find(|c| c.name == *name).map(|c| &c.surface), diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 221709080e..dd0ff8c803 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -292,6 +292,11 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result let surf = match &mut l.content { LayerContent::Raster(surf) => surf, LayerContent::Smart(_) => { + // A smart filter re-renders from its params alone, without the document's + // channels, so Lens Blur's alpha-channel depth would be lost (#7400). + if matches!(fp, FilterParams::LensBlur { depth: algo::DepthSource::Channel, .. }) { + return Err(EngineError::Other("Lens Blur can't read an alpha channel as depth on a Smart Object: rasterize the layer first".into())); + } // Non-destructive: record the filter and re-render the smart object from its source. let sf = SmartFilter { command: id.clone(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 068d37e9da..b2359ffa57 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -123,9 +123,11 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.lensBlur" => { - &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] - } + "filter.blur.lensBlur" => &[ + ("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), + ("depthMap", "none|transparency|layerMask|channel"), + ("distribution", "uniform|gaussian"), + ], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index e4f6523a19..0425e1932a 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -277,6 +277,7 @@ pub fn params_for(id: &str, p: &Value) -> Option { depth: match s(p, "depthMap", "none") { "transparency" => DepthSource::Transparency, "layerMask" => DepthSource::LayerMask, + "channel" => DepthSource::Channel, _ => DepthSource::None, }, focal_distance: f(p, "focalDistance", 0.0).clamp(0.0, 255.0), @@ -429,8 +430,19 @@ fn map_from_document(doc: &Document, layer: Option) -> Option } /// Resolves inputs that live outside the target layer: Displace's map (`mapPath`, or -/// `mapDocument` index with optional `mapLayer` id). +/// `mapDocument` index with optional `mapLayer` id), and the alpha channel Lens Blur reads its +/// depth from (`depthChannel`, an index or a name). pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<()> { + if let FilterParams::LensBlur { depth: DepthSource::Channel, depth_map, .. } = fp { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let channel = p + .get("depthChannel") + .ok_or_else(|| EngineError::Other("Lens Blur: `depthMap` channel needs `depthChannel` (an alpha channel's index or name)".into()))?; + let surface = crate::edit_menu_cmds::channel_mask(doc, channel) + .ok_or_else(|| EngineError::Other(format!("Lens Blur: the document has no alpha channel {channel}")))?; + *depth_map = Some(Arc::new(algo::Image::read(surface, doc.bounds()))); + return Ok(()); + } let FilterParams::Displace { map, .. } = fp else { return Ok(()) }; if map.is_some() { return Ok(()); @@ -662,7 +674,7 @@ pub fn specs() -> Vec { "filter.blur.lensBlur", "Lens Blur…", ["Filter", "Blur"], - r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask","focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## + r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask|channel","depthChannel":index|name?,"focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## ), cmd!( "filter.blur.shapeBlur", diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index 0f32c5367d..490568e87c 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -220,6 +220,51 @@ fn lens_blur_uses_the_layer_mask_as_depth() { assert_ne!(pixels(&s), before); } +/// #7400: an alpha channel is a Lens Blur depth source, by name or index, as Photoshop lists +/// the document's channels in Depth Map › Source. A missing channel is an error that changes +/// nothing, and on a Smart Object (whose smart filters re-render without the channels) it is +/// refused instead of silently blurring without depth. +#[test] +fn lens_blur_reads_an_alpha_channel_as_depth() { + let mut s = session(); + // A white channel: depth 1 everywhere. Creating it targets it; the filter is for the layer. + s.execute("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let before = pixels(&s); + for channel in [json!("Depth Map"), json!(0)] { + let p = |focal: f64| json!({"radius": 6, "depthMap": "channel", "depthChannel": channel, "focalDistance": focal}); + // Focal distance 255 puts depth 1 in focus: nothing blurs. + s.execute("filter.blur.lensBlur", p(255.0)).unwrap(); + assert_eq!(pixels(&s), before, "{channel}: in focus"); + s.execute("filter.blur.lensBlur", p(0.0)).unwrap(); + assert_ne!(pixels(&s), before, "{channel}: out of focus blurs"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&s), before); + } + for bad in [ + json!({"radius": 6, "depthMap": "channel"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": "Nope"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": 7}), + ] { + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("Lens Blur"), "{bad}: {e}"); + assert_eq!(pixels(&s), before, "{bad}: nothing changed"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "{bad}: the document was replaced"); + assert_eq!(st.history.past_len(), past, "{bad}: a history step was recorded"); + } + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", json!({"radius": 6, "depthMap": "channel", "depthChannel": "Depth Map"})).unwrap_err().to_string(); + assert!(e.contains("Smart Object"), "{e}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "no smart filter was added"); + assert_eq!(st.history.past_len(), past, "no history step"); +} + #[test] fn params_map_to_algorithm_units() { assert_eq!(params_for("filter.pixelate.crystallize", &json!({"cellSize": 1})), Some(FilterParams::Crystallize { cell_size: 3.0, seed: 0 })); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 72126a69c8..d09caf4953 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -111,7 +111,8 @@ pub fn parse_spec(spec: &str) -> Vec { Kind::Color(crate::color_picker_ui::parse_hex(default)) } else if v == "doc" { Kind::Document - } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') { + } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') || v.trim_end_matches('?') == "index|name" { + // `index|name`: a reference (an alpha channel…) the dialog sets through another control. Kind::Json } else if let Some(n) = v.strip_prefix("int[").and_then(|r| r.strip_suffix(']')).and_then(|n| n.parse().ok()) { Kind::Grid(n) @@ -468,6 +469,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { if crate::blur_pins::handles(command) { crate::blur_pins::init(app, &mut fields); } + if command == LENS_BLUR { + lens_blur_channels(app, &mut fields); + } if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { // The document picker lists every open document (params refer to them by index). let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); @@ -594,6 +598,37 @@ fn uses_logarithmic_slider(min: f32, max: f32) -> bool { min > 0.0 && max / min > 500.0 } +const LENS_BLUR: &str = "filter.blur.lensBlur"; + +/// Lens Blur's Depth Map Source lists the document's alpha channels after None, Transparency +/// and Layer Mask, as in Photoshop (#7400): their names go to `__channels`. A `channel` source +/// without a channel this document has (references aren't remembered) opens as None. +fn lens_blur_channels(app: &PhotocraftApp, f: &mut Map) { + let names: Vec = app.session.active().map(|d| d.doc.channels.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let known = f.get("depthChannel").and_then(|v| channel_index(v, &names)).is_some(); + if f.get("depthMap").and_then(Value::as_str) == Some("channel") && !known { + f.insert("depthMap".into(), json!("none")); + f.remove("depthChannel"); + } + f.insert("__channels".into(), json!(names)); +} + +/// Lens Blur's Source menu entry for the alpha channel at `index`. By index, not name: two +/// channels may share a name, and each must be choosable. +fn channel_choice(index: usize) -> String { + format!("channel:{index}") +} + +/// The alpha channel `v` names (an index, or a name: the first channel so named), as the +/// engine reads `depthChannel`. +fn channel_index(v: &Value, names: &[String]) -> Option { + match v { + Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < names.len()), + Value::String(name) => names.iter().position(|n| n == name), + _ => None, + } +} + /// Schema dialogs whose `profile` menu lists the installed profiles after the built-ins. const PROFILE_DIALOGS: &[&str] = &["edit.assignProfile", "edit.convertToProfile", "view.proofSetup"]; @@ -767,6 +802,23 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut cur = f.get(&p.key).and_then(Value::as_str).unwrap_or(initial).to_string(); let labels: Vec = options.iter().map(|o| choice_label(o)).collect(); let mut opts: Vec<(String, &str)> = options.iter().cloned().zip(labels.iter().map(String::as_str)).collect(); + // Lens Blur's Source: each alpha channel by its name, in place of `channel`. + let depth = cmd == LENS_BLUR && p.key == "depthMap"; + let names: Vec = if depth { + f.get("__channels").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() + } else { + Vec::new() + }; + let channels: Vec = (0..names.len()).map(channel_choice).collect(); + if depth { + opts.retain(|(v, _)| v != "channel"); + opts.extend(channels.iter().cloned().zip(names.iter().map(String::as_str))); + if cur == "channel" + && let Some(i) = f.get("depthChannel").and_then(|v| channel_index(v, &names)) + { + cur = channel_choice(i); + } + } // Like Photoshop, the profile menus also list every profile installed in // the system (printer and paper profiles), by description. let installed = if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) { @@ -788,6 +840,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { opts.push((cur.clone(), stem.as_str())); } crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0); + if let Some(i) = cur.strip_prefix("channel:").filter(|_| depth).and_then(|i| i.parse::().ok()) { + f.insert("depthChannel".into(), json!(i)); + cur = "channel".into(); + } else if depth { + f.remove("depthChannel"); + } f.insert(p.key.clone(), json!(cur)); }); } @@ -1468,6 +1526,38 @@ mod tests { assert_eq!(params["amount"].as_f64(), Some(37.5)); } + /// #7400: Lens Blur's Source lists the document's alpha channels, by name, each choosable even + /// when two share a name; `depthChannel` is set through that menu, not shown as a field, and + /// like the other references it isn't remembered, so a remembered `channel` source opens as None. + #[test] + fn lens_blur_source_lists_the_alpha_channels() { + let spec = photocraft_engine::commands::find(LENS_BLUR).unwrap().params; + let params = parse_spec(spec); + assert!(params.iter().any(|p| p.key == "depthChannel" && p.kind == Kind::Json), "depthChannel is no dialog field"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["__channels"], json!(["Depth Map"])); + // The menu's values are channel indices, so two channels with one name are both choosable. + app.run("channel.new", json!({"name": "Depth Map", "fill": "black"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + let names: Vec = serde_json::from_value(app.ui.dialog_mut(id).unwrap().fields["__channels"].clone()).unwrap(); + assert_eq!(names, ["Depth Map", "Depth Map"]); + assert_eq!((channel_choice(0), channel_choice(1)), ("channel:0".to_string(), "channel:1".to_string())); + assert_eq!(channel_index(&json!(1), &names), Some(1), "the second one by its index"); + assert_eq!(channel_index(&json!("Depth Map"), &names), Some(0), "a name is the first so named, as the engine reads it"); + assert_eq!(channel_index(&json!(2), &names), None); + + app.session.prefs.edit(|p| { + p.dialogs.insert(LENS_BLUR.into(), json!({"depthMap": "channel", "depthChannel": "Depth Map"})); + }); + let id = open(&mut app, LENS_BLUR).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["depthMap"], "none", "no channel was remembered with it"); + assert!(f.get("depthChannel").is_none()); + } + #[test] fn wide_positive_ranges_use_the_logarithmic_slider_path() { assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius"); diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a170c96632..d5d58b2ff2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -743,6 +743,7 @@ Brush Settings 브러시 설정 Brushes 브러시 Channels 채널 + Channel 채널 Character 문자 Character Styles 문자 스타일 Clone Source 복제 원본 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8928778e47..0f242f8f63 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -772,6 +772,7 @@ Brush Settings 画笔设置 Brushes 画笔 Channels 通道 + Channel 通道 Character 字符 Character Styles 字符样式 Clone Source 仿制源 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0d68180573..621bee5518 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -777,6 +777,7 @@ Brush Settings 筆刷設定 Brushes 筆刷 Channels 色版 + Channel 色版 Character 字元 Character Styles 字元樣式 Clone Source 仿製來源 From c6fd71241d80798d6ba1693fd02deb5cb968a09b Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 19 Oct 2026 00:15:53 +0200 Subject: [PATCH 171/304] docs: add user docs in docs/user (#3951) * docs: add user docs for PhotoCraft Adds docs/user/ (published at getartcraft.com/docs/photocraft), the docs check and publish workflows (copied from craft-launcher), and a User docs rule in AGENTS.md. Checker: 0 error(s), 0 warning(s). Source notes per page: - index.md: README (intro, status, Crafting Apps table), website crafting-apps.ts photocraft entry, links.rs (Discord, issues), menus.rs (Help > Report an Issue). - getting-started/index.md: menus.rs and menu_catalog.rs labels, panels.rs (Layers, Properties, History), dialogs.rs (Create button). - getting-started/install.md: README Downloads and Get started sections, gh release view v0.7.0 asset list, craft-launcher catalog.rs (lists PhotoCraft). - guides/open-and-save-files.md: apps/photocraft/src/services.rs (open and save filters), README Formats and PSD sections, docs/file-format-parity.md. - guides/adjust-colors-with-adjustment-layers.md: menu_catalog.rs, panels.rs (footer button, adjustment list), lib.rs (Properties shows new layer). - guides/select-and-mask.md: menu_catalog.rs Select and Layer Mask menus, state.rs tool names, retouch_ui.rs (Select Subject button and message). - guides/add-and-style-text.md: state.rs (Horizontal Type Tool, T), type_tool.rs, menu_catalog.rs Layer Style and Type menus, dialogs.rs. - guides/export-an-image.md: export_dialog.rs (labels, formats, options). - troubleshooting/taskbar-turns-black.md: issues 3550, 3864, 3430, 3144, PR 3308 (git: not an ancestor of v0.7.0), prefs_ui.rs (System title bar). - troubleshooting/text-moves-when-editing.md: issues 3795, 1317, PR 3819 (open). - troubleshooting/get-help.md: menus.rs, links.rs, gpu_status.rs (System Info content). - faq/*: README (status, license badge, offline, formats), LICENSE-MIT and LICENSE-APACHE, docs/file-format-parity.md. Co-Authored-By: Claude Opus 5.5 * docs(user): apply final review fixes Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 4 items 1-9: remove x86 row and TGA resolution, formats, new photocraft-does-not-start page, mask label, taskbar workaround, wording, Affinity read-only, index. Co-Authored-By: Claude Opus 5.5 * docs(user): use the standard section title Co-Authored-By: Claude Opus 5.5 * docs(user): describe PhotoCraft 0.7.0 startup behaviour Co-Authored-By: Claude Opus 5.5 * docs(user): tighten PhotoCraft startup wording Drop the unsourced "slower" claim for CPU mode and give the exact Preferences path on Windows and macOS. Co-Authored-By: Claude Opus 5.5 * docs: pin the docs checker to a commit and its SHA-256 Co-Authored-By: Claude Opus 5.5 * docs: drop the docs workflows until the checker is on artcraft-services main Co-Authored-By: Claude Opus 5.5 * docs: re-verify against 0.8.2 - taskbar and PSD text pages: both fixes ship in 0.8.2; tell users to update and drop the "not in a release yet" wording and workarounds - does not start: 0.8.2 fixes the Intel Iris Pro/Monterey Device(Lost) case; OpenCore Legacy Patcher Macs still have no fix - install: Windows 10 or later, 32-bit Windows builds, RISC-V deb/rpm, Wayland paste and XWayland notice - open and save: Krita and Paint.NET filters, Krita/PCX/FITS formats, save progress, JPEG/WebP encoding kept on Save - export: TGA Resolution, multi-layer Export As and Quick Export - text: Shift-click starts new text over existing type - CLAUDE.md is now a regular file pointing to AGENTS.md - verified: 0.8.2 on every page Co-Authored-By: Claude Opus 5.5 * Drop the root AGENTS.md docs rule; keep the PR to docs/user/ Co-Authored-By: Claude Opus 5.5 * docs(user): drop the stale missing-tools count from the Photoshop FAQ Re-verified the page against v0.9.0. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/user/AGENTS.md | 60 +++++++++++++++++++ docs/user/CLAUDE.md | 1 + docs/user/faq/index.md | 10 ++++ docs/user/faq/is-photocraft-free.md | 14 +++++ docs/user/faq/open-photoshop-files.md | 16 +++++ docs/user/faq/replace-photoshop.md | 12 ++++ docs/user/getting-started/index.md | 21 +++++++ docs/user/getting-started/install.md | 59 ++++++++++++++++++ docs/user/guides/add-and-style-text.md | 36 +++++++++++ .../adjust-colors-with-adjustment-layers.md | 32 ++++++++++ docs/user/guides/export-an-image.md | 38 ++++++++++++ docs/user/guides/index.md | 14 +++++ docs/user/guides/open-and-save-files.md | 32 ++++++++++ docs/user/guides/select-and-mask.md | 40 +++++++++++++ docs/user/index.md | 25 ++++++++ docs/user/troubleshooting/get-help.md | 19 ++++++ docs/user/troubleshooting/index.md | 11 ++++ .../photocraft-does-not-start.md | 58 ++++++++++++++++++ .../troubleshooting/taskbar-turns-black.md | 29 +++++++++ .../text-moves-when-editing.md | 29 +++++++++ 20 files changed, 556 insertions(+) create mode 100644 docs/user/AGENTS.md create mode 100644 docs/user/CLAUDE.md create mode 100644 docs/user/faq/index.md create mode 100644 docs/user/faq/is-photocraft-free.md create mode 100644 docs/user/faq/open-photoshop-files.md create mode 100644 docs/user/faq/replace-photoshop.md create mode 100644 docs/user/getting-started/index.md create mode 100644 docs/user/getting-started/install.md create mode 100644 docs/user/guides/add-and-style-text.md create mode 100644 docs/user/guides/adjust-colors-with-adjustment-layers.md create mode 100644 docs/user/guides/export-an-image.md create mode 100644 docs/user/guides/index.md create mode 100644 docs/user/guides/open-and-save-files.md create mode 100644 docs/user/guides/select-and-mask.md create mode 100644 docs/user/index.md create mode 100644 docs/user/troubleshooting/get-help.md create mode 100644 docs/user/troubleshooting/index.md create mode 100644 docs/user/troubleshooting/photocraft-does-not-start.md create mode 100644 docs/user/troubleshooting/taskbar-turns-black.md create mode 100644 docs/user/troubleshooting/text-moves-when-editing.md diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 0000000000..dd791ea1f5 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,60 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/photocraft. The file path is the URL: +`troubleshooting/taskbar-turns-black.md` → `/docs/photocraft/troubleshooting/taskbar-turns-black`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists using PhotoCraft, Discord mods answering them, and AI assistants looking things up for +either. Write for an artist, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install PhotoCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code or a release. +- Call the product "PhotoCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. Menu paths read `File › Open…`. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: Export an image as PNG or JPEG + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.7.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/photocraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show :`. Pages describe the latest release users download, not unreleased code on main. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user + +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): `. +Review them like code: check every claim against the source. diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 0000000000..e7bcfb70ed --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 0000000000..c50180a390 --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,10 @@ +--- +title: FAQ +description: Short answers about PhotoCraft's price, Photoshop files and how it compares with Photoshop. +order: 40 +verified: 0.8.2 +--- + +- [Is PhotoCraft free?](is-photocraft-free.md) +- [Can PhotoCraft open my Photoshop files?](open-photoshop-files.md) +- [Can PhotoCraft replace Photoshop?](replace-photoshop.md) diff --git a/docs/user/faq/is-photocraft-free.md b/docs/user/faq/is-photocraft-free.md new file mode 100644 index 0000000000..dd9946540a --- /dev/null +++ b/docs/user/faq/is-photocraft-free.md @@ -0,0 +1,14 @@ +--- +title: Is PhotoCraft free? +description: PhotoCraft is free and open source under the MIT or Apache 2.0 license, and works offline. +order: 10 +verified: 0.8.2 +--- + +Yes. PhotoCraft is one of the Crafting Apps, which the project describes as free, open-source creative tools. The code is licensed under MIT or Apache 2.0, your choice, and is on [GitHub](https://github.com/storytold/photocraft). + +PhotoCraft works offline. Its smart selection tools run on your computer, with no cloud and no account. + +The ArtCraft logos in the repository are not open source. + +See [Install PhotoCraft](../getting-started/install.md) to download it. diff --git a/docs/user/faq/open-photoshop-files.md b/docs/user/faq/open-photoshop-files.md new file mode 100644 index 0000000000..06fbe367bc --- /dev/null +++ b/docs/user/faq/open-photoshop-files.md @@ -0,0 +1,16 @@ +--- +title: Can PhotoCraft open my Photoshop files? +description: PhotoCraft opens and saves layered PSD and PSB files and keeps what it does not understand yet. +order: 20 +verified: 0.8.2 +--- + +Yes. PhotoCraft opens, edits and saves layered PSD and PSB files, including 16-bit, 32-bit, CMYK and Lab documents. + +When it saves the file again, the picture renders the same for 307 of the 309 files in the psd-tools test set the project checks against. Parts PhotoCraft does not model yet, such as raw blocks and descriptors, are carried over instead of being dropped. A re-saved file is not byte for byte the same as the original, because PhotoCraft rewrites image resources, layer records and the composite. + +Text layers stay editable. In version 0.7.0 some of them moved when you started editing them; 0.8.2 fixes that, see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +Results vary from file to file. If a PSD does not open or looks wrong, search the issue tracker or report it with the file. + +PhotoCraft also opens layered TIFF, Paint.NET, OpenRaster, SVG, Krita and Affinity files (Krita and Affinity read only), and many flat formats. The full list is in [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/faq/replace-photoshop.md b/docs/user/faq/replace-photoshop.md new file mode 100644 index 0000000000..56027f2706 --- /dev/null +++ b/docs/user/faq/replace-photoshop.md @@ -0,0 +1,12 @@ +--- +title: Can PhotoCraft replace Photoshop? +description: Not yet. PhotoCraft is in early alpha and is not ready for daily professional work, but it follows Photoshop's menus and shortcuts. +order: 30 +verified: 0.9.0 +--- + +Not yet. PhotoCraft is in early alpha. Much of Photoshop's feature surface exists in some form, but the project says plainly that it is not yet a Photoshop replacement for daily professional work. + +The biggest gaps the project lists are AI and generative features, tools Photoshop has that PhotoCraft does not yet, depth in typography and professional workflows, and plug-in compatibility. Every Photoshop menu item is connected to a command, but that only shows the menu is wired up. Having a tool or command is not the same as matching Photoshop's behaviour. + +What PhotoCraft does well already: layers, masks, adjustment layers, layer styles, type, vector shapes, brushes and live-preview filters, in a menu and shortcut layout that follows Photoshop. See [PhotoCraft](../index.md) for an overview, and the [project roadmap](https://github.com/storytold/photocraft/blob/main/ROADMAP.md) for what comes next. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 0000000000..2383e17f0b --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,21 @@ +--- +title: Getting started +description: Install PhotoCraft and open your first image. +order: 10 +verified: 0.8.2 +--- + +Install PhotoCraft, then open an image with **File › Open…**. The panels you use most are **Layers**, **Properties** and **History**. + +1. [Install PhotoCraft](install.md). +2. Start PhotoCraft and choose **File › Open…**. To start from nothing, choose **File › New…** and click **Create**. +3. Pick the **Horizontal Type Tool**, a selection tool or an adjustment, and work on the image. Every step is listed in the **History** panel and can be undone. +4. Keep your layers with **File › Save As…** (PSD or PhotoCraft), or make a flat copy with **File › Export › Export As…**. + +The guides walk through each of these: + +- [Open and save files](../guides/open-and-save-files.md) +- [Adjust colors without changing your pixels](../guides/adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](../guides/select-and-mask.md) +- [Add and style text](../guides/add-and-style-text.md) +- [Export an image](../guides/export-an-image.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 0000000000..975f7f3b71 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,59 @@ +--- +title: Install PhotoCraft +description: Download PhotoCraft for Windows 10 or 11, macOS or Linux from getartcraft.com or GitHub, or install it with ArtCraft Launcher. +order: 10 +verified: 0.8.2 +--- + +The easiest way is the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). You can also install it with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher/guides/install-and-update-apps), which lists PhotoCraft. For a specific build or file format, take it from the [latest release](https://github.com/storytold/photocraft/releases/latest) on GitHub. In the file names below, `` stands for the version number, for example `0.8.2`. + +## Windows + +PhotoCraft needs Windows 10 or later. On Windows 7 and 8 the installer stops with "PhotoCraft needs Windows 10 or later." + +1. Open the [latest release](https://github.com/storytold/photocraft/releases/latest). +2. Download `photocraft--windows-x64.msi`. It fits most PCs (64-bit Intel and AMD). +3. Run the file and follow the installer. + +To run without installing, download `photocraft--windows-x64-portable.zip` instead and unzip it. + +| Your PC | Installer | Portable | +|------------------------------------|------------------------------------------|---------------------------------------------------| +| 64-bit Intel or AMD | `photocraft--windows-x64.msi` | `photocraft--windows-x64-portable.zip` | +| Arm (Snapdragon and other Arm PCs) | `photocraft--windows-arm64.msi` | `photocraft--windows-arm64-portable.zip` | +| 32-bit | `photocraft--windows-x86.msi` | `photocraft--windows-x86-portable.zip` | + +The installers and executables are code-signed. + +## macOS + +1. Download `photocraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs, and is signed and notarized by Apple. +2. Open it and drag PhotoCraft to Applications. + +## Linux + +Pick one file from the release. Use `x86_64` for Intel and AMD, or `aarch64` for Arm. + +| Format | File | Notes | +|---------------------|----------------------------------------------|------------------------------------------------------------------------------------| +| AppImage | `photocraft--linux-x86_64.AppImage` | Runs anywhere and needs no install. | +| Debian and Ubuntu | `photocraft--linux-x86_64.deb` | | +| Fedora and openSUSE | `photocraft--linux-x86_64.rpm` | | +| Flatpak | `photocraft--linux-x86_64.flatpak` | Run `flatpak install --user `, then `flatpak run ai.storyteller.photocraft`. | +| Tarball | `photocraft--linux-x86_64.tar.gz` | Unpack it anywhere. | + +The first time you run the AppImage, it adds its icon and menu entry under `~/.local/share`. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that. For RISC-V, take the `riscv64` file: `photocraft--linux-riscv64.tar.gz`, `.deb` or `.rpm`. The RISC-V tarball needs glibc 2.39 or newer. + +On a Wayland session, files dropped on the PhotoCraft window do not open yet. Use **File › Open…** instead, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. PhotoCraft shows the right command for your install in a notice until you dismiss it. + +## FreeBSD and the web + +FreeBSD 14 (x86_64) has a tarball, `photocraft--freebsd-x86_64.tar.gz`, laid out like `/usr/local`. The web version comes as `photocraft-web-.zip`, a static site you host on any web server. It runs in a modern browser. + +## Check your download + +`SHA256SUMS.txt` on the release lists a checksum for every file. + +## Next step + +Open an image: [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/guides/add-and-style-text.md b/docs/user/guides/add-and-style-text.md new file mode 100644 index 0000000000..71dcd7db97 --- /dev/null +++ b/docs/user/guides/add-and-style-text.md @@ -0,0 +1,36 @@ +--- +title: Add and style text +description: Add point or paragraph text with the Horizontal Type Tool in PhotoCraft, then give it a drop shadow or glow. +order: 40 +verified: 0.8.2 +--- + +Pick the **Horizontal Type Tool** (shortcut `T`), click on the canvas for a line of text or drag for a text box, and type. Text stays editable, so you can change the words and the style later. + +## Add text + +1. Choose the **Horizontal Type Tool** in the toolbox, or press `T`. +2. Click the canvas to start a line of text (point text), or drag a rectangle to start a block of text (paragraph text). +3. Type. The text is edited right on the canvas. +4. Set the font, size, leading, tracking, alignment and color with the character and paragraph controls. Each typing session is one **Edit Type** step in the **History** panel. + +To switch between the two kinds later, use **Type › Convert to Paragraph Text** or **Type › Convert to Point Text**. + +## Edit the text later + +Pick the **Horizontal Type Tool** and click the text on the canvas. Text from a PSD that was made in Photoshop stays editable too. If PSD text jumps when you start editing it, update to 0.8.2; see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +To start new text on top of an existing text layer instead of editing it, hold `Shift` while you click. + +## Add a layer style + +1. Click the text layer in the **Layers** panel. +2. Choose **Layer › Layer Style**, then an effect such as **Drop Shadow…**, **Outer Glow…**, **Stroke…** or **Bevel & Emboss…**. The **Layer Style** dialog opens. +3. Adjust the effect and confirm. + +The other effects in the menu are **Inner Shadow…**, **Inner Glow…**, **Satin…**, **Color Overlay…**, **Gradient Overlay…** and **Pattern Overlay…**. **Layer › Layer Style** also has **Copy Layer Style** and **Paste Layer Style** to reuse a style on another layer, and **Hide All Effects**. + +## Turn text into shapes or pixels + +- **Type › Convert to Shape** makes vector shapes from the letters. +- **Type › Rasterize Type Layer** turns the text into pixels. After that you can no longer edit the words. diff --git a/docs/user/guides/adjust-colors-with-adjustment-layers.md b/docs/user/guides/adjust-colors-with-adjustment-layers.md new file mode 100644 index 0000000000..500ff3521c --- /dev/null +++ b/docs/user/guides/adjust-colors-with-adjustment-layers.md @@ -0,0 +1,32 @@ +--- +title: Adjust colors without changing your pixels +description: Add a Curves, Levels or other adjustment layer in PhotoCraft so you can change or switch it off later. +order: 20 +verified: 0.8.2 +--- + +Use **Layer › New Adjustment Layer** and pick an adjustment. The change sits on its own layer above your image, so your original pixels stay as they were and you can edit or hide the adjustment later. + +## Add an adjustment layer + +1. Open your image and click the layer the adjustment should sit above in the **Layers** panel. +2. Choose **Layer › New Adjustment Layer**, then an adjustment such as **Curves…**, **Levels…**, **Vibrance…** or **Hue/Saturation…**. +3. Change the settings in the **Properties** panel, which shows the settings of the new layer. + +The same list is behind the button at the bottom of the **Layers** panel, labelled **Create new fill or adjustment layer** when you hover over it. + +The adjustments in the menu are **Brightness/Contrast**, **Levels**, **Curves**, **Exposure**, **Vibrance**, **Hue/Saturation**, **Color Balance**, **Black & White**, **Photo Filter**, **Channel Mixer**, **Color Lookup**, **Invert**, **Posterize**, **Threshold**, **Gradient Map** and **Selective Color**. + +## Limit it to one area + +1. Select the area first, for example with the **Elliptical Marquee Tool**. See [Select part of an image and mask it](select-and-mask.md). +2. Add the adjustment layer as above. +3. With the adjustment layer active, choose **Layer › Layer Mask › Reveal Selection**. The adjustment now shows only inside the selection. + +## Change it later + +Click the adjustment layer in the **Layers** panel and edit its settings in **Properties**. Every step you take is also listed in the **History** panel. + +## Apply an adjustment straight to the pixels + +If you do not need to change it later, use **Image › Adjustments**, for example **Image › Adjustments › Curves…**. These change the pixels of the layer directly and are not kept as a separate layer. diff --git a/docs/user/guides/export-an-image.md b/docs/user/guides/export-an-image.md new file mode 100644 index 0000000000..82d5c5a4e4 --- /dev/null +++ b/docs/user/guides/export-an-image.md @@ -0,0 +1,38 @@ +--- +title: Export an image +description: Export a PNG, JPG, WebP, TIFF or TGA from PhotoCraft with a preview, a size estimate and a scale. +order: 50 +verified: 0.8.2 +--- + +Choose **File › Export › Export As…**, set the format, and click **Export**. The dialog shows a preview and an estimate of the file size before you save. Your layered document is not changed. + +## Export with settings + +1. Choose **File › Export › Export As…**. +2. Under **File Settings**, pick a **Format**: **PNG**, **JPG**, **WebP**, **TIFF** or **TGA**. +3. Set the options for that format: + - **JPG** and lossy **WebP** have a **Quality** slider from 1 to 100. + - **WebP** also has a **Lossless** checkbox. + - **TGA** has **Resolution**: **Auto**, **24 bits/pixel** or **32 bits/pixel**. + - **Transparency** keeps transparent areas. JPG has no transparency, so this checkbox is not shown for JPG and the image is flattened. + - **Metadata** is **None** or **All**. +4. Under **Image Size**, set **Scale** in percent. The size in pixels shows below it. +5. Check the preview on the right. The line under it shows the format and the estimated file size. +6. Click **Export**, then choose a name and a place for the file. + +PhotoCraft shows "Exported" with the file path and size in the status bar when it is done. + +## Export in one step + +**File › Export › Quick Export as PNG** exports without asking about settings. It uses the format, quality, metadata and location you set in **File › Export › Export Preferences…**. + +## Export layers + +Choose **Layer › Export As…** (or **Layer › Quick Export as PNG**) to export only the active layer. + +With several layers or artboards selected in the **Layers** panel, both commands export each one as its own file. You choose a name and place once. The first file gets that name, and the others get the same name with a number and the layer name added. + +## Keep your layers + +Exporting makes a flat copy. To keep the layers, use **File › Save As…** with **PSD Document** or **PhotoCraft**; see [Open and save files](open-and-save-files.md). diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 0000000000..a493dc2c8a --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,14 @@ +--- +title: Guides +description: Step-by-step guides for the everyday tasks in PhotoCraft. +order: 20 +verified: 0.8.2 +--- + +- [Open and save files](open-and-save-files.md) +- [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](select-and-mask.md) +- [Add and style text](add-and-style-text.md) +- [Export an image](export-an-image.md) + +Menu paths are written like **File › Open…**. Where a shortcut is shown, `Cmd` is the Ctrl key on Windows and Linux. diff --git a/docs/user/guides/open-and-save-files.md b/docs/user/guides/open-and-save-files.md new file mode 100644 index 0000000000..0c2b4306af --- /dev/null +++ b/docs/user/guides/open-and-save-files.md @@ -0,0 +1,32 @@ +--- +title: Open and save files +description: Open a PSD or other image in PhotoCraft, and save it with its layers or as a flat copy. +order: 10 +verified: 0.8.2 +--- + +Open a file with **File › Open…** and keep your layers with **File › Save As…**, choosing **PSD Document** or **PhotoCraft** as the type. Formats such as PNG and JPEG are flat: they hold one image with no layers. + +## Open a file + +1. Choose **File › Open…**. +2. Pick your file. The type menu in the dialog offers **All Formats**, **PhotoCraft**, **OpenRaster**, **Krita**, **SVG** and **Paint.NET**. +3. Confirm in the dialog. + +You can also reopen a recent file from **File › Open Recent**. On Linux under Wayland, dropping a file on the window does not work yet; see [Install PhotoCraft](../getting-started/install.md). + +PhotoCraft opens PSD and PSB (including 16-bit, 32-bit, CMYK and Lab files), layered TIFF, Paint.NET (`.pdn`), OpenRaster (`.ora`), SVG, and the flat formats PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, PCX, OpenEXR, Radiance HDR and FITS. Krita files (`.kra`) open as one image layer made from the picture Krita saved, without Krita's layers. HEIC and HEIF photos from iPhone and Mac open too, read only, and so do camera raw files such as DNG, CR2, CR3, NEF and ARW. It can also open Affinity files (`.af`, `.afphoto`, `.afdesign`, `.afpub`), read only; PhotoCraft cannot save Affinity files. Anything it cannot match exactly, such as layer effects and adjustments, is approximated or left out and PhotoCraft shows a warning. + +## Save with layers + +1. Choose **File › Save As…**. +2. Pick **PSD Document** (`.psd` or `.psb`) to share the file with Photoshop, or **PhotoCraft** (`.pcraft`) for PhotoCraft's own format. +3. Name the file and save. + +After that, **File › Save** saves into the same file. **File › Save a Copy…** saves a copy under a new name. While a large file saves, the status bar and the tab title show "Saving" with a percentage. + +The **Save As…** dialog also lists OpenRaster, PNG, JPEG, WebP, TIFF, Targa, OpenEXR and FITS. **File › Save** on an opened JPEG keeps its quality, and on a WebP keeps it lossy or lossless. Check the file extension in the name box: PhotoCraft writes the format that the extension names. If you want a flat PNG or JPEG with a size and quality preview, use [Export an image](export-an-image.md) instead. + +## Good to know + +Saving a PSD again keeps the layers and the rendered result, but the file is not byte for byte the same as the one you opened. PhotoCraft rewrites image resources, layer records and the composite. It keeps parts it does not model yet instead of dropping them. diff --git a/docs/user/guides/select-and-mask.md b/docs/user/guides/select-and-mask.md new file mode 100644 index 0000000000..50f78b7685 --- /dev/null +++ b/docs/user/guides/select-and-mask.md @@ -0,0 +1,40 @@ +--- +title: Select part of an image and mask it +description: Make a selection with the marquee tools or Select Subject, soften its edge, and turn it into a layer mask in PhotoCraft. +order: 30 +verified: 0.8.2 +--- + +Pick a selection tool, drag around the area, then choose **Layer › Layer Mask › Reveal Selection** to show only that area. A mask hides part of a layer without erasing any pixels. + +## Make a selection + +Use one of these: + +- **Elliptical Marquee Tool** or **Rectangular Marquee Tool** for simple shapes. Drag on the canvas. +- **Quick Selection Tool**, **Magic Wand Tool** or **Lasso Tool** to follow the edges of an object. +- **Select › Subject** to let PhotoCraft pick the main subject. The **Quick Selection Tool** and **Object Selection Tool** also have a **Select Subject** button in the options bar. If nothing is found, PhotoCraft says "No subject found" and suggests the Object Selection or Lasso tool. + +Smart selection runs on your computer. It needs no account and no cloud. + +## Adjust the selection + +- **Select › Inverse Selection** swaps the selected and unselected areas. +- **Select › Modify** has **Border…**, **Smooth…**, **Expand…**, **Contract…** and **Feather…**. Feather softens the edge. +- **Select › Select and Mask…** refines an edge, for example around hair. +- **Select › Deselect** removes the selection. **Select › Reselect** brings back the last one. + +## Turn the selection into a mask + +1. Click the layer you want to mask in the **Layers** panel. +2. Choose **Layer › Layer Mask › Reveal Selection** to show only the selected area, or **Hide Selection** to hide it. + +To start with the whole layer visible or hidden and paint the mask yourself, use **Layer › Layer Mask › Reveal All** or **Hide All**. + +## Remove or apply a mask + +- **Layer › Layer Mask › Delete** removes the mask. +- **Layer › Layer Mask › Apply** applies the mask to the layer's pixels. +- **Layer › Layer Mask › Disable Layer Mask** (or **Enable Layer Mask**) switches the mask off and on. + +To limit a color adjustment to the selection, see [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md). diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 0000000000..29e229c0bd --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,25 @@ +--- +title: PhotoCraft +description: PhotoCraft is a free, open-source image editor with layers, masks, type and real PSD files, built as an alternative to Adobe Photoshop. +verified: 0.8.2 +--- + +PhotoCraft is a free, open-source image editor with layers, masks, adjustment layers, layer styles, type, vector shapes and brushes. It is an alternative to Adobe Photoshop: the menus, panels, tools and shortcuts follow Photoshop's layout, so Photoshop users find things where they expect them. + +It is a native app written in Rust that draws the canvas on your graphics card, works offline, and opens and saves layered PSD files. It runs on macOS, Windows, Linux and FreeBSD, and a web version runs in the browser. PhotoCraft is in early alpha: much of Photoshop's feature surface exists, but it is not yet a Photoshop replacement for daily professional work. See [Can PhotoCraft replace Photoshop?](faq/replace-photoshop.md). + +## Start here + +- [Install PhotoCraft](getting-started/install.md) +- [Your first edit](getting-started/index.md) +- [Guides: open, adjust, select, type, export](guides/index.md) +- [Troubleshooting](troubleshooting/index.md) +- [FAQ](faq/index.md) + +## Part of the Craft App Suite + +PhotoCraft is one of the Crafting Apps from the ArtCraft team, next to VectorCraft, FilmCraft and the other Craft apps. You can install and update them from [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/photocraft/issues). In PhotoCraft, **Help › Report an Issue…** opens the same issue tracker. diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md new file mode 100644 index 0000000000..ee8bc860ee --- /dev/null +++ b/docs/user/troubleshooting/get-help.md @@ -0,0 +1,19 @@ +--- +title: Get help with a problem +description: How to ask for help or file a PhotoCraft bug so it can be fixed, and what to include. +order: 30 +verified: 0.8.2 +--- + +Open **Help › Report an Issue…**. It takes you to the [PhotoCraft issue tracker](https://github.com/storytold/photocraft/issues) on GitHub. You can also ask in [Discord](https://discord.gg/artcraft). + +## What to include + +1. What you did, step by step, and what you expected. +2. What happened instead. A screenshot or a short video helps. +3. Your system details. Choose **Help › System Info…**, click **Copy**, and paste the text into your message. It lists the PhotoCraft version, your operating system and processor, and your graphics adapter. +4. The file, if one file causes it. Zip a PSD or other file and attach it. Leave it out if it contains anything private. + +## Before you post + +Search the tracker for your problem first. If someone already reported it, add your details there instead of opening a new issue. For known problems, see the other pages in [Troubleshooting](index.md). diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 0000000000..4785ae412b --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,11 @@ +--- +title: Troubleshooting +description: Fixes and workarounds for known PhotoCraft problems. +order: 30 +verified: 0.8.2 +--- + +- [PhotoCraft does not start](photocraft-does-not-start.md) +- [The Windows taskbar turns black when PhotoCraft is maximized](taskbar-turns-black.md) +- [Text moves when I start editing a PSD text layer](text-moves-when-editing.md) +- [Get help with a problem](get-help.md) diff --git a/docs/user/troubleshooting/photocraft-does-not-start.md b/docs/user/troubleshooting/photocraft-does-not-start.md new file mode 100644 index 0000000000..549f6bd65d --- /dev/null +++ b/docs/user/troubleshooting/photocraft-does-not-start.md @@ -0,0 +1,58 @@ +--- +title: PhotoCraft does not start +description: PhotoCraft closes or crashes at startup on Windows PCs with Intel graphics (it uses DirectX 12 and picks a safer graphics mode after a crash) or on older Intel Macs (0.8.2 fixes one cause; no fix yet for Macs patched with OpenCore Legacy Patcher). +order: 30 +verified: 0.8.2 +--- + +On Windows, PhotoCraft starts Intel graphics on DirectX 12. If PhotoCraft closed while it was starting, start it again: it picks a safer graphics mode after a crash. On an older Intel Mac that shows a `Device(Lost)` error, update to 0.8.2, which fixes one cause; on Macs patched with OpenCore Legacy Patcher there is no fix yet. + +## Symptoms + +- Windows with Intel graphics (for example Intel UHD Graphics 630): PhotoCraft closes or crashes right after you start it. Windows Event Viewer shows an application error in `igvk64.dll` with exception `0xc0000005` ([issue 2320](https://github.com/storytold/photocraft/issues/2320), [issue 4](https://github.com/storytold/photocraft/issues/4)). +- Intel Mac: the app icon bounces in the Dock and the app disappears. Started from Terminal, it prints `Error: Wgpu(RequestDeviceError(RequestDeviceError { inner: Core(Device(Lost)) }))` ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). + +## Fix + +### Windows + +1. Use PhotoCraft 0.7.0 or later. Older builds do not have the steps below. +2. Start PhotoCraft again. Double-clicking its icon or a `.pcraft` file works. PhotoCraft notes that the last start did not finish and uses the next safer graphics mode this time: + - A start on Vulkan is followed by one on DirectX 12. + - A start on DirectX 12 is followed by one on the CPU. The CPU mode does not depend on the graphics driver. +3. If it started, nothing more to do. PhotoCraft remembers the mode that worked. You can see or reset it under **Edit › Preferences › Performance…** (on macOS, **PhotoCraft › Settings › Performance…**). + +If PhotoCraft still closes on every start, force DirectX 12 from PowerShell: + +1. Open PowerShell in the folder that contains `photocraft.exe`. +2. Run these three lines: + + $env:WGPU_BACKEND = "dx12" + & .\photocraft.exe + Remove-Item Env:WGPU_BACKEND + +The last line clears the setting so later launches use the default again. You need the first two lines each time you start PhotoCraft this way. While `WGPU_BACKEND` is set, PhotoCraft uses it and does not step down to a safer mode by itself ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). + +### Intel Mac + +1. Update to PhotoCraft 0.8.2 or later. On Intel Macs it skips a set of graphics checks that macOS could not compile on older Intel graphics, such as Intel Iris Pro on macOS Monterey ([issue 3107](https://github.com/storytold/photocraft/issues/3107)). +2. Start PhotoCraft again. + +If it still fails, there is no fix yet. This is the case reported on Macs patched with OpenCore Legacy Patcher, where setting `WGPU_BACKEND`, `--safe-gpu` and `PHOTOCRAFT_CPU_CANVAS=1` did not change the error ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). Add your Mac model, macOS version and PhotoCraft version to that issue. + +## Why this happens + +On Windows, PhotoCraft draws with your graphics driver. Intel's Vulkan driver (`igvk64.dll`) crashed PhotoCraft on a UHD 630 PC with PhotoCraft 0.6.0, and the DirectX 12 setting above avoided the crash ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). Since 0.7.0 PhotoCraft prefers DirectX 12 on Intel graphics. Before it opens the window, it writes a small file called `gpu-starting.json` in its settings folder and deletes it once the first frames are drawn. A start that crashed leaves the file behind, and the next start reads it and moves to the next safer mode. + +## Still stuck? + +See [Get help with a problem](get-help.md). Say which graphics card you have, which PhotoCraft version you use and how you started it. Add one of these: + +- If you can open PhotoCraft: the text from **Help › System Info…**. +- If PhotoCraft does not open at all: the log file `photocraft.log`, plus the faulting module and exception code from Event Viewer on Windows. PhotoCraft keeps it in a `logs` folder inside its settings folder: + - Windows: `%APPDATA%\Photocraft\logs\photocraft.log` + - macOS: `~/Library/Application Support/Photocraft/logs/photocraft.log` + - Linux: `~/.config/photocraft/logs/photocraft.log` + - Portable copy (a `portable.txt` file next to the program): the `PhotoCraftData\logs` folder next to the program. + + Every start moves the previous log to `photocraft.1.log`. If you already started PhotoCraft again after the crash, attach `photocraft.1.log` too, because it holds the run that crashed. diff --git a/docs/user/troubleshooting/taskbar-turns-black.md b/docs/user/troubleshooting/taskbar-turns-black.md new file mode 100644 index 0000000000..0c8f5b733e --- /dev/null +++ b/docs/user/troubleshooting/taskbar-turns-black.md @@ -0,0 +1,29 @@ +--- +title: The Windows taskbar turns black when PhotoCraft is maximized +description: On Windows 11, PhotoCraft 0.7.0 can turn the taskbar black or hide it when maximized. PhotoCraft 0.8.2 fixes this; update. +order: 10 +verified: 0.8.2 +--- + +Update PhotoCraft. Version 0.7.0 on Windows 11 could turn the taskbar black or hide it while the window was maximized. PhotoCraft 0.8.2 includes the fix. + +## Symptoms + +- You maximize the PhotoCraft window, or switch it to full screen, and the Windows taskbar turns completely black or is hidden. +- After you resize the window and maximize it again, a black bar the height of the taskbar stays at the bottom of the screen. + +## Fix + +1. Check your version with **Help › System Info…**, which starts with "PhotoCraft" and the version. +2. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). See [Install PhotoCraft](../getting-started/install.md). +3. Start PhotoCraft again and maximize the window. + +A user who saw the black taskbar on 0.7.0 confirmed that it is gone in 0.8.2 ([issue 3864](https://github.com/storytold/photocraft/issues/3864)). + +## Why this happens + +PhotoCraft draws its own title bar. When such a window was maximized, Windows sized it to cover the whole screen, taskbar included, took it for a full-screen app and dropped the taskbar's background. The fix keeps a maximized PhotoCraft window clear of the taskbar ([issue 3144](https://github.com/storytold/photocraft/issues/3144), [pull request 3308](https://github.com/storytold/photocraft/pull/3308)). + +## Still stuck? + +If you still see it on 0.8.2, add your Windows version and PhotoCraft version to [issue 3864](https://github.com/storytold/photocraft/issues/3864), or ask in [Discord](https://discord.gg/artcraft). As a test, you can switch on **System title bar** under **Edit › Preferences › Interface…**, click **Apply** and restart PhotoCraft; Windows then draws the title bar. See [Get help with a problem](get-help.md) for what to include. diff --git a/docs/user/troubleshooting/text-moves-when-editing.md b/docs/user/troubleshooting/text-moves-when-editing.md new file mode 100644 index 0000000000..7130c8c7cf --- /dev/null +++ b/docs/user/troubleshooting/text-moves-when-editing.md @@ -0,0 +1,29 @@ +--- +title: Text moves when I start editing a PSD text layer +description: In PhotoCraft 0.7.0, a text layer from a Photoshop file can jump when you click it with the Type tool. PhotoCraft 0.8.2 fixes the known causes; update. +order: 20 +verified: 0.8.2 +--- + +Update PhotoCraft. In version 0.7.0, a text layer from a Photoshop file could jump or disappear when you started editing it. PhotoCraft 0.8.2 reads the text's styles and position the way Photoshop saved them, so the text stays where it was. + +## Symptoms + +- You open a PSD that was made in Photoshop and click one of its text layers with the **Horizontal Type Tool**. The text shifts, often to the left, as soon as editing starts. +- Centered text may show as left-aligned in the options bar. +- In some files, text that you click can disappear. + +## Fix + +1. Check your version with **Help › System Info…**. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). +2. Open the PSD again and click the text layer with the **Horizontal Type Tool**. + +If you already saved the file in 0.7.0 after the text moved, the moved text is now in the file. Go back to your original PSD if you still have it. + +## Why this happens + +Clicking a PSD text layer with the Type tool starts editing it, and PhotoCraft then lays the text out itself instead of showing the pixels Photoshop saved. PhotoCraft 0.7.0 read the alignment and size of each run of text only from Photoshop's default styles, and ignored part of the text position that recent Photoshop versions store. Both are fixed in 0.8.2 ([issue 3795](https://github.com/storytold/photocraft/issues/3795), [pull request 3819](https://github.com/storytold/photocraft/pull/3819)). + +## Still stuck? + +If text still moves in 0.8.2, keep a copy of the original with **File › Save a Copy…**, then report it in the [issue tracker](https://github.com/storytold/photocraft/issues) with the PSD (zipped) and the Photoshop version that saved it. You can also ask in [Discord](https://discord.gg/artcraft). See [Get help with a problem](get-help.md). From 6cec0cab24bf80e5ca1fa7bea16174030e70d034 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 18 Oct 2026 16:24:03 -0600 Subject: [PATCH 172/304] Save: a read-only file is refused, so its metadata test asserts that MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's 589941a5 (#6600) refuses to replace a read-only file before anything is written. This branch's test wrote to a 0o444 file and expected the save to succeed, so the merge of the two failed on Linux: cannot save .../doc.psd: the file is read-only; the original file was not changed No bug on either side: the two changes disagreed about what a read-only save should do, and main's answer is the deliberate one. The test now asserts the refusal, that the original is untouched, and that the attribute and mode survive — which is what it set out to check. The writable cases alongside it still cover the metadata copy itself. The windows-latest failure was the roll-up gate reporting this same failure, not a second one. --- crates/format/src/atomic/meta/tests.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs index 8bf5689039..5c8f08c6ba 100644 --- a/crates/format/src/atomic/meta/tests.rs +++ b/crates/format/src/atomic/meta/tests.rs @@ -93,8 +93,8 @@ mod xattrs { assert_eq!(get(&p, TAG), Some(value)); } - /// Copying the mode must not get in the way of copying the attributes: a read-only file is - /// still writable to us *before* its mode lands on the temporary file, and only then. + /// A read-only file is refused outright (#6600), so a save never reaches the metadata copy and + /// the file keeps everything it had. The writable cases above cover the copy itself. #[test] fn a_read_only_file_keeps_its_attributes_and_its_mode() { use std::os::unix::fs::PermissionsExt; @@ -106,9 +106,13 @@ mod xattrs { } std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap(); - atomic_write(&p, b"second, longer").unwrap(); + // #6600: a read-only file is refused before anything is written, so the question this test + // asks — does a replace keep the attributes and the mode — is answered by the save not + // happening. The writable case above covers attributes surviving a replace. + let e = atomic_write(&p, b"second, longer").expect_err("a read-only file is refused"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied); - assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(std::fs::read(&p).unwrap(), b"first", "the original was left alone"); assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "a read-only file lost its attribute"); assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o444); } From eeeeb13f0ea156b90cf68d26e1fb5a4a9f66f663 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 18:25:22 -0400 Subject: [PATCH 173/304] Print: restore the winsafe dependency, the DLL check and its tests lost in merges (#5884) The merges from main dropped three parts of 86f6199b: - `winsafe` from crates/engine/Cargo.toml (Cargo.lock still listed it, so every `--locked` CI step failed). It now sits in the engine's existing `cfg(windows)` dependency table next to `same-file`. - the `require_windows_pdf()` call. Main moved `craft_print::submit` into `spool_job`, so the check now runs there, right before the Windows spooler route; that is the only call that reaches winprint's `WinPdfPrinter` (Windows.Data.Pdf.dll). The native RGB route, IPP, the app spooler and Save as PDF don't need the DLL and are unchanged. - the Windows load test and the no-op test for other platforms. Co-Authored-By: Claude Opus 5.5 --- crates/engine/Cargo.toml | 3 +++ crates/engine/src/print_cmds.rs | 1 + crates/engine/src/print_cmds/tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index fb3bc3fbec..6b710eab93 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -47,6 +47,9 @@ tempfile = "3" # file is that file (std's `MetadataExt::file_index` is unstable). [target.'cfg(windows)'.dependencies] same-file = "1" +# `file.print` loads Windows.Data.Pdf.dll before calling into it (#5884): the safe binding +# ui-egui and craft-print already use, pinned to the same version. +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } # Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9d84195415..e657a60521 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -90,6 +90,7 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) - return app(sp); } if let Some(name) = system_spooler() { + require_windows_pdf()?; return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other); } } diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index c54dddf6f8..2b0f71bc14 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -772,3 +772,29 @@ mod cups { assert_eq!(jobs, 2, "both prints reached the fake CUPS"); } } + +#[cfg(target_os = "windows")] +#[test] +fn windows_pdf_component_is_detected_by_loading_its_dll() { + // #5884: a Windows image without a loadable Windows.Data.Pdf.dll reports an error instead of + // aborting at the delay-load stub. + let dir = tempfile::tempdir().unwrap(); + let dll = windows_pdf_dll(dir.path()); + assert!(!windows_pdf_loads(&dll)); + // Present but not loadable counts as missing. + std::fs::create_dir(dir.path().join("System32")).unwrap(); + std::fs::write(&dll, b"MZ").unwrap(); + assert!(!windows_pdf_loads(&dll)); + let root = std::env::var_os("SystemRoot").unwrap(); + let system = windows_pdf_dll(std::path::Path::new(&root)); + if system.is_file() { + assert!(windows_pdf_loads(&system)); + assert!(require_windows_pdf().is_ok()); + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn the_windows_pdf_check_is_a_no_op_elsewhere() { + assert!(require_windows_pdf().is_ok()); +} From 732070b22d77c90b598310a3ee21d089b6bda3bd Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 18:51:08 -0400 Subject: [PATCH 174/304] Print: let the Windows PDF test pass where the system DLL cannot load (#5884) A stripped Windows image can have Windows.Data.Pdf.dll present but not loadable; that is the case `require_windows_pdf` reports, so the test now checks that the result follows the loader instead of assuming the DLL loads. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/print_cmds/tests.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 2b0f71bc14..31dfa5d1a7 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -786,11 +786,9 @@ fn windows_pdf_component_is_detected_by_loading_its_dll() { std::fs::write(&dll, b"MZ").unwrap(); assert!(!windows_pdf_loads(&dll)); let root = std::env::var_os("SystemRoot").unwrap(); + // The real DLL may be missing or unloadable on a stripped image; the check follows the loader. let system = windows_pdf_dll(std::path::Path::new(&root)); - if system.is_file() { - assert!(windows_pdf_loads(&system)); - assert!(require_windows_pdf().is_ok()); - } + assert_eq!(require_windows_pdf().is_ok(), windows_pdf_loads(&system)); } #[cfg(not(target_os = "windows"))] From e2adb1816586d28e775d4185cac7bf2f6ddd4518 Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Thu, 15 Oct 2026 21:01:24 +0200 Subject: [PATCH 175/304] fix(psd): render constant noise gradient fills --- crates/io/src/blocks.rs | 66 ++++++++++++++++++++++++++++++++++++++--- docs/gaps.md | 5 ++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..f356c13204 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -294,6 +294,29 @@ pub(crate) fn gradient_stops_editable(grad: &Descriptor, method: Option<&[u8]>) raw } +/// The exact, constant-RGB subset of Photoshop noise gradients. If every RGB component has the +/// same minimum and maximum, the generated field is constant regardless of seed or roughness. +/// Other models, transparency, and varying ranges are not approximated. +fn constant_rgb_noise_color(grad: &Descriptor) -> Option { + if enum_of(grad, "GrdF")? != b"ClNs" || enum_of(grad, "ClrS")? != b"RGBC" || bool_of(grad, "ShTr") { + return None; + } + let components = |key: &str| -> Option<[f32; 4]> { + let Some(Value::List(values)) = grad.get(key) else { return None }; + if values.len() != 4 { + return None; + } + let converted: Vec = values.iter().map(|value| num(Some(value)).map(|v| v as f32)).collect::>()?; + converted.try_into().ok() + }; + let min = components("Mnm ")?; + let max = components("Mxm ")?; + if min.iter().zip(max).take(3).any(|(a, b)| !a.is_finite() || *a < 0.0 || *a > 100.0 || *a != b) { + return None; + } + Some(Color::rgb(min[0] / 100.0, min[1] / 100.0, min[2] / 100.0)) +} + fn gradient_stops_raw(grad: &Descriptor) -> RawStops { let mut stops = Vec::new(); let mut mids = Vec::new(); @@ -413,7 +436,8 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { b"GdFl" => { let angle = num(d.get("Angl")).unwrap_or(90.0) as f32; let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); - let raw = get_desc(d, "Grad").map(|g| gradient_stops_editable(g, gradient_method(d))); + let gradient = get_desc(d, "Grad"); + let raw = gradient.map(|g| gradient_stops_editable(g, gradient_method(d))); let (mut stops, midpoints) = raw.map(|r| (r.stops, r.mids)).unwrap_or_default(); // Opacity stops with Photoshop's smoothness and opacity midpoints applied (raw when // the interpolation is plain); none when fully opaque. @@ -422,7 +446,12 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { opacity_stops.clear(); } if stops.is_empty() { - stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + if let Some(grad) = gradient.filter(|g| enum_of(g, "GrdF") == Some(b"ClNs")) { + let color = constant_rgb_noise_color(grad)?; + stops = vec![(0.0, color), (1.0, color)]; + } else { + stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + } } let pct = |o: &Descriptor, k: &str| num(o.get(k)).map_or(0.0, |v| v as f32 / 100.0); let offset = get_desc(d, "Ofst").map_or((0.0, 0.0), |o| (pct(o, "Hrzn"), pct(o, "Vrtc"))); @@ -757,6 +786,37 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool { #[cfg(test)] mod tests { + use super::*; + + fn noise_gradient_fill(min: [i32; 4], max: [i32; 4], add_transparency: bool) -> Descriptor { + let values = |v: [i32; 4]| Value::List(v.into_iter().map(Value::Integer).collect()); + let gradient = Descriptor::new("Grdn") + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("ClNs") }) + .with("ClrS", Value::Enumerated { type_id: Id::new("ClrS"), value: Id::new("RGBC") }) + .with("ShTr", Value::Boolean(add_transparency)) + .with("VctC", Value::Boolean(true)) + .with("RndS", Value::Integer(9052369)) + .with("Smth", Value::Integer(2048)) + .with("Mnm ", values(min)) + .with("Mxm ", values(max)); + Descriptor::new("null").with("Grad", Value::Descriptor(gradient)) + } + + #[test] + fn constant_rgb_noise_fill_uses_its_exact_colour() { + use photocraft_doc::Fill; + let fill = fill_from_desc(b"GdFl", &noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], false)); + let Some(Fill::Gradient { stops, .. }) = fill else { panic!("constant noise gradient") }; + assert_eq!(stops, vec![(0.0, Color::rgb(0.5, 0.25, 0.0)), (1.0, Color::rgb(0.5, 0.25, 0.0))]); + } + + #[test] + fn variable_or_transparent_noise_fill_is_not_fabricated_as_a_black_to_white_ramp() { + for descriptor in [noise_gradient_fill([0, 0, 0, 0], [100, 100, 100, 100], false), noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], true)] { + assert!(fill_from_desc(b"GdFl", &descriptor).is_none()); + } + } + /// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a /// gradient fill's opacity stops reach the model (as its opacity stops; written from stop /// alpha) and survive a write; the ramp's alpha follows them. @@ -888,8 +948,6 @@ mod tests { assert_eq!(super::brst_data(0), None); } - use super::*; - #[test] fn placed_layer_warp_is_read() { use photocraft_geom::warp::WarpStyle; diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87dde..ab42d0f8ef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -42,6 +42,10 @@ what an agent can't finish alone. ### G1. PSD fidelity and Photoshop acceptance - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. +- **Partial (2026-10-15, #5972 A1):** `GdFl` noise fills render when all three RGB ranges are + constant and transparency is disabled. Variable-range synthesis, HSB/Lab modes and + transparency remain unsupported and are preserved verbatim; the five visual examples do not + establish Photoshop's exact noise algorithm or include this degenerate constant case. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored oracles 188/258 (73%; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers @@ -270,6 +274,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | From ea0eec98a3cd238e97505a0bb7c978330f4f9791 Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Fri, 16 Oct 2026 08:42:52 -0700 Subject: [PATCH 176/304] macOS: list fonts activated outside the font folders (#6683) The installed-font scan read only /System/Library/Fonts, /Library/Fonts and ~/Library/Fonts (plus PingFang). Fonts macOS offers from anywhere else, such as those a font manager, Adobe Fonts or Office activated, never reached the font menus although other apps showed them. load_system_fonts now also registers every file Core Text offers. The list comes from fontique's Core Text backend, enabled for macOS only, used as a scratch collection whose families are discarded: only its file paths are read and registered in our own collection, so generic-family defaults and the merge of installed faces into the bundled families are unchanged. The reserved UI fonts under /System/Library/PrivateFrameworks are skipped (fontique cannot draw their outlines). On a stock macOS install the folder scan already listed all 183 Core Text families; the gap is fonts Core Text knows from elsewhere. With Hack activated from a scratch folder via CTFontManagerRegisterFontsForURL, the list had 313 families before and 314 after (Hack added, none removed), type.fonts lists it and a type layer renders in it. The scan stays 37-57 ms. Tests: font_files_of_lists_each_font_file_once (all platforms); coretext_lists_the_system_font_files and every_family_coretext_offers_is_listed (macOS). The last fails without the call when a font is activated elsewhere ("lacks: Hack") and passes with it. Cargo.lock gains fontique's optional per-platform dependencies for the `system` feature; `cargo tree` shows the feature only for aarch64-apple-darwin. Still open: Windows fonts outside the two folders (#3336) and the web build. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 38 ++++++++++++-- crates/text/Cargo.toml | 8 +++ crates/text/src/fonts.rs | 106 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aff38ac01b..5d7788208c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1234,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610 dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", - "roxmltree", + "roxmltree 0.20.0", "tempfile", "winprint", "winsafe", @@ -2116,7 +2116,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2141,9 +2141,17 @@ dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", "parlance", "read-fonts", + "roxmltree 0.21.1", "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", ] [[package]] @@ -4824,7 +4832,7 @@ dependencies = [ "photocraft-text", "photocraft-vector", "rayon", - "roxmltree", + "roxmltree 0.20.0", "same-file", "serde", "serde_json", @@ -4912,7 +4920,7 @@ dependencies = [ "photocraft-vector", "proptest", "resvg", - "roxmltree", + "roxmltree 0.20.0", "serde_json", "thiserror 2.0.21", ] @@ -5958,6 +5966,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -7458,7 +7475,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "rustybuzz", "simplecss", "siphasher", @@ -8704,6 +8721,17 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 985f627e90..f7d4150fa1 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -28,6 +28,14 @@ flate2 = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] } +# macOS only: the `system` feature adds fontique's Core Text backend, which lists every font the +# system offers wherever its file lives (fonts a font manager or Adobe Fonts activated, Office's +# own, Apple's on-demand downloads). `fonts.rs` reads only its list of files and registers them +# like the folder scan's, so our own collection keeps its behaviour (generic families, merging +# installed faces into the bundled families). It uses no fontconfig, unlike `system` on Linux. +[target.'cfg(target_os = "macos")'.dependencies] +parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts", "system"] } + [build-dependencies] # Deflates the bundled fonts (build.rs); `flate2` above inflates them. flate2 = { workspace = true } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 756ce87455..720d2b77eb 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -10,6 +10,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock}; use parley::FontContext; +#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))] +use parley::fontique::SourceKind; use parley::fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache}; use skrifa::raw::{FileRef, TableProvider, types::Tag}; use skrifa::{MetadataProvider, string::StringId}; @@ -254,6 +256,10 @@ impl FontDb { } #[cfg(target_os = "linux")] files.extend(fontconfig_font_paths()); + // Core Text knows every font macOS offers, also those activated from outside the + // font folders (#6683). + #[cfg(target_os = "macos")] + files.extend(coretext_font_paths()); // PingFang (the macOS Chinese UI font) lives outside the font folders. if cfg!(target_os = "macos") { files.extend(crate::cjk::mac_pingfang()); @@ -761,6 +767,49 @@ mod fontconfig_tests { } } +/// The font files Core Text offers: the installed fonts and those activated from any other folder +/// (a font manager, Adobe Fonts, Office, Apple's on-demand downloads). A scratch collection with +/// fontique's Core Text backend lists them; its fonts are not used, only their files, so our own +/// collection registers them like any scanned file. +#[cfg(target_os = "macos")] +fn coretext_font_paths() -> Vec { + // Reading arbitrary fonts must not take the scan down: no files is the folder scan's result. + std::panic::catch_unwind(|| { + let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true }); + let mut files = font_files_of(&mut system); + files.retain(|f| !f.starts_with(MACOS_PRIVATE_FONTS)); + files + }) + .unwrap_or_default() +} + +/// Fonts reserved for the system UI (PingFangUI.ttc among them). Core Text offers them, but +/// fontique can't draw their outlines (it works around PingFangUI for the same reason), so they +/// are not listed. +#[cfg(target_os = "macos")] +const MACOS_PRIVATE_FONTS: &str = "/System/Library/PrivateFrameworks"; + +/// The files `collection`'s fonts are read from, each once and in a fixed order. Fonts +/// registered from memory have no file. +#[cfg(any(target_os = "macos", all(test, not(target_arch = "wasm32"))))] +fn font_files_of(collection: &mut Collection) -> Vec { + let names: Vec = collection.family_names().map(str::to_string).collect(); + let mut files = HashSet::new(); + for name in names { + let Some(family) = collection.family_by_name(&name) else { + continue; + }; + for font in family.fonts() { + if let SourceKind::Path(path) = font.source().kind() { + files.insert(path.to_path_buf()); + } + } + } + let mut files: Vec<_> = files.into_iter().collect(); + files.sort(); + files +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn system_font_dirs() -> Vec { use std::path::PathBuf; @@ -903,6 +952,63 @@ mod tests { assert_eq!(super::localized_name("X", &[]), None); } + /// The files a collection's fonts come from (what Core Text's fonts are loaded from, #6683): + /// each once, none for fonts held in memory. + #[test] + fn font_files_of_lists_each_font_file_once() { + use super::{Blob, Collection, CollectionOptions}; + let dir = std::env::temp_dir().join(format!("photocraft-font-files-{}", std::process::id())); + let activated = dir.join("activated"); + std::fs::create_dir_all(&activated).unwrap(); + let (inter, mono) = (dir.join("inter.ttf"), activated.join("mono.ttf")); + std::fs::write(&inter, super::INTER_REGULAR.as_slice()).unwrap(); + std::fs::write(&mono, super::JETBRAINS_MONO_REGULAR.as_slice()).unwrap(); + let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: false }); + collection.load_fonts_from_paths([&dir]); + // The same file again and a font from memory add no files. + collection.load_fonts_from_paths([&inter]); + collection.register_fonts(Blob::new(std::sync::Arc::new(super::INTER_MEDIUM.clone())), None); + let files = super::font_files_of(&mut collection); + let _ = std::fs::remove_dir_all(&dir); + let mut expected = vec![inter, mono]; + expected.sort(); + assert_eq!(files, expected); + } + + /// Core Text lists the system's font files, all readable, none of the reserved UI fonts. + #[test] + #[cfg(target_os = "macos")] + fn coretext_lists_the_system_font_files() { + let files = super::coretext_font_paths(); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); + assert!(files.iter().all(|f| f.is_file() && !f.starts_with("/System/Library/PrivateFrameworks"))); + } + + /// Every family macOS offers is in the font list, not only those in the font folders (#6683). + #[test] + #[cfg(target_os = "macos")] + fn every_family_coretext_offers_is_listed() { + use super::{Collection, CollectionOptions, SourceKind}; + let mut system = Collection::new(CollectionOptions { shared: false, system_fonts: true }); + // Its names include localized aliases of a family ("Systemschrift"); the family has one name. + let aliases: Vec = system.family_names().map(str::to_string).collect(); + let mut offered = Vec::new(); + for family in aliases.iter().filter_map(|n| system.family_by_name(n)) { + // A family only the reserved UI fonts provide (PingFang before its download) is not listed. + let listed_file = + |font: &parley::fontique::FontInfo| matches!(font.source().kind(), SourceKind::Path(p) if !p.starts_with(super::MACOS_PRIVATE_FONTS)); + if !super::is_hidden_family(family.name()) && family.fonts().iter().any(listed_file) { + offered.push(family.name().to_string()); + } + } + offered.sort(); + offered.dedup(); + assert!(!offered.is_empty()); + let listed = super::FontDb::with_system_fonts().families(); + let missing: Vec<&String> = offered.iter().filter(|n| !listed.contains(n)).collect(); + assert!(missing.is_empty(), "macOS offers these families, the font list lacks them: {missing:?}"); + } + #[test] fn missing_font_dirs_are_skipped() { let mut files = Vec::new(); From 49d48fc43c4fa0ab20544c52b7dc665f414b6529 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:11:51 +0800 Subject: [PATCH 177/304] Fix blend preview coverage for periodic tiles and multiple views --- crates/ui-egui/src/blend_preview.rs | 27 +++++++----- crates/ui-egui/src/blend_preview/tests.rs | 34 ++++++++++++++ crates/ui-egui/src/canvas.rs | 7 ++- .../src/canvas/blend_viewport_tests.rs | 44 +++++++++++++++++++ 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index fb8b6f3177..06e7a208e1 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -29,8 +29,8 @@ pub(crate) struct BlendPreview { area: Option, /// The document shown for `mode`, with its key. shown: Option<(u64, Arc)>, - /// GPU preview key, affected region (including effects), and the area last refreshed. - covered: Option<(u64, Rect, Rect)>, + /// GPU preview key, affected region (including effects), and rectangles already refreshed. + covered: Option<(u64, Rect, Vec)>, } /// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the @@ -106,26 +106,31 @@ pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, ke return region; } let shown = region.intersect(&visible); - // `covered` records only this refresh, including an empty intersection; it is not cumulative. - // Keep `region` so `uncovered` can recompute the next visible area. Forgetting older coverage - // can cause a redundant refresh, but cannot skip newly exposed pixels for this key. - p.covered = Some((key, region, shown)); + p.covered = Some((key, region, vec![shown])); shown } /// A pan or zoom can expose pixels not yet rendered for the current preview key. pub(crate) fn uncovered(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, visible: Rect) -> Option { let p = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision)?; - let (covered_key, region, covered) = p.covered?; - if covered_key != key { + let (covered_key, region, covered) = p.covered.as_mut()?; + if *covered_key != key { return None; } let want = region.intersect(&visible); - if want.is_empty() || covered.contains_rect(&want) { + if want.is_empty() || covered.iter().any(|r| r.contains_rect(&want)) { return None; } - p.covered = Some((key, region, want)); - Some(want) + // Keep disjoint views without marking the unrendered gaps between them as covered. + // Bound bookkeeping during long pans: after eight rectangles, render the whole affected + // region once. A different mode/revision resets coverage through `clip_damage`. + covered.retain(|r| !want.contains_rect(r)); + let refresh = if covered.len() < 8 { want } else { *region }; + if refresh == *region { + covered.clear(); + } + covered.push(refresh); + Some(refresh) } fn is_preview_key(key: u64) -> bool { diff --git a/crates/ui-egui/src/blend_preview/tests.rs b/crates/ui-egui/src/blend_preview/tests.rs index 22908ac248..72e809f786 100644 --- a/crates/ui-egui/src/blend_preview/tests.rs +++ b/crates/ui-egui/src/blend_preview/tests.rs @@ -138,3 +138,37 @@ fn a_hovered_mode_previews_on_every_selected_layer() { hover(&mut app, &[top], Some(BlendMode::Multiply)); assert_ne!(display_doc(&mut app, 0).unwrap().1, key); } + +#[test] +fn blend_hover_disjoint_coverage_retains_views_without_hiding_gaps() { + let (s, top) = session(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + hover(&mut app, &[top], Some(BlendMode::Multiply)); + let (_, key) = display_doc(&mut app, 0).unwrap(); + let st = app.session.active().unwrap(); + let (id, revision, region) = (st.doc.id, st.revision, st.doc.bounds()); + let a = photocraft_geom::Rect::new(0, 0, 2, 2); + let b = photocraft_geom::Rect::new(4, 0, 6, 2); + assert_eq!(super::clip_damage(&mut app, id, revision, key, region, a), a); + assert_eq!(super::uncovered(&mut app, id, revision, key, b), Some(b)); + assert_eq!(super::uncovered(&mut app, id, revision, key, a), None); + let gap = photocraft_geom::Rect::new(2, 0, 4, 2); + assert_eq!(super::uncovered(&mut app, id, revision, key, gap), Some(gap)); + // Repeated distinct views have bounded bookkeeping, then the whole region is current. + let mut fell_back = false; + for x in (8..40).step_by(4) { + let visible = photocraft_geom::Rect::new(x, 0, x + 2, 2); + if super::uncovered(&mut app, id, revision, key, visible) == Some(region) { + fell_back = true; + break; + } + } + assert!(fell_back); + assert_eq!(super::uncovered(&mut app, id, revision, key, region), None); + hover(&mut app, &[top], Some(BlendMode::Screen)); + let (_, next) = display_doc(&mut app, 0).unwrap(); + assert_eq!(super::clip_damage(&mut app, id, revision, next, region, a), a); + assert_eq!(super::uncovered(&mut app, id, revision, next, b), Some(b)); + hover(&mut app, &[top], None); + assert_eq!(super::clip_damage(&mut app, id, revision, 0, region, a), region); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 99aa686904..dee9e1388e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1575,12 +1575,15 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: }; let (doc, raw_key) = display_doc(app, idx); let (display, _) = canvas_display(app, &doc, None); + // Repeated pattern tiles sample the same texture, including pixels outside the unwrapped + // viewport. Refresh the affected document region while Pattern Preview is enabled. + let blend_visible = if app.ui.view.pattern_preview { doc.bounds() } else { visible }; // The texture is shared by every display: only what it stores counts, not the monitor. let display_key = texture_key(display.as_deref()); let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key)); let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage)); if let Some(region) = damage { - damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, visible)); + damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, blend_visible)); } // To an adjustment dialog's preview: only what the view shows now (the rest as it moves there). if damage.is_some() @@ -1619,7 +1622,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: } // Blend previews keep offscreen pixels untouched until the view reaches them. // Use the normal refresh path so CPU fallback and pending redraws stay synchronized. - damage = crate::blend_preview::uncovered(app, id, revision, raw_key, visible); + damage = crate::blend_preview::uncovered(app, id, revision, raw_key, blend_visible); if damage.is_none() { return true; } diff --git a/crates/ui-egui/src/canvas/blend_viewport_tests.rs b/crates/ui-egui/src/canvas/blend_viewport_tests.rs index b1b4872aa6..ae6a01742d 100644 --- a/crates/ui-egui/src/canvas/blend_viewport_tests.rs +++ b/crates/ui-egui/src/canvas/blend_viewport_tests.rs @@ -30,6 +30,50 @@ fn blend_hover_multiple_layers_tracks_visible_canvas() { check_blend_viewport(false, true); } +#[test] +fn blend_hover_pattern_tiles_and_multiple_views_reuse_current_pixels() { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + let ctx = egui::Context::default(); + let a = DRect::new(724, 200, 1324, 500); + let b = DRect::new(0, 200, 300, 500); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + app.ui.view.pattern_preview = true; + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, b); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); + + app.ui.view.pattern_preview = false; + for mode in [BlendMode::Multiply, BlendMode::Screen] { + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(mode)); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert!(ensure_gpu(&mut app, &ctx, 0, b)); + for visible in [a, b, a, b] { + app.perf.last_refresh_px = 0; + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 0, "unchanged views must retain coverage together"); + assert_visible_pixels(&mut app, visible.intersect(&bounds)); + } + } + // Enabling periodic display during an existing hover must catch up the unseen gap too. + app.ui.view.pattern_preview = true; + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); +} + #[test] fn blend_hover_during_background_redraw_restores_committed_pixels() { let _guard = photocraft_testkit::gpu_lock(); From 850bf9fdaa7540d5c352cbddd97adfe793fc485a Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:59 +0200 Subject: [PATCH 178/304] perf(codecs): Avoid redundant PNG filter passes Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression. --- crates/codecs/src/codecs/png.rs | 132 +++++++++++++++++++---- docs/png-filter-selection-performance.md | 38 +++++++ 2 files changed, 151 insertions(+), 19 deletions(-) create mode 100644 docs/png-filter-selection-performance.md diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 7c1a633d17..34ae84acc0 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -505,8 +505,15 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o } }; let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); - let mut best = (cost(row), 0u8); + let mut best = cost(row); + let start = out.len(); + out.push(0); + out.extend_from_slice(row); for kind in 1u8..=4 { + if best == 0 { + break; + } + let mut c = 0u64; for i in 0..n { let x = row[i]; scratch[i] = match kind { @@ -515,27 +522,14 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; + c += u64::from((scratch[i] as i8).unsigned_abs()); } - let c = cost(&scratch[..n]); - if c < best.0 { - best = (c, kind); + if c < best { + best = c; + out[start] = kind; + out[start + 1..].copy_from_slice(&scratch[..n]); } } - out.push(best.1); - if best.1 == 0 { - out.extend_from_slice(row); - return; - } - for i in 0..n { - let x = row[i]; - scratch[i] = match best.1 { - 1 => x.wrapping_sub(left(i)), - 2 => x.wrapping_sub(up(i)), - 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), - _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), - }; - } - out.extend_from_slice(&scratch[..n]); } const ADLER_MOD: u32 = 65_521; @@ -659,6 +653,106 @@ mod indexed_tests { mod parallel_tests { use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_filter_selection() { + use std::{hint::black_box, time::Instant}; + for pattern in ["zero", "identical", "varied"] { + let row: Vec = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect(); + let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect(); + let mut scratch = vec![0; row.len()]; + let mut out = Vec::with_capacity(row.len() + 1); + for trial in 0..6 { + type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec); + let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + for _ in 0..4000 { + out.clear(); + black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out); + black_box(&out); + } + println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0); + } + } + } + } + + // The main-branch implementation before this refactor. + fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec) { + let n = row.len(); + let up = |i: usize| prev.map_or(0, |p| p[i]); + let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 }; + let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 }; + let paeth = |a: u8, b: u8, c: u8| { + let p = i16::from(a) + i16::from(b) - i16::from(c); + let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + }; + let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); + let mut best = (cost(row), 0u8); + for kind in 1u8..=4 { + for i in 0..n { + let x = row[i]; + scratch[i] = match kind { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + let c = cost(&scratch[..n]); + if c < best.0 { + best = (c, kind); + } + } + out.push(best.1); + if best.1 == 0 { + out.extend_from_slice(row); + return; + } + for i in 0..n { + let x = row[i]; + scratch[i] = match best.1 { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + out.extend_from_slice(&scratch[..n]); + } + + #[test] + fn adaptive_filter_preserves_minimum_cost_and_ties() { + for bpp in [1, 2, 3, 4, 6, 8] { + for seed in 0..32usize { + let row: Vec = (0..137).map(|i| (i * seed + i / 5) as u8).collect(); + let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect(); + for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] { + let mut expected = vec![99]; + let mut actual = expected.clone(); + let mut scratch = vec![0; row.len()]; + previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected); + filter_row(&row, prev, bpp, &mut scratch, &mut actual); + assert_eq!(actual, expected, "bpp={bpp}, seed={seed}"); + } + } + } + let mut out = Vec::new(); + filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); + assert_eq!(out, vec![0; 17]); + } + #[test] fn adler_combine_matches_one_pass() { let a: Vec = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect(); diff --git a/docs/png-filter-selection-performance.md b/docs/png-filter-selection-performance.md new file mode 100644 index 0000000000..34938b6d84 --- /dev/null +++ b/docs/png-filter-selection-performance.md @@ -0,0 +1,38 @@ +# PNG filter selection + +Only adaptive PNG filter selection changes: stop evaluating filters when their partial cost cannot win, and stop after a zero-cost winner. The kernel processes 4000 rows of 6000 RGBA8 pixels (24 MP) for zero, identical and varied rows; deflate is outside the timer. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| zero | 448.138 | 5.734 | -98.7% | +| identical | 447.020 | 59.819 | -86.6% | +| varied | 450.469 | 277.254 | -38.5% | + +These are serial filter-kernel timings, not end-to-end PNG encoding timings. + +The committed oracle is the previous main-branch filter function. The unit test compares exact output bytes, including the selected filter/tie order, across six bytes-per-pixel layouts, 32 patterns and absent/identical/shifted previous rows. Existing encoder round trips and deterministic worker-count tests remain in place. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-codecs --lib --no-run +# Run the printed executable at normal priority: + benchmark_filter_selection --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-codecs --locked`: passed (782 passed, 4 ignored). +- `cargo clippy -p photocraft-codecs --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-codecs --locked --target wasm32-unknown-unknown`: passed. +- `cargo xtask test-corpus -p codecs`: passed (796 passed, 4 ignored). From f6e7fc3c2a7e72afe23f7fce8d30871c303039f2 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:59 +0200 Subject: [PATCH 179/304] perf(raster): Avoid redundant default pixel work Preserve default sample bits while skipping absent zero tiles and redundant default-pixel copies. Add the previous read path as the oracle. --- crates/raster/src/lib.rs | 118 +++++++++++++++++++++--- docs/sparse-raster-reads-performance.md | 40 ++++++++ 2 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 docs/sparse-raster-reads-performance.md diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 20b809764a..1ea317d955 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -239,11 +239,19 @@ impl Surface { decode_pixel(&self.format, &self.default_pixel) } + fn default_samples(&self) -> [f32; 8] { + let mut px = [0.0; 8]; + for (i, v) in px.iter_mut().enumerate().take(self.channels()) { + *v = read_sample(&self.default_pixel, self.format.sample, i); + } + px + } + /// Mutable access to a tile, allocating it (filled with the default pixel) or un-sharing it. pub fn tile_mut(&mut self, c: TileCoord) -> &mut Tile { let fmt = self.format; - let dp = self.default_pixel.clone(); - let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, &dp))); + let dp = &self.default_pixel; + let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, dp))); Arc::make_mut(arc) } @@ -319,9 +327,9 @@ impl Surface { /// Drop tiles that are entirely the default pixel. pub fn prune(&mut self) { - let dp = self.default_pixel.clone(); + let dp = self.default_pixel.as_ref(); let bpp = dp.len(); - self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != &*dp)); + self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != dp)); } #[inline] @@ -411,10 +419,15 @@ impl Surface { let w = r.width() as usize; out.clear(); out.resize(w * r.height() as usize * n, 0.0); - let dp = self.default_pixel(); + let dp = self.default_samples(); + let dp = &dp[..n]; + let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); + if tile.is_none() && zero { + continue; + } for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; @@ -426,9 +439,10 @@ impl Surface { *d = read_sample(&t.data, self.format.sample, base + i); } } + None if n == 1 => dst.fill(dp[0]), None => { for px in dst.chunks_exact_mut(n) { - px.copy_from_slice(&dp); + px.copy_from_slice(dp); } } } @@ -445,7 +459,7 @@ impl Surface { debug_assert_eq!(out.len(), w * r.height() as usize); let fmt = self.format; let n = fmt.channels(); - let dp = to_rgba(&fmt, &self.default_pixel()); + let dp = to_rgba(&fmt, &self.default_samples()); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); @@ -510,7 +524,7 @@ impl Surface { let fmt = self.format; let rgba8 = matches!((fmt.mode, fmt.sample, fmt.alpha), (ColorMode::Rgb, SampleType::U8, true)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - let dp = to_rgba(&fmt, &self.default_pixel()); + let dp = to_rgba(&fmt, &self.default_samples()); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; let mut row = Vec::new(); for tc in r.tiles() { @@ -551,10 +565,15 @@ impl Surface { let n = self.channels(); let w = r.width() as usize; let mut out = vec![0.0f32; w * r.height() as usize * n]; - let dp = self.default_pixel(); + let dp = self.default_samples(); + let dp = &dp[..n]; + let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); + if tile.is_none() && zero { + continue; + } for y in tr.y0..tr.y1 { for x in tr.x0..tr.x1 { let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; @@ -565,7 +584,8 @@ impl Surface { out[o + i] = read_sample(&t.data, self.format.sample, base + i); } } - None => out[o..o + n].copy_from_slice(&dp), + None if n == 1 => out[o] = dp[0], + None => out[o..o + n].copy_from_slice(dp), } } } @@ -1102,6 +1122,58 @@ pub fn is_hdr(sample: SampleType) -> bool { #[cfg(test)] mod tests { + + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_sparse_reads() { + use std::{hint::black_box, time::Instant}; + let rect = Rect::new(0, 0, 6000, 4000); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for default in [0.0, 0.25] { + let mut s = Surface::with_default(PixelFormat::GRAY8.with_sample(depth), &[default]); + s.fill_rect(Rect::new(257, 257, 266, 266), &[0.75]); + assert_eq!(s.read_region(rect), previous_read_region(&s, rect)); + for trial in 0..6 { + let mut paths = [("main", previous_read_region as fn(&Surface, Rect) -> Vec), ("split", Surface::read_region)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + let out = black_box(path)(&s, rect); + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + black_box(&out); + println!("sparse,{depth:?}-{default},{trial},{name},{elapsed:.6}"); + } + } + } + } + } + fn previous_read_region(surface: &Surface, r: Rect) -> Vec { + let n = surface.channels(); + let w = r.width() as usize; + let mut out = vec![0.0f32; w * r.height() as usize * n]; + let dp = surface.default_pixel(); + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let tile = surface.tiles.get(&tc); + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; + match tile { + Some(t) => { + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; + for i in 0..n { + out[o + i] = read_sample(&t.data, surface.format.sample, base + i); + } + } + None => out[o..o + n].copy_from_slice(&dp), + } + } + } + } + out + } /// Distinct tile allocations in `s`. fn distinct_tiles(s: &Surface) -> usize { let mut seen: Vec<*const Tile> = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect(); @@ -1237,27 +1309,47 @@ mod tests { #[test] fn zero_alloc_accessors_match_read_region() { - for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::GRAY8] { - let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + for (fmt, default) in [ + PixelFormat::RGBA8, + PixelFormat::RGBA16, + PixelFormat::RGBA32F, + PixelFormat::GRAYA8, + PixelFormat::CMYKA8, + PixelFormat::GRAY8, + PixelFormat::GRAY8.with_sample(SampleType::F32), + PixelFormat::new(ColorMode::Lab, SampleType::U16, true), + ] + .into_iter() + .flat_map(|fmt| [0.0, 0.25, -0.0].map(|default| (fmt, default))) + { + let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]); let r = Rect::new(-300, -20, 300, 40); for (i, (x, y)) in [(-299, -19), (0, 0), (255, 39), (256, 10), (299, 0)].into_iter().enumerate() { let px: Vec = (0..fmt.channels()).map(|c| ((i * 3 + c) % 5) as f32 / 4.0).collect(); s.write_pixel(x, y, &px); } let region = s.read_region(r); + assert!(region.iter().map(|v| v.to_bits()).eq(previous_read_region(&s, r).into_iter().map(f32::to_bits))); let mut into = vec![9.0; 3]; s.read_region_into(r, &mut into); assert_eq!(region, into); let mut rgba = vec![[0.0; 4]; (r.width() * r.height()) as usize]; s.read_rgba_into(r, &mut rgba); + let mut rgba8 = vec![[0; 4]; rgba.len()]; + s.read_rgba8_into(r, &mut rgba8); let n = fmt.channels(); for (i, p) in region.chunks_exact(n).enumerate() { let want = to_rgba(&fmt, p); + assert_eq!(rgba8[i], want.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)); for c in 0..4 { assert!((rgba[i][c] - want[c]).abs() < 1e-6, "{fmt:?} px {i}"); } let (x, y) = (r.x0 + (i % r.width() as usize) as i32, r.y0 + (i / r.width() as usize) as i32); - assert_eq!(s.sample_channel(x, y, 0), p[0]); + for c in 0..n { + let bits = s.sample_channel(x, y, c).to_bits(); + assert_eq!(bits, p[c].to_bits()); + assert_eq!(bits, into[i * n + c].to_bits()); + } } assert!(s.has_tiles_in(Rect::new(0, 0, 1, 1))); assert!(!s.has_tiles_in(Rect::new(1000, 1000, 1001, 1001))); diff --git a/docs/sparse-raster-reads-performance.md b/docs/sparse-raster-reads-performance.md new file mode 100644 index 0000000000..6a76244a99 --- /dev/null +++ b/docs/sparse-raster-reads-performance.md @@ -0,0 +1,40 @@ +# Sparse raster reads + +Only default-pixel work changes: skip absent zero-default tiles and avoid temporary default-pixel allocations/copies. Read 6000×4000 single-channel pixels (24 MP) with one 9×9 painted patch, at three depths and two defaults. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| U8-0 | 48.484 | 0.430 | -99.1% | +| U8-0.25 | 48.934 | 4.466 | -90.9% | +| U16-0 | 48.517 | 0.441 | -99.1% | +| U16-0.25 | 48.475 | 4.461 | -90.8% | +| F32-0 | 49.066 | 0.479 | -99.0% | +| F32-0.25 | 48.511 | 4.503 | -90.7% | + +These are read_region timings including output allocation. ZIP CRC-32 acceleration is already on main and is not duplicated here. + +The committed oracle is the previous read_region implementation. Unit coverage compares exact float bits and checks read_region_into, RGBA/RGBA8 reads and individual samples across sparse tile boundaries, negative coordinates, signed zero, nonzero defaults and eight pixel formats. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-raster --lib --no-run +# Run the printed executable at normal priority: + benchmark_sparse_reads --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-raster --locked`: passed (27 passed, 1 ignored). +- `cargo clippy -p photocraft-raster --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-raster --locked --target wasm32-unknown-unknown`: passed. From 76a6405f9b25491cc1016ed2861e0976f6455b7a Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:31:59 +0200 Subject: [PATCH 180/304] perf(io): Interleave PSD pixels in the final buffer Remove intermediate band buffers while retaining byte-for-byte equivalence with the previous path at every sample depth. --- crates/io/src/pixels.rs | 142 ++++++++++++++++++++--------- docs/psd-interleave-performance.md | 38 ++++++++ 2 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 docs/psd-interleave-performance.md diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 0167eb3b3f..cca2a8dfd4 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -43,40 +43,43 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: Sampl let ch = planes.len(); let row = ch * bps; let mut out = vec![0u8; n * row]; - // One band of rows per core, written straight into its slice of the shared output: the - // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations - // instead of all of them. + if out.is_empty() { + return out; + } let bs = bands(n); - let parts: Vec> = par_map(bs.clone(), |band| { - let mut part = vec![0u8; band.len() * row]; - for (i, p) in band.clone().enumerate() { - for (c, plane) in planes.iter().enumerate() { - let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; - match plane { - Some(pl) if pl.len() >= (p + 1) * bps => { - let src = &pl[p * bps..(p + 1) * bps]; - for k in 0..bps { - dst[k] = src[bps - 1 - k]; - } - if cfg!(target_endian = "big") { - dst.copy_from_slice(src); - } - if invert[c] { - invert_sample(dst, s); - } - } - _ => dst.copy_from_slice(&fill[c]), - } + let step = bs.first().map_or(n, |b| b.len()) * row; + par_map(bs.into_iter().zip(out.chunks_mut(step)).collect(), |(band, part)| { + for (c, plane) in planes.iter().enumerate() { + let src = plane.and_then(|p| p.get(band.start * bps..)).unwrap_or_default(); + let inv = src.len() >= bps && invert[c]; + let fill = fill.get(c).map(Vec::as_slice).unwrap_or_default(); + match s { + SampleType::U8 => write_channel(part, ch, c, src, fill, |[v]| [if inv { 255 - v } else { v }]), + SampleType::U16 => write_channel(part, ch, c, src, fill, |v| { + let v = u16::from_be_bytes(v); + (if inv { 65535 - v } else { v }).to_ne_bytes() + }), + SampleType::F32 => write_channel(part, ch, c, src, fill, |v| { + // Preserve float bits when no inversion is requested. + if inv { (1.0 - f32::from_be_bytes(v)).to_ne_bytes() } else { u32::from_be_bytes(v).to_ne_bytes() } + }), } } - part }); - for (band, part) in bs.into_iter().zip(parts) { - out[band.start * row..band.end * row].copy_from_slice(&part); - } out } +fn write_channel(out: &mut [u8], ch: usize, c: usize, src: &[u8], fill: &[u8], decode: impl Fn([u8; N]) -> [u8; N]) { + let mut samples = src.as_chunks::().0.iter(); + for pixel in out.chunks_exact_mut(ch * N) { + let dst = &mut pixel[c * N..(c + 1) * N]; + match samples.next() { + Some(sample) => dst.copy_from_slice(&decode(*sample)), + None => dst.copy_from_slice(fill), + } + } +} + /// Splits interleaved native-endian bytes into big-endian planes. pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); @@ -155,6 +158,7 @@ pub fn zero_sample(s: SampleType) -> Vec { } /// Quantizes a normalized value to big-endian bytes. +#[inline] pub fn encode_be(v: f32, s: SampleType, out: &mut Vec) { match s { SampleType::U8 => out.push((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8), @@ -217,21 +221,29 @@ pub fn bands(n: usize) -> Vec> { mod tests { use super::*; - #[test] - fn banded_shuffles_match_a_sequential_reference() { - fn seq_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { - let bps = s.bytes(); - let ch = planes.len(); - let mut out = vec![0u8; n * ch * bps]; - for (c, plane) in planes.iter().enumerate() { - for i in 0..n { - let dst = &mut out[(i * ch + c) * bps..(i * ch + c + 1) * bps]; + pub(super) fn previous_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { + let bps = s.bytes(); + let ch = planes.len(); + let row = ch * bps; + let mut out = vec![0u8; n * row]; + // One band of rows per core, written straight into its slice of the shared output: the + // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations + // instead of all of them. + let bs = bands(n); + let parts: Vec> = par_map(bs.clone(), |band| { + let mut part = vec![0u8; band.len() * row]; + for (i, p) in band.clone().enumerate() { + for (c, plane) in planes.iter().enumerate() { + let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; match plane { - Some(p) if p.len() >= (i + 1) * bps => { - let src = &p[i * bps..(i + 1) * bps]; + Some(pl) if pl.len() >= (p + 1) * bps => { + let src = &pl[p * bps..(p + 1) * bps]; for k in 0..bps { dst[k] = src[bps - 1 - k]; } + if cfg!(target_endian = "big") { + dst.copy_from_slice(src); + } if invert[c] { invert_sample(dst, s); } @@ -240,8 +252,16 @@ mod tests { } } } - out + part + }); + for (band, part) in bs.into_iter().zip(parts) { + out[band.start * row..band.end * row].copy_from_slice(&part); } + out + } + + #[test] + fn banded_shuffles_match_a_sequential_reference() { fn seq_deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); let n = bytes.len() / (ch * bps).max(1); @@ -260,16 +280,17 @@ mod tests { planes } // Odd pixel counts cross band boundaries; a missing plane exercises the fill path. - for n in [1usize, 7, 64, 1000] { + for n in [0usize, 1, 7, 64, 1000] { for s in [SampleType::U8, SampleType::U16, SampleType::F32] { let bps = s.bytes(); let planes: Vec> = (0..4).map(|c| (0..n * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); let mut refs: Vec> = planes.iter().map(|p| Some(&p[..])).collect(); refs[2] = None; + refs[3] = Some(&planes[3][..(n * bps).saturating_sub(1)]); let fill: Vec> = (0..4).map(|c| vec![c as u8 * 40; bps]).collect(); let invert = [false, true, false, true]; let got = interleave(&refs, &fill, n, s, &invert); - let want = seq_interleave(&refs, &fill, n, s, &invert); + let want = previous_interleave(&refs, &fill, n, s, &invert); assert_eq!(got, want, "interleave n={n}"); let planes_back = deinterleave(&got, 4, s, &invert); let want_back = seq_deinterleave(&got, 4, s, &invert); @@ -287,6 +308,7 @@ mod tests { let fill = vec![zero_sample(s); 3]; let inv = [false, true, false]; let il = interleave(&refs, &fill, 4, s, &inv); + assert_eq!(interleave(&refs, &[], 4, s, &inv), il); let back = deinterleave(&il, 3, s, &inv); if s == SampleType::F32 { // 1 - (1 - v) may not be exact for floats; only check non-inverted planes. @@ -302,6 +324,12 @@ mod tests { fn missing_planes_use_fill() { let il = interleave(&[None, Some(&[5, 6])], &[vec![9], vec![0]], 2, SampleType::U8, &[false, false]); assert_eq!(il, vec![9, 5, 9, 6]); + for s in [SampleType::U8, SampleType::U16, SampleType::F32] { + for n in [1, 1000] { + let fill = vec![vec![9; s.bytes()]; 2]; + assert_eq!(interleave(&[None, Some(&[1, 2, 3][..s.bytes() - 1])], &fill, n, s, &[]), vec![9; n * 2 * s.bytes()]); + } + } } #[test] @@ -343,3 +371,35 @@ mod tests { assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5); } } + +#[cfg(test)] +mod measurement { + use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_interleave() { + use std::{hint::black_box, time::Instant}; + const N: usize = 24_000_000; + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let bps = sample.bytes(); + let planes: Vec> = (0..4).map(|c| (0..N * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); + let refs: Vec<_> = planes.iter().map(|p| Some(p.as_slice())).collect(); + let fill = vec![vec![0; bps]; 4]; + let invert = [false, true, false, true]; + assert_eq!(tests::previous_interleave(&refs, &fill, N, sample, &invert), interleave(&refs, &fill, N, sample, &invert)); + for trial in 0..6 { + let mut paths = [("main", tests::previous_interleave as fn(_, _, _, _, _) -> _), ("split", interleave)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + let out = black_box(path)(&refs, &fill, N, sample, &invert); + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + black_box(&out); + println!("interleave,{sample:?},{trial},{name},{elapsed:.6}"); + } + } + } + } +} diff --git a/docs/psd-interleave-performance.md b/docs/psd-interleave-performance.md new file mode 100644 index 0000000000..99ae70824f --- /dev/null +++ b/docs/psd-interleave-performance.md @@ -0,0 +1,38 @@ +# PSD pixel buffer allocation + +Only PSD pixel shuffling and sample-codec inlining change. Interleave writes into the final buffer rather than allocating an intermediate buffer per band. The kernel processes 24 million pixels with four full planes at 8-bit, 16-bit and float depth. + +Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max +(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that +base; none of the other performance splits is applied. Release uses optimization +level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. +Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal +priority with no concurrent builds. Timings are medians of five samples after one +warm-up, except the compositor's ten Criterion samples. These synthetic workloads +are reproducible checks of this change, not a representative image corpus. + +| Workload | Main ms | This change ms | Time change | +|---|---:|---:|---:| +| U8 | 20.493 | 5.097 | -75.1% | +| U16 | 31.827 | 6.312 | -80.2% | +| F32 | 41.915 | 11.305 | -73.0% | + +These are interleave-kernel timings, not whole PSD import/export timings. Direct RLE output is already on main and is not part of this PR. + +The committed equivalence test uses the literal previous main-branch interleave implementation. It compares raw bytes across U8/U16/F32, empty and partial bands, missing/truncated planes, fill pixels and inversion. Existing deinterleave reference and big-endian sample-codec round trips cover the companion paths. + +Reproduce with the same release profile environment: + +```sh +CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-io --lib --no-run +# Run the printed executable at normal priority: + benchmark_interleave --ignored --nocapture --test-threads=1 +``` + +## Local validation + +- `cargo test -p photocraft-io --locked`: passed (617 passed, 6 ignored). +- `cargo clippy -p photocraft-io --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-io --locked --target wasm32-unknown-unknown`: passed. +- `cargo xtask test-corpus -p io`: passed (646 passed, 6 ignored). From cb1b47945261043edf1696d4c8575d0a163347c5 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:37:54 +0300 Subject: [PATCH 181/304] fix(web): start the app when the server already inflated its gzip part (#7639) --- apps/photocraft-web-boot/Cargo.toml | 2 +- apps/photocraft-web-boot/src/main.rs | 105 +++++++++++++++++++++++++-- packaging/web/README.md | 4 + 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml index dd12341af2..7443ead089 100644 --- a/apps/photocraft-web-boot/Cargo.toml +++ b/apps/photocraft-web-boot/Cargo.toml @@ -21,4 +21,4 @@ flate2 = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs index 56c2bf1517..c8ba439208 100644 --- a/apps/photocraft-web-boot/src/main.rs +++ b/apps/photocraft-web-boot/src/main.rs @@ -8,7 +8,10 @@ //! one part, however large the app grows. //! //! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80). -//! In one that can't, this module inflates them itself, a little slower. +//! In one that can't, this module inflates them itself, a little slower. Some servers recognise +//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic` +//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then +//! taken as it is, by its first bytes. //! //! A download that fails is tried again, the later tries past the browser's cache (a bad copy //! there would otherwise fail forever). When every try fails the page says so and offers a reload. @@ -36,6 +39,34 @@ fn parts(list: &str) -> Option> { (!parts.is_empty() && parts.iter().all(plain)).then_some(parts) } +/// What the joined parts hold, told by their first bytes. +#[cfg(any(target_arch = "wasm32", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Payload { + /// The gzip packaging made, still to be inflated. + Gzip, + /// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser + /// already undid. + Wasm, +} + +/// Enough leading bytes to tell the payloads apart. +#[cfg(any(target_arch = "wasm32", test))] +const HEAD: usize = 4; + +/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or +/// `None` when it is neither, such as a web page or nothing at all. +#[cfg(any(target_arch = "wasm32", test))] +fn payload(head: &[u8]) -> Option { + if head.starts_with(&[0x1f, 0x8b]) { + Some(Payload::Gzip) + } else if head.starts_with(b"\0asm") { + Some(Payload::Wasm) + } else { + None + } +} + /// Gzip inflated a chunk at a time, for browsers that can't do it themselves. #[cfg(any(target_arch = "wasm32", test))] struct Inflater(flate2::write::GzDecoder>); @@ -69,11 +100,11 @@ mod boot { use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::{ - Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream, - TransformStreamDefaultController, Transformer, WritableStream, + Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, + TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter, }; - use super::Inflater; + use super::{HEAD, Inflater, Payload}; /// Where packaging puts the app, next to this module's own files. const PARTS: &str = "./app/parts.txt"; @@ -172,11 +203,71 @@ mod boot { Ok(compressed.pipe_through(&pair)) } + /// The next chunk `reader` gives, or `None` at the end of the stream. + async fn read(reader: &ReadableStreamDefaultReader) -> Result, JsValue> { + let result = JsFuture::from(reader.read()).await?; + if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() { + return Ok(None); + } + Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?)) + } + + /// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up + /// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending. + async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> { + if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await { + let _ = reader.cancel_with_reason(&e); + return Err(e); + } + Ok(()) + } + + /// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it. + async fn pass_on(held: Vec, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> { + for chunk in &held { + write(chunk, reader, writer).await?; + } + while let Some(chunk) = read(reader).await? { + write(&chunk, reader, writer).await?; + } + JsFuture::from(writer.close()).await?; + Ok(()) + } + + /// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the + /// browser has already done that. + async fn unpacked(joined: &ReadableStream) -> Result { + let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into(); + let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new()); + while head.len() < HEAD { + let Some(chunk) = read(&reader).await? else { break }; + let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length()); + head.extend(chunk.subarray(0, wanted).to_vec()); + held.push(chunk); + } + let Some(payload) = super::payload(&head) else { + let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly"); + // Stops the rest of the download, which nothing will read. + let _ = reader.cancel_with_reason(&e); + return Err(e); + }; + let rest = TransformStream::new()?; + let writer = rest.writable().get_writer()?; + spawn_local(async move { + if let Err(e) = pass_on(held, &reader, &writer).await { + let _ = writer.abort_with_reason(&e); + } + }); + match payload { + Payload::Gzip => inflated(&rest.readable()), + Payload::Wasm => Ok(rest.readable()), + } + } + async fn run(fresh: bool) -> Result<(), JsValue> { let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default(); let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?; let joined = TransformStream::new()?; - let wasm = inflated(&joined.readable())?; // Why the download stopped: the app's loader only reports that its stream broke. let cause = Rc::new(RefCell::new(None)); let failed = cause.clone(); @@ -187,6 +278,10 @@ mod boot { failed.replace(Some(e)); } }); + let wasm = match unpacked(&joined.readable()).await { + Ok(wasm) => wasm, + Err(e) => return Err(cause.take().unwrap_or(e)), + }; let headers = Headers::new()?; headers.set("Content-Type", "application/wasm")?; let init = ResponseInit::new(); diff --git a/packaging/web/README.md b/packaging/web/README.md index e96dc38257..1f62de0a90 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -73,6 +73,10 @@ save/download work before closing or reloading the page. A browser with WebGPU o required; `?webgl` forces the fallback when troubleshooting. In a browser that can't inflate a stream itself (no `DecompressionStream`: before Safari 16.4 and Firefox 113), the release zip's site inflates the app's parts in its own code, a little slower. +A server that recognises the gzip in a part and sends it with `Content-Encoding: gzip` or +`x-gzip` (Apache with `mod_mime_magic` does) works too: the browser inflates it on the way, and +the page then uses it as it is. (That holds while the app ships as one part, as it does today: +such a server labels only the first part, the one that starts with gzip's header.) To verify the browser deployment, open the page, open an image and save/export it. From af0857372ba55522dc1b557a28c18e4efb46db9d Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:37:55 +0300 Subject: [PATCH 182/304] test(web): the boot module tells gzip from wasm the browser already inflated (#7639) --- apps/photocraft-web-boot/src/main.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs index c8ba439208..c7dc50d9d5 100644 --- a/apps/photocraft-web-boot/src/main.rs +++ b/apps/photocraft-web-boot/src/main.rs @@ -353,7 +353,7 @@ fn main() { #[cfg(test)] mod tests { - use super::{Inflater, MARKER, parts}; + use super::{HEAD, Inflater, MARKER, Payload, parts, payload}; fn gzip(bytes: &[u8]) -> Vec { let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); @@ -412,6 +412,22 @@ mod tests { assert!(run(b"").is_err(), "nothing at all"); } + /// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with + /// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts + /// are then the app's WebAssembly, which must start as it is, not be inflated a second time. + #[test] + fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() { + let mut wasm = b"\0asm\x01\0\0\0".to_vec(); + wasm.extend(sample()); + let packed = gzip(&wasm); + assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged"); + assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser"); + assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD"); + assert_eq!(payload(b""), None, "a web page"); + assert_eq!(payload(b"\0as"), None, "a cut-off wasm header"); + assert_eq!(payload(b""), None, "nothing at all"); + } + #[test] fn a_parts_list_gives_its_names_in_order() { let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n"); From 80f896210a6871c9bc3b258f30e318fca2a2cd7a Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Mon, 19 Oct 2026 13:34:32 +0300 Subject: [PATCH 183/304] perf(compose): write band tiles as they finish, not after the band Parallel bands each collected their finished tiles until the band's last one was done, so with work stealing many bands could hold most of their tiles at once, close to a second copy of the output. Each tile is now put into its band behind a lock as soon as it is rendered; only the tiles in flight are held, as before the bands ran in parallel. --- crates/compose/src/lib.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5d5a733e2b..00cd5f4d00 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -194,13 +194,16 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer }); } else { // Few, wide bands: the tiles of each band in parallel, and the bands too, so a band's - // slowest tile doesn't leave the other cores idle until the next band starts. + // slowest tile doesn't leave the other cores idle until the next band starts. Each + // tile is written as soon as it is done, so only the tiles in flight are held. out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| { let y0 = rect.y0 + i as i32 * tile; - let parts: Vec = band_tiles(y0).into_par_iter().map(run).collect(); - for part in &parts { - put(band, y0, part); - } + let band = std::sync::Mutex::new(band); + band_tiles(y0).into_par_iter().for_each(|t| { + let part = run(t); + let mut band = band.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + put(&mut band, y0, &part); + }); }); } } From 6b1aa81561d6cc8487a95da27b95ef6d2d4199af Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:15:05 +0200 Subject: [PATCH 184/304] docs(perf): measure PNG filtering on real images --- docs/png-filter-selection-performance.md | 42 +++++++++++------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/docs/png-filter-selection-performance.md b/docs/png-filter-selection-performance.md index 34938b6d84..1f7ffeab29 100644 --- a/docs/png-filter-selection-performance.md +++ b/docs/png-filter-selection-performance.md @@ -1,35 +1,31 @@ # PNG filter selection -Only adaptive PNG filter selection changes: stop evaluating filters when their partial cost cannot win, and stop after a zero-cost winner. The kernel processes 4000 rows of 6000 RGBA8 pixels (24 MP) for zero, identical and varied rows; deflate is outside the timer. +Stop evaluating PNG filters when their partial cost cannot win. Filter choice and encoded bytes must remain identical. -Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max -(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that -base; none of the other performance splits is applied. Release uses optimization -level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. -Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal -priority with no concurrent builds. Timings are medians of five samples after one -warm-up, except the compositor's ten Criterion samples. These synthetic workloads -are reproducible checks of this change, not a representative image corpus. +Photo PNG encoding improves, with identical streams. The small designs show little change because they use the existing serial PNG encoder. The previous filter-kernel percentages are not end-to-end results. -| Workload | Main ms | This change ms | Time change | -|---|---:|---:|---:| -| zero | 448.138 | 5.734 | -98.7% | -| identical | 447.020 | 59.819 | -86.6% | -| varied | 450.469 | 277.254 | -38.5% | +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. -These are serial filter-kernel timings, not end-to-end PNG encoding timings. +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. -The committed oracle is the previous main-branch filter function. The unit test compares exact output bytes, including the selected filter/tie order, across six bytes-per-pixel layouts, 32 patterns and absent/identical/shifted previous rows. Existing encoder round trips and deterministic worker-count tests remain in place. +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. -Reproduce with the same release profile environment: +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-8 | png-encode | 358.725 | 346.020 | -3.5% | -7.0..-0.9% | 24,569,965 / 24,569,965 | +| panasonic-16 | png-encode | 338.106 | 296.860 | -12.2% | -12.3..-9.7% | 111,583,441 / 111,583,441 | +| canon-8 | png-encode | 178.675 | 169.289 | -5.3% | -35.0..-2.7% | 13,720,268 / 13,720,268 | +| canon-16 | png-encode | 162.107 | 145.356 | -10.3% | -12.2..+4.6% | 58,279,529 / 58,279,529 | +| layout-masks | png-encode | 4.037 | 3.986 | -1.3% | -3.3..+1.9% | 42,842 / 42,842 | +| layout-mask-index | png-encode | 11.343 | 11.404 | +0.5% | -0.0..+2.2% | 115,229 / 115,229 | -```sh -CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-codecs --lib --no-run -# Run the printed executable at normal priority: - benchmark_filter_selection --ignored --nocapture --test-threads=1 -``` +The real-image PNG streams match main byte for byte and decode to identical pixels. The committed previous-filter oracle also covers bytes-per-pixel layouts, early exits, zero-cost rows and ties. -## Local validation +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - `cargo test -p photocraft-codecs --locked`: passed (782 passed, 4 ignored). - `cargo clippy -p photocraft-codecs --locked --all-targets -- -D warnings`: passed. From 78a9982eec3508dbd7654b262aea6b9f28386b09 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:15:06 +0200 Subject: [PATCH 185/304] perf(raster): share contiguous region reads --- crates/raster/src/lib.rs | 34 ++++----------- docs/sparse-raster-reads-performance.md | 57 ++++++++++++++----------- 2 files changed, 39 insertions(+), 52 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 1ea317d955..2ebfde9c59 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -419,6 +419,12 @@ impl Surface { let w = r.width() as usize; out.clear(); out.resize(w * r.height() as usize * n, 0.0); + self.read_samples_into(r, out); + } + + fn read_samples_into(&self, r: Rect, out: &mut [f32]) { + let n = self.channels(); + let w = r.width() as usize; let dp = self.default_samples(); let dp = &dp[..n]; let zero = self.default_pixel.iter().all(|&b| b == 0); @@ -431,7 +437,7 @@ impl Surface { for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; - let dst = &mut out[o..o + span]; + let Some(dst) = out.get_mut(o..o + span) else { continue }; match tile { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; @@ -565,31 +571,7 @@ impl Surface { let n = self.channels(); let w = r.width() as usize; let mut out = vec![0.0f32; w * r.height() as usize * n]; - let dp = self.default_samples(); - let dp = &dp[..n]; - let zero = self.default_pixel.iter().all(|&b| b == 0); - for tc in r.tiles() { - let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); - if tile.is_none() && zero { - continue; - } - for y in tr.y0..tr.y1 { - for x in tr.x0..tr.x1 { - let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; - match tile { - Some(t) => { - let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; - for i in 0..n { - out[o + i] = read_sample(&t.data, self.format.sample, base + i); - } - } - None if n == 1 => out[o] = dp[0], - None => out[o..o + n].copy_from_slice(dp), - } - } - } - } + self.read_samples_into(r, &mut out); out } diff --git a/docs/sparse-raster-reads-performance.md b/docs/sparse-raster-reads-performance.md index 6a76244a99..1cc1a04502 100644 --- a/docs/sparse-raster-reads-performance.md +++ b/docs/sparse-raster-reads-performance.md @@ -1,38 +1,43 @@ # Sparse raster reads -Only default-pixel work changes: skip absent zero-default tiles and avoid temporary default-pixel allocations/copies. Read 6000×4000 single-channel pixels (24 MP) with one 9×9 painted patch, at three depths and two defaults. +Skip absent zero-default tiles and avoid temporary default-pixel allocations and copies. ZIP CRC acceleration is already on main. -Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max -(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that -base; none of the other performance splits is applied. Release uses optimization -level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. -Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal -priority with no concurrent builds. Timings are medians of five samples after one -warm-up, except the compositor's ten Criterion samples. These synthetic workloads -are reproducible checks of this change, not a representative image corpus. +A roughly 4% median dense 16-bit city-photo read slowdown repeated, with paired intervals crossing zero. The local fix shares the existing contiguous row loop between allocating and reusable-buffer reads, removes duplicated code, and preserves zero-filled allocation for allocating reads. Final measurements include all photo depths and authored designs; all outputs match bit for bit. -| Workload | Main ms | This change ms | Time change | -|---|---:|---:|---:| -| U8-0 | 48.484 | 0.430 | -99.1% | -| U8-0.25 | 48.934 | 4.466 | -90.9% | -| U16-0 | 48.517 | 0.441 | -99.1% | -| U16-0.25 | 48.475 | 4.461 | -90.8% | -| F32-0 | 49.066 | 0.479 | -99.0% | -| F32-0.25 | 48.511 | 4.503 | -90.7% | +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. -These are read_region timings including output allocation. ZIP CRC-32 acceleration is already on main and is not duplicated here. +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. -The committed oracle is the previous read_region implementation. Unit coverage compares exact float bits and checks read_region_into, RGBA/RGBA8 reads and individual samples across sparse tile boundaries, negative coordinates, signed zero, nonzero defaults and eight pixel formats. +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. -Reproduce with the same release profile environment: +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-16 | read (fixed) | 54.689 | 41.140 | -24.8% | -28.0..-23.8% | — | +| panasonic-16 | render (fixed) | 38.492 | 38.345 | -0.4% | -1.8..+2.0% | — | +| canon-16 | read (fixed) | 27.634 | 19.236 | -30.4% | -34.0..-23.3% | — | +| panasonic-8 | read (fixed) | 50.429 | 20.210 | -59.9% | -62.2..-56.7% | — | +| panasonic-8 | render (fixed) | 30.953 | 30.670 | -0.9% | -3.4..+1.5% | — | +| panasonic-32 | read (fixed) | 91.120 | 69.922 | -23.3% | -23.6..-20.7% | — | +| panasonic-32 | render (fixed) | 39.436 | 39.867 | +1.1% | -4.5..+3.7% | — | +| canon-8 | read (fixed) | 24.899 | 10.543 | -57.7% | -58.5..-54.9% | — | +| canon-8 | render (fixed) | 16.925 | 16.901 | -0.1% | -5.6..+5.5% | — | +| canon-16 | render (fixed) | 21.569 | 21.716 | +0.7% | -2.2..+2.4% | — | +| canon-32 | read (fixed) | 45.494 | 35.200 | -22.6% | -23.5..-21.5% | — | +| canon-32 | render (fixed) | 22.056 | 22.180 | +0.6% | -1.8..+6.9% | — | +| layout-masks | read (fixed) | 35.240 | 3.438 | -90.2% | -90.7..-89.9% | — | +| layout-masks | render (fixed) | 8.167 | 7.461 | -8.6% | -10.6..-8.3% | — | +| layout-mask-index | read (fixed) | 2.314 | 1.051 | -54.6% | -61.7..-50.0% | — | +| layout-mask-index | render (fixed) | 1.926 | 1.827 | -5.1% | -13.0..+0.6% | — | -```sh -CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-raster --lib --no-run -# Run the printed executable at normal priority: - benchmark_sparse_reads --ignored --nocapture --test-threads=1 -``` +All real read-region and render float bits match main. The committed previous-read-region oracle also covers sparse boundaries, negative coordinates, signed zero, defaults and eight pixel formats. -## Local validation +Current local checks: `cargo test -p photocraft-raster --locked --lib` (27 passed, one ignored manual benchmark); strict all-target Clippy; affected wasm32 check. All passed. The existing previous-read-path oracle covers the shared loop. + +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - `cargo test -p photocraft-raster --locked`: passed (27 passed, 1 ignored). - `cargo clippy -p photocraft-raster --locked --all-targets -- -D warnings`: passed. From b5b5776a3058b8c7c4920f9728476b07122c635e Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:15:06 +0200 Subject: [PATCH 186/304] perf(io): remove the PSD export inlining hint --- crates/io/src/pixels.rs | 1 - docs/psd-interleave-performance.md | 54 +++++++++++++++++------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index cca2a8dfd4..0251f83755 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -158,7 +158,6 @@ pub fn zero_sample(s: SampleType) -> Vec { } /// Quantizes a normalized value to big-endian bytes. -#[inline] pub fn encode_be(v: f32, s: SampleType, out: &mut Vec) { match s { SampleType::U8 => out.push((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8), diff --git a/docs/psd-interleave-performance.md b/docs/psd-interleave-performance.md index 99ae70824f..5958442881 100644 --- a/docs/psd-interleave-performance.md +++ b/docs/psd-interleave-performance.md @@ -1,35 +1,43 @@ # PSD pixel buffer allocation -Only PSD pixel shuffling and sample-codec inlining change. Interleave writes into the final buffer rather than allocating an intermediate buffer per band. The kernel processes 24 million pixels with four full planes at 8-bit, 16-bit and float depth. +Write PSD interleaved pixels directly into the final buffer. Direct RLE output is already on main. The unrelated export-side sample-encoder inlining hint is removed locally. -Measured on 2026-10-19 against `d95fdf3181c01ad41e95456091a394b105a9afb2` on an Apple M4 Max -(16 CPUs, 128 GiB), macOS arm64, rustc 1.99.1. Each branch starts directly at that -base; none of the other performance splits is applied. Release uses optimization -level 3, LTO off, 16 codegen units, debug info off and incremental compilation off. -Builds ran at nice 10 with APFS-cloned warm caches; measurements ran at normal -priority with no concurrent builds. Timings are medians of five samples after one -warm-up, except the compositor's ten Criterion samples. These synthetic workloads -are reproducible checks of this change, not a representative image corpus. +Whole-photo imports improve by about 4–7%, and the authored designs by about 20–26%. An added export-side inlining hint accompanied a separately confirmed 2.3% float PSD export slowdown (2440.168 → 2497.423 ms). Removing that one line leaves export source equal to main; follow-up float export is +0.4% and 16-bit export +0.9%, with both paired intervals crossing zero. These results supersede the much larger previous interleave-only percentages. -| Workload | Main ms | This change ms | Time change | -|---|---:|---:|---:| -| U8 | 20.493 | 5.097 | -75.1% | -| U16 | 31.827 | 6.312 | -80.2% | -| F32 | 41.915 | 11.305 | -73.0% | +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. -These are interleave-kernel timings, not whole PSD import/export timings. Direct RLE output is already on main and is not part of this PR. +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. -The committed equivalence test uses the literal previous main-branch interleave implementation. It compares raw bytes across U8/U16/F32, empty and partial bands, missing/truncated planes, fill pixels and inversion. Existing deinterleave reference and big-endian sample-codec round trips cover the companion paths. +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. -Reproduce with the same release profile environment: +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-32 | psd-export (fixed) | 2471.456 | 2481.877 | +0.4% | -0.1..+1.7% | 461,042,978 / 461,042,978 | +| panasonic-16 | psd-export (fixed) | 2649.246 | 2673.071 | +0.9% | -5.2..+1.6% | 274,409,683 / 274,409,683 | +| panasonic-8 | psd-import | 275.828 | 262.237 | -4.9% | -5.9..-4.1% | 98,823,859 / 98,823,859 | +| panasonic-8 | psd-export | 2937.854 | 2915.929 | -0.7% | -11.5..+5.6% | 98,823,859 / 98,823,859 | +| panasonic-16 | psd-import | 365.901 | 349.827 | -4.4% | -4.4..-2.6% | 274,409,683 / 274,409,683 | +| panasonic-32 | psd-import | 360.706 | 334.838 | -7.2% | -12.6..-4.4% | 461,042,978 / 461,042,978 | +| canon-8 | psd-import | 141.331 | 135.878 | -3.9% | -5.1..-3.6% | 56,209,597 / 56,209,597 | +| canon-8 | psd-export | 1052.421 | 1054.472 | +0.2% | -0.6..+1.3% | 56,209,597 / 56,209,597 | +| canon-16 | psd-import | 177.327 | 169.952 | -4.2% | -5.5..-1.7% | 140,673,004 / 140,673,004 | +| canon-16 | psd-export | 1248.449 | 1239.159 | -0.7% | -1.6..+0.6% | 140,673,004 / 140,673,004 | +| canon-32 | psd-import | 175.721 | 163.271 | -7.1% | -9.3..-4.9% | 228,234,238 / 228,234,238 | +| canon-32 | psd-export | 1008.772 | 1005.356 | -0.3% | -1.8..+0.7% | 228,234,238 / 228,234,238 | +| layout-masks | psd-import | 12.509 | 9.981 | -20.2% | -20.8..-14.5% | 493,006 / 493,006 | +| layout-masks | psd-export | 55.113 | 54.714 | -0.7% | -2.3..+1.2% | 493,006 / 493,006 | +| layout-mask-index | psd-import | 3.299 | 2.443 | -25.9% | -30.8..-23.4% | 411,435 / 411,435 | +| layout-mask-index | psd-export | 18.200 | 18.397 | +1.1% | -0.1..+1.7% | 411,435 / 411,435 | -```sh -CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_INCREMENTAL=0 nice -n 10 cargo test --release -p photocraft-io --lib --no-run -# Run the printed executable at normal priority: - benchmark_interleave --ignored --nocapture --test-threads=1 -``` +Real PSD exports remain byte-identical and imported layer/mask float samples match main. The committed literal previous-interleave oracle covers depths, bands, fills, truncation and inversion. -## Local validation +Current local checks: affected `pixels::` unit tests (five passed, one ignored manual benchmark); strict all-target IO Clippy; affected IO wasm32 check. All passed. Follow-up exports at float and 16-bit depth remain byte-identical to main; no synthetic timing test was run. + +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. - `cargo test -p photocraft-io --locked`: passed (617 passed, 6 ignored). - `cargo clippy -p photocraft-io --locked --all-targets -- -D warnings`: passed. From c8907e2cd2809a328f2c331191f11e90e9857fab Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 05:23:03 -0600 Subject: [PATCH 187/304] Paragraph panel: grey the justify buttons on point text (#7713) Justification needs a paragraph box: Photoshop greys the four justify buttons for point text, which lays out left whatever its alignment. PhotoCraft offered them, and choosing one did nothing visible. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 34 +++++++++++++++++++++------ crates/ui-egui/src/type_tool_tests.rs | 30 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d07b8bb161..bb4780d5e3 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2057,9 +2057,20 @@ fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnc if on { ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside); } - paint(ui, r, if on { t.text } else { t.text_dim }); + let enabled = ui.is_enabled(); + paint( + ui, + r, + if !enabled { + t.text_faint + } else if on { + t.text + } else { + t.text_dim + }, + ); let tip = tip.to_string(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, on, &tip)); resp.on_hover_text(tl!(&tip)).clicked() } @@ -2352,14 +2363,23 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa ("justifyAll", tl!("Justify all"), A::JustifyAll), ]; let cw = cell_width(full, items.len()); + // Justification needs a paragraph box: on point text Photoshop greys the four justify + // buttons (#7713). + let point = target(app).is_some_and(|(layer, _)| box_shape(app, LayerId(layer)).is_none()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; for (key, tip, a) in items { - let clicked = toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| { - // The glyph keeps its size; the cell grows around it. - let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0)); - align_glyph(ui.painter(), g, a, if para.align == a { col } else { t.icon }); - }); + let justify = !matches!(a, A::Left | A::Center | A::Right); + let clicked = ui + .add_enabled_ui(!(point && justify), |ui| { + toggle_cell(ui, cw, para.align == a, tip, |ui, r, col| { + // The glyph keeps its size; the cell grows around it. + let g = egui::Rect::from_center_size(r.center(), egui::vec2(12.0, 10.0)); + let col = if !ui.is_enabled() || para.align == a { col } else { t.icon }; + align_glyph(ui.painter(), g, a, col); + }) + }) + .inner; if clicked { apply(app, ui.ctx(), json!({"align": key})); } diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 20e59a01d6..368a725acb 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -2130,3 +2130,33 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); } + +/// #7713: justification needs a paragraph box. On point text the Paragraph panel's four justify +/// buttons are greyed, as in Photoshop; on paragraph text they work. +#[test] +fn justify_buttons_are_greyed_on_point_text() { + use egui_kittest::kittest::{NodeT, Queryable}; + let justify = ["Justify last left", "Justify last centered", "Justify last right", "Justify all"]; + for (paragraph, enabled) in [(false, false), (true, true)] { + let mut app = new_app(); + let mut p = json!({"text": "one two three four", "size": 24, "x": 300, "y": 420}); + if paragraph { + p["box"] = json!([300, 300, 240, 180]); + } + app.run("type.create", p).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::type_tool::character_panel(app, ui, true); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for tip in justify { + assert_eq!(!h.get_by_label(tip).accesskit_node().is_disabled(), enabled, "{tip} on {}", if paragraph { "paragraph text" } else { "point text" }); + } + assert!(!h.get_by_label("Center text").accesskit_node().is_disabled(), "alignment itself stays available"); + } +} From dd552f76b1fbd918c2a56a80300f874d50f0594b Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:33:01 +0200 Subject: [PATCH 188/304] docs(raster): document touched read helpers --- crates/raster/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 2ebfde9c59..694cdb5751 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -239,6 +239,7 @@ impl Surface { decode_pixel(&self.format, &self.default_pixel) } + /// Decode the default pixel into fixed storage without allocating. fn default_samples(&self) -> [f32; 8] { let mut px = [0.0; 8]; for (i, v) in px.iter_mut().enumerate().take(self.channels()) { @@ -422,6 +423,7 @@ impl Surface { self.read_samples_into(r, out); } + /// Copy samples into a zero-initialized buffer sized for `r`. fn read_samples_into(&self, r: Rect, out: &mut [f32]) { let n = self.channels(); let w = r.width() as usize; @@ -1105,6 +1107,7 @@ pub fn is_hdr(sample: SampleType) -> bool { #[cfg(test)] mod tests { + /// Manually compare allocating reads with the previous implementation. #[test] #[ignore = "manual 24 MP release benchmark"] fn benchmark_sparse_reads() { @@ -1131,6 +1134,7 @@ mod tests { } } } + /// Previous allocating read path, retained as the bitwise-equivalence oracle. fn previous_read_region(surface: &Surface, r: Rect) -> Vec { let n = surface.channels(); let w = r.width() as usize; @@ -1289,6 +1293,7 @@ mod tests { } } + /// Compare sparse reads and converted accessors with the previous allocating path. #[test] fn zero_alloc_accessors_match_read_region() { for (fmt, default) in [ From b862323a8e1a7aebebd736bd9420e91d784e4ecd Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 05:39:46 -0600 Subject: [PATCH 189/304] Justify buttons: grey when any formatted type layer is point text Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 11 ++++++++++- crates/ui-egui/src/type_tool_tests.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index bb4780d5e3..441048c0a9 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2365,7 +2365,16 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa let cw = cell_width(full, items.len()); // Justification needs a paragraph box: on point text Photoshop greys the four justify // buttons (#7713). - let point = target(app).is_some_and(|(layer, _)| box_shape(app, LayerId(layer)).is_none()); + // The panel formats the layer being typed in, else every selected type layer. + let point = match formatting_params(app) { + Some(p) => p.get("layers").and_then(serde_json::Value::as_array).map_or_else( + || p.get("layer").and_then(serde_json::Value::as_u64).into_iter().collect::>(), + |ids| ids.iter().filter_map(serde_json::Value::as_u64).collect(), + ), + None => Vec::new(), + } + .into_iter() + .any(|layer| box_shape(app, LayerId(layer)).is_none()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; for (key, tip, a) in items { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 368a725acb..168409760f 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -2159,4 +2159,23 @@ fn justify_buttons_are_greyed_on_point_text() { } assert!(!h.get_by_label("Center text").accesskit_node().is_disabled(), "alignment itself stays available"); } + // A selection of a paragraph and a point type layer formats both: justify is greyed. + let mut app = new_app(); + let point = app.run("type.create", json!({"text": "point", "size": 24, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap(); + let boxed = + app.run("type.create", json!({"text": "boxed text", "size": 24, "x": 300, "y": 300, "box": [300, 300, 240, 180]})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": point})).unwrap(); + app.run("layer.select", json!({"layer": boxed, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + let mut h = Harness::builder().with_size(vec2(320.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::type_tool::character_panel(app, ui, true); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + assert!(h.get_by_label("Justify all").accesskit_node().is_disabled(), "one of the formatted layers is point text"); } From 4d50e31efd32576b3b65b0a47b84e32b1db76d37 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:43:19 +0200 Subject: [PATCH 190/304] perf(codecs): stop PNG candidates at the winning cost --- crates/codecs/src/codecs/png.rs | 6 ++++ docs/png-filter-selection-performance.md | 39 +++++++++++++++--------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 34ae84acc0..946f8949e8 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -523,6 +523,9 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; c += u64::from((scratch[i] as i8).unsigned_abs()); + if c >= best { + break; + } } if c < best { best = c; @@ -751,6 +754,9 @@ mod parallel_tests { let mut out = Vec::new(); filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); assert_eq!(out, vec![0; 17]); + out.clear(); + filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out); + assert_eq!(out, [vec![1, 1], vec![0; 15]].concat()); } #[test] diff --git a/docs/png-filter-selection-performance.md b/docs/png-filter-selection-performance.md index 1f7ffeab29..6abbaaa846 100644 --- a/docs/png-filter-selection-performance.md +++ b/docs/png-filter-selection-performance.md @@ -1,27 +1,36 @@ # PNG filter selection -Stop evaluating PNG filters when their partial cost cannot win. Filter choice and encoded bytes must remain identical. +The final inner loop stops as soon as candidate cost reaches `best`. Costs cannot decrease, and only a strict improvement replaces the winning filter, preserving tie order. The previous-filter oracle now also checks an immediate tie against Sub. -Photo PNG encoding improves, with identical streams. The small designs show little change because they use the existing serial PNG encoder. The previous filter-kernel percentages are not end-to-end results. +Measured on 2026-10-19 against frozen main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently for each branch. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1. Release O3, LTO off, 16 codegen units, debug off; builds/tests at nice 10 with warm caches. Timed operations run at normal priority without concurrent builds or profiling. Persistent processes alternate execution order after a warmup. Time change compares the two medians; paired bootstrap intervals from these small samples describe variation, not guarantees. -Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. +CC0 Pixls P1000475.RW2 supplies a 6000 x 4000 centre crop (24 MP, no resampling); IMG_4059.CR2 supplies the native 4000 x 3000 city photograph. U8/U16/F32 documents retain ProPhoto RGB; F32 derives from 16-bit development and is not native HDR. No generated noise, repeated ramps, enlarged charts or duplicated photo layers are timed. Source pins and attribution remain in the existing corpus. -The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. +Nine alternating samples compare main, the previous split and the final cutoff implementation. The four photo cases use the parallel encoder; the two MIT psd-tools designs use the unchanged serial encoder. The illustrated card has a separate nine-pair confirmation, shown in the table. Input conversion, hashes and decoded-pixel checks are outside timers. Every PNG is byte-identical to main and decodes to identical pixels. -The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. +| Document | Operation | Main ms | Branch ms | Change | Paired 95% interval | +|---|---|---:|---:|---:|---:| +| panasonic-8 | png-encode | 358.588 | 337.725 | -5.8% | -6.6..-5.2% | +| panasonic-16 | png-encode | 334.253 | 301.130 | -9.9% | -10.8..-9.3% | +| canon-8 | png-encode | 179.462 | 168.651 | -6.0% | -8.4..-4.0% | +| canon-16 | png-encode | 168.310 | 150.325 | -10.7% | -17.5..-9.4% | +| layout-masks | png-encode | 4.035 | 4.020 | -0.4% | -1.4..+0.8% | +| layout-mask-index | png-encode (confirm) | 10.808 | 10.674 | -1.2% | -2.5..+0.7% | -| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | -|---|---|---:|---:|---:|---:|---:| -| panasonic-8 | png-encode | 358.725 | 346.020 | -3.5% | -7.0..-0.9% | 24,569,965 / 24,569,965 | -| panasonic-16 | png-encode | 338.106 | 296.860 | -12.2% | -12.3..-9.7% | 111,583,441 / 111,583,441 | -| canon-8 | png-encode | 178.675 | 169.289 | -5.3% | -35.0..-2.7% | 13,720,268 / 13,720,268 | -| canon-16 | png-encode | 162.107 | 145.356 | -10.3% | -12.2..+4.6% | 58,279,529 / 58,279,529 | -| layout-masks | png-encode | 4.037 | 3.986 | -1.3% | -3.3..+1.9% | 42,842 / 42,842 | -| layout-mask-index | png-encode | 11.343 | 11.404 | +0.5% | -0.0..+2.2% | 115,229 / 115,229 | +| Document | Previous split ms | Final ms | Cutoff-only change | +|---|---:|---:|---:| +| panasonic-8 | 339.785 | 337.725 | -0.6% | +| panasonic-16 | 300.287 | 301.130 | +0.3% | +| canon-8 | 172.739 | 168.651 | -2.4% | +| canon-16 | 150.183 | 150.325 | +0.1% | +| layout-masks | 4.019 | 4.020 | +0.0% | +| layout-mask-index | 10.674 | 10.674 | -0.0% | -The real-image PNG streams match main byte for byte and decode to identical pixels. The committed previous-filter oracle also covers bytes-per-pixel layouts, early exits, zero-cost rows and ties. +The cutoff-only photo intervals cross zero: these complete-image measurements do not establish an additional speedup over the previous split. The serial card initially measured +2.1% against the previous split; its independent confirmation is neutral (-0.0%, interval -1.1..+1.2%). The updated table reports the final implementation against main without relying on the earlier kernel-only promise. -Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. +Current affected checks: seven PNG unit tests passed, one ignored manual benchmark; strict all-target codec Clippy, affected codec WASM check and formatting passed. No synthetic timing test was run. + +Harness, raw observations, exact inputs and binary hashes: `.local/perf-followup-261019/` (ignored local evidence). Previous flat-photo and authored-design results remain in `.local/perf-real-261019/`. Nothing was pushed and no PR was changed. ## Earlier split validation From 198fa59c7d46dc6bef2c3b2544ab62a1b9aed92f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:52:12 +0200 Subject: [PATCH 191/304] fix(psd): a new type layer gets a full Txt2 object and its own frame (#7698) A type layer the file's Txt2 has no object for (one added in PhotoCraft) was written as a bare stub with no view, no paragraph runs and no frame in /0 /8 /0. Photopea and Photoshop refused the file once the objects outnumbered the frames. build_txt2 now gives such a layer an object shaped like one of the file's own (refitted with edited_object from #7220), with its view naming its own frame, and pads the frame list to one per object. A stub written by an earlier save is replaced the same way, so re-saving an affected file repairs it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 77 ++++++++++++++++++++++++++++++++++++++-- crates/text/src/tests.rs | 60 +++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 41d65ff887..5ffbebd94d 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -975,6 +975,11 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec let prev = previous.and_then(parse_txt2); let prev_objects: &[E] = prev.as_ref().and_then(|p| p.path(&["1", "1"]).and_then(E::as_array)).unwrap_or(&[]); let mut slots: Vec = prev_objects.to_vec(); + // Looked up once per save, not per layer: a crafted block with many objects must not cost + // a pass over all of them for every type layer. + let all_stubs = prev_objects.iter().all(is_stub); + // A template is copied once per new layer, so a large one is skipped (the stub is written). + let template = prev_objects.iter().rev().find(|o| names_one_frame(o) && fits(o, &mut { TEMPLATE_NODES }) && edited_object(o, "\r").is_some()); for (index, layer) in objects { if !(0..=MAX_TEXT_INDEX).contains(index) { continue; @@ -988,8 +993,15 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in // Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file. let edited = kept.is_none().then(|| prev_objects.get(at).and_then(|o| edited_object(o, &text))).flatten(); - let reuse = kept.is_some() || edited.is_some(); - let mut object = kept.cloned().or(edited).unwrap_or_else(E::dict); + // A new object (#7698): a layer the file's block has no object for (a type layer added + // here) takes the shape of one of the file's own objects, refitted the same way, with its + // view naming its own frame. A bare stub beside Photoshop's objects, its slot without a + // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. A stub an earlier save + // wrote is replaced the same way, so re-saving such a file repairs it. + let kept = kept.filter(|o| !is_stub(o) || all_stubs); + let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten(); + let reuse = kept.is_some() || edited.is_some() || fresh.is_some(); + let mut object = kept.cloned().or(edited).or(fresh).unwrap_or_else(E::dict); let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict); let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes)); let runs = file_runs.unwrap_or_else(|| { @@ -1019,6 +1031,7 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec // The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and // `/1` whatever it holds beside the text objects. let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())])); + pad_frames(&mut outer, slots.len()); match outer.get_mut("1") { Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)), _ => outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])), @@ -1036,6 +1049,66 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec } } +/// The most values a `Txt2` object may hold to serve as the template for new objects. Photoshop's +/// own objects hold a few hundred (611 at most in the file from #7698). +const TEMPLATE_NODES: usize = 4096; + +/// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over). +fn fits(v: &E, budget: &mut usize) -> bool { + let Some(left) = budget.checked_sub(1) else { return false }; + *budget = left; + match v { + E::Dict(items) => items.iter().all(|(_, x)| fits(x, budget)), + E::Array(items) => items.iter().all(|x| fits(x, budget)), + _ => true, + } +} + +/// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as +/// Photoshop writes it. +fn names_one_frame(object: &E) -> bool { + object.path(&["1", "0"]).and_then(E::as_array).is_some_and(|f| f.len() == 1 && f.first().and_then(|x| x.get("0")).is_some()) +} + +/// A text object for the slot `at`, which the file's block has none for: the file's object +/// `template` refitted to `text` by [`edited_object`], its view now naming frame `at` and its view +/// runs (`/1 /1 /0`, one per paragraph) remade for the new paragraphs. `None` when the template +/// can't be refitted. +fn new_object(template: &E, at: usize, text: &str) -> Option { + let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); + let mut object = edited_object(template, text)?; + let view = object.get_mut("1")?; + view.set("0", E::Array(vec![E::Dict(vec![("0".into(), E::Int(i64::try_from(at).ok()?))])])); + if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) { + let fitted = runs_per(runs, ¶graphs)?; + view.get_mut("1")?.set("0", E::Array(fitted)); + } + Some(object) +} + +/// Whether `object` is the bare `<< /0 << /0 text /6 runs >> >>` object [`build_txt2`] writes +/// when it has nothing to take a shape from: no view, no paragraph runs. +fn is_stub(object: &E) -> bool { + let only = |v: &E, keys: &[&str]| matches!(v, E::Dict(items) if items.iter().all(|(k, _)| keys.contains(&k.as_str()))); + only(object, &["0"]) && object.get("0").is_some_and(|m| only(m, &["0", "6"])) +} + +/// Gives the block `outer` a frame (`/0 /8 /0`) for each of its `count` text objects: Photoshop +/// writes one per object, and a reader indexes them by object number. Missing frames are the +/// small point-text frame Photoshop writes (`<< /0 << /2 << /6 [ -1.0 -1.0 ] /11 << /4 -1 /7 +/// false >> >> >> >>`), never copies of a file's frame: the slot count reaches +/// [`MAX_TEXT_INDEX`], and a large frame copied that often would blow up the save. A block +/// without a frame list is left as it is. +fn pad_frames(outer: &mut E, count: usize) { + let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) else { return }; + if frames.len() < count { + let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]); + let data = E::Dict(vec![("6".into(), E::Array(vec![E::Real(-1.0), E::Real(-1.0)])), ("11".into(), kind)]); + let frame = E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]); + frames.resize(count, frame); + } +} + /// The file's `Txt2` text object `object` (`<< /0 model /1 view … >>`) for the changed engine /// text `text` (`\r` breaks, trailing `\r`): the model's text replaced, its paragraph runs (`/5`) /// one per paragraph of the new text (each keeping the style of the file's paragraph at that diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d2aaa78a45..4f6aad9c86 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1147,6 +1147,66 @@ fn edited_text_keeps_the_files_txt2_object() { } } +/// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an +/// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`, +/// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out. +#[test] +fn new_text_layer_gets_a_txt2_object_and_frame() { + use crate::engine_data::Value as E; + let run = |len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 >> >> >> /1 {len} >> "); + let prev = format!( + "\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}] >> >> /1 << /0 [ << /0 0 >> ] /1 << /0 [ << /0 << /1 1 >> /1 3 >> << /0 << /1 1 >> /1 3 >> ] >> /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>", + run(3), + run(3), + run(6) + ); + // The same block after an earlier save wrote a bare stub for "v1.0" (the file in #7698). + let stub = "<< /0 << /0 (\u{fe}\u{ff}\0v\x001\0.\x000\0\r) /6 << /0 [ << /0 << /0 << /0 () /6 << /0 0 /11 1 >> >> >> /1 5 >> ] >> >> >> "; + let broken = prev.replace(" >> ] /2 << /0 7 >>", &format!(" >> {stub}] /2 << /0 7 >>")); + let broken: Vec = broken.chars().map(|c| c as u32 as u8).collect(); + let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); + let before = crate::psd::parse_txt2(&prev).unwrap(); + let old = runs_of("AB\nCD", &[(5, CharStyle::default())]); + let new = runs_of("v1.0", &[(4, CharStyle::default())]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&prev))).unwrap(); + let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap(); + assert_eq!(objects.len(), 2); + let kept = &before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; + assert_eq!(objects[0].get("1"), kept.get("1"), "the file's object keeps its view"); + assert_eq!(objects[0].path(&["0", "5"]), kept.path(&["0", "5"]), "the file's object keeps its paragraphs"); + let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap(); + assert_eq!(frames.len(), 2, "one frame per object"); + let object = &objects[1]; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v1.0\r")); + assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "the view names its own frame"); + assert!(object.get("21").is_none()); + let lens = + |path: &[&str]| -> Vec { object.path(path).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() }; + assert_eq!(lens(&["0", "5", "0"]), vec![5]); + assert_eq!(lens(&["0", "6", "0"]), vec![5]); + assert_eq!(lens(&["1", "1", "0"]), vec![5], "view runs follow the new paragraphs"); + + // Re-saving the file with the stub repairs it: the stub is replaced like a missing object. + assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); + let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap(); + assert_eq!(repaired, out, "a stub from an earlier save is replaced"); + + // No object to take the shape from: the fresh object as before, with the frames still padded. + let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> "; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(1, &new)], Some(bare))).unwrap(); + assert_eq!(out.path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); + assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2); + + // An oversized object is never the template (it would be copied once per new layer). + let filler = "0 ".repeat(5000); + let big = prev.iter().map(|&b| b as char).collect::().replace("/2 [ << /99 /PC >> ]", &format!("/2 [ << /99 /PC >> ] /9 [ {filler}]")); + let big: Vec = big.chars().map(|c| c as u32 as u8).collect(); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&big))).unwrap(); + let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[1]; + assert!(object.get("1").is_none(), "no copy of an oversized template"); + assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2); +} + /// The file's style runs are only reused when they cover the text: runs of another length (or /// hostile lengths) fall back to generated runs, without panicking. #[test] From 604007490bfba2f0df84b8e0b87f8b6648229214 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:33:05 +0200 Subject: [PATCH 192/304] fix(psd): replace an earlier save's Txt2 stub also when its text is edited The stub filter applied only to the kept object, so an edited layer whose slot held a stub from an earlier save went through edited_object and kept the bare stub. The slot is now filtered before both lookups. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 12 +++++++----- crates/text/src/tests.rs | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 5ffbebd94d..2caddb0c6e 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -987,18 +987,20 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec let text = format!("{}\r", layer.text.replace('\n', "\r")); let modes = txt2_mode_runs(layer); let at = *index as usize; - let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); + // A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so + // it is replaced like a missing one (below) and re-saving such a file repairs it, whether + // or not the layer's text changed. + let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs); + let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); // An edited object (#6968): the file's object at this slot with its text changed. It keeps // the file's structure — paragraph runs, the view naming its frame, the rest — with every // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in // Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file. - let edited = kept.is_none().then(|| prev_objects.get(at).and_then(|o| edited_object(o, &text))).flatten(); + let edited = kept.is_none().then(|| slot.and_then(|o| edited_object(o, &text))).flatten(); // A new object (#7698): a layer the file's block has no object for (a type layer added // here) takes the shape of one of the file's own objects, refitted the same way, with its // view naming its own frame. A bare stub beside Photoshop's objects, its slot without a - // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. A stub an earlier save - // wrote is replaced the same way, so re-saving such a file repairs it. - let kept = kept.filter(|o| !is_stub(o) || all_stubs); + // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten(); let reuse = kept.is_some() || edited.is_some() || fresh.is_some(); let mut object = kept.cloned().or(edited).or(fresh).unwrap_or_else(E::dict); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4f6aad9c86..1015107843 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1190,6 +1190,12 @@ fn new_text_layer_gets_a_txt2_object_and_frame() { assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap(); assert_eq!(repaired, out, "a stub from an earlier save is replaced"); + // Also when the stub's layer is edited before the re-save. + let edited = runs_of("v2.0", &[(4, CharStyle::default())]); + let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &edited)], Some(&broken))).unwrap(); + let object = &repaired.path(&["1", "1"]).and_then(E::as_array).unwrap()[1]; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v2.0\r")); + assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "an edited stub is replaced too"); // No object to take the shape from: the fresh object as before, with the frames still padded. let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> "; From dfeba9e97497e0e2debc28f8006b0f30ed13e33d Mon Sep 17 00:00:00 2001 From: Akos Arinton Date: Mon, 19 Oct 2026 18:11:13 +0300 Subject: [PATCH 193/304] test(compose): compare NaN as NaN in the vector span test, not by its bits --- crates/compose/src/psblend.rs | 5 +++-- crates/compose/src/tests.rs | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index b5eeaa79d0..4c0556be22 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -238,7 +238,7 @@ pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity normal_rgb(backdrop, source, as_, ao) } -/// [`composite_normal_rgb`] over a row, bit for bit, in blocks the compiler vectorizes (see +/// [`composite_normal_rgb`] over a row, with the same bits, in blocks the compiler vectorizes (see /// [`normal_lanes`]); the tail that doesn't fill a block goes through the scalar kernel. pub(crate) fn composite_normal_rgb_span(dst: &mut [[f32; 4]], src: &[[f32; 4]], opacity: f32) { let n = dst.len().min(src.len()); @@ -262,7 +262,8 @@ const LANES: usize = 8; /// [`composite_normal_rgb`] (after the caller's `source alpha > 0` test) on one block of pixels, /// without branches: one array per channel, fixed-length loops the compiler vectorizes, and a /// select per pixel between the backdrop, zero and the mix. Same operations in the same order -/// (Rust never fuses them into multiply-adds) and the same tests, NaN included: bit-identical. +/// (Rust never fuses them into multiply-adds) and the same tests: the same bits, except that a +/// NaN's sign and payload may differ (IEEE 754 doesn't fix which operand's NaN is passed on). #[inline] #[allow(clippy::needless_range_loop)] // lanes index several arrays in lockstep fn normal_lanes(b: &mut [[f32; 4]; LANES], s: &[[f32; 4]; LANES], opacity: f32) { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 4cc6e7925e..5043657c14 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -82,7 +82,7 @@ fn normal_rgb_kernel_preserves_scalar_bits() { /// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover /// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals -/// and values outside 0..1. +/// and values outside 0..1: the same bits, except that a NaN only has to stay NaN. #[test] fn normal_rgb_span_preserves_scalar_bits() { let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30]; @@ -111,8 +111,11 @@ fn normal_rgb_span_preserves_scalar_bits() { } let mut got = backdrop; psblend::composite_normal_rgb_span(&mut got, &source, opacity); + // IEEE 754 leaves open which operand's NaN an operation passes on (x86 and the + // vector units differ in its sign), so any NaN counts as the same NaN. + let bits = |p: &[f32; 4]| p.map(|v| if v.is_nan() { u32::MAX } else { v.to_bits() }); for (i, (g, w)) in got.iter().zip(&want).enumerate() { - assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}"); + assert_eq!(bits(g), bits(w), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}"); } } } From 83aff80f35b06dd4a847ffcfce7d6d50bb724eef Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:19:18 +0200 Subject: [PATCH 194/304] fix(raster): support sample counts beyond stack capacity --- crates/raster/src/lib.rs | 61 ++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 694cdb5751..2e3dc53811 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -117,6 +117,16 @@ pub struct Surface { tiles: BTreeMap>, } +/// Use stack storage when it fits, otherwise reuse a dynamically sized sample buffer. +fn sample_buffer<'a>(channels: usize, stack: &'a mut [f32], heap: &'a mut Vec) -> &'a mut [f32] { + if let Some(samples) = stack.get_mut(..channels) { + samples + } else { + heap.resize(channels, 0.0); + heap + } +} + /// Tiles of one pixel value met while moving a surface ([`Surface::translated`]): each scanned /// once, and one tile kept per value so the targets share it. #[derive(Default)] @@ -239,10 +249,10 @@ impl Surface { decode_pixel(&self.format, &self.default_pixel) } - /// Decode the default pixel into fixed storage without allocating. - fn default_samples(&self) -> [f32; 8] { - let mut px = [0.0; 8]; - for (i, v) in px.iter_mut().enumerate().take(self.channels()) { + /// Decode the default pixel, using dynamic storage only when the stack buffer is too small. + fn default_samples<'a>(&self, stack: &'a mut [f32], heap: &'a mut Vec) -> &'a mut [f32] { + let px = sample_buffer(self.channels(), stack, heap); + for (i, v) in px.iter_mut().enumerate() { *v = read_sample(&self.default_pixel, self.format.sample, i); } px @@ -427,8 +437,9 @@ impl Surface { fn read_samples_into(&self, r: Rect, out: &mut [f32]) { let n = self.channels(); let w = r.width() as usize; - let dp = self.default_samples(); - let dp = &dp[..n]; + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let dp = self.default_samples(&mut stack, &mut heap); let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); @@ -467,7 +478,10 @@ impl Surface { debug_assert_eq!(out.len(), w * r.height() as usize); let fmt = self.format; let n = fmt.channels(); - let dp = to_rgba(&fmt, &self.default_samples()); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let px = self.default_samples(&mut stack, &mut heap); + let dp = to_rgba(&fmt, px); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); @@ -511,12 +525,11 @@ impl Surface { } } _ => { - let mut px = [0.0f32; 8]; for (i, d) in dst.iter_mut().enumerate() { - for (c, v) in px.iter_mut().enumerate().take(n) { + for (c, v) in px.iter_mut().enumerate() { *v = read_sample(&t.data, fmt.sample, base + i * n + c); } - *d = to_rgba(&fmt, &px[..n]); + *d = to_rgba(&fmt, px); } } } @@ -532,7 +545,9 @@ impl Surface { let fmt = self.format; let rgba8 = matches!((fmt.mode, fmt.sample, fmt.alpha), (ColorMode::Rgb, SampleType::U8, true)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - let dp = to_rgba(&fmt, &self.default_samples()); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; let mut row = Vec::new(); for tc in r.tiles() { @@ -1293,6 +1308,24 @@ mod tests { } } + /// Sample counts above the stack capacity use writable storage without truncating channels. + #[test] + fn sample_buffer_keeps_stack_fast_path_and_supports_dynamic_channels() { + for channels in [1, 8, 9, 32] { + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let samples = sample_buffer(channels, &mut stack, &mut heap); + assert_eq!(samples.len(), channels); + samples.fill(0.25); + if channels <= stack.len() { + assert!(heap.is_empty()); + assert_eq!(&stack[..channels], vec![0.25; channels]); + } else { + assert_eq!(heap, vec![0.25; channels]); + } + } + } + /// Compare sparse reads and converted accessors with the previous allocating path. #[test] fn zero_alloc_accessors_match_read_region() { @@ -1310,6 +1343,12 @@ mod tests { .flat_map(|fmt| [0.0, 0.25, -0.0].map(|default| (fmt, default))) { let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + for capacity in [0, stack.len()] { + let decoded = s.default_samples(&mut stack[..capacity], &mut heap); + assert!(decoded.iter().map(|v| v.to_bits()).eq(s.default_pixel().into_iter().map(f32::to_bits))); + } let r = Rect::new(-300, -20, 300, 40); for (i, (x, y)) in [(-299, -19), (0, 0), (255, 39), (256, 10), (299, 0)].into_iter().enumerate() { let px: Vec = (0..fmt.channels()).map(|c| ((i * 3 + c) % 5) as f32 / 4.0).collect(); From 716d1e24c977ac23120654cc283a127c3d2bd9df Mon Sep 17 00:00:00 2001 From: Elvis33LE Date: Mon, 19 Oct 2026 17:24:19 +0200 Subject: [PATCH 195/304] fix(io): keep variable noise GdFl fills as fill layers constant_rgb_noise_color's None propagated out of fill_from_desc for every noise gradient that is not provably constant - Photoshop's default Noise gradient included - so the layer imported as pixels and the export dropped its GdFl (RASTER_STALE_KEYS): an open-and-save data loss. The exact colour now applies only when provable; everything else keeps the placeholder ramp, the fill_cache pixels and the raw GdFl block, which the re-save writes back byte for byte. Round-trip test included (fails on the old head: the layer came back as pixels without a GdFl). --- crates/io/src/blocks.rs | 27 ++++++++--- crates/io/tests/psd_noise_fill_roundtrip.rs | 54 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 crates/io/tests/psd_noise_fill_roundtrip.rs diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index f356c13204..4cad23ce44 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -446,12 +446,17 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { opacity_stops.clear(); } if stops.is_empty() { - if let Some(grad) = gradient.filter(|g| enum_of(g, "GrdF") == Some(b"ClNs")) { - let color = constant_rgb_noise_color(grad)?; - stops = vec![(0.0, color), (1.0, color)]; - } else { - stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; - } + // A provably constant-RGB noise gradient renders one exact colour. Any other + // noise gradient (variable ranges, transparency, other models) has no stops to + // read: keep the placeholder ramp rather than failing the whole fill — the + // layer stays a fill layer, Photoshop's own pixels ride along as `fill_cache`, + // and the raw `GdFl` block survives the round trip byte for byte. Failing + // here would import the layer as pixels and drop `GdFl` on save (#6272). + stops = gradient + .filter(|g| enum_of(g, "GrdF") == Some(b"ClNs")) + .and_then(constant_rgb_noise_color) + .map(|color| vec![(0.0, color), (1.0, color)]) + .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); } let pct = |o: &Descriptor, k: &str| num(o.get(k)).map_or(0.0, |v| v as f32 / 100.0); let offset = get_desc(d, "Ofst").map_or((0.0, 0.0), |o| (pct(o, "Hrzn"), pct(o, "Vrtc"))); @@ -811,9 +816,15 @@ mod tests { } #[test] - fn variable_or_transparent_noise_fill_is_not_fabricated_as_a_black_to_white_ramp() { + fn variable_or_transparent_noise_stays_a_fill_with_the_placeholder_ramp() { + use photocraft_doc::Fill; + // Photoshop's default Noise gradient (min 0, max 100) and a transparent variant: no + // exact colour is provable, but the fill must parse — the layer keeps its type, its + // stored pixels (`fill_cache`) and its raw `GdFl` block. Only the uncached preview + // shows the placeholder ramp. for descriptor in [noise_gradient_fill([0, 0, 0, 0], [100, 100, 100, 100], false), noise_gradient_fill([50, 25, 0, 0], [50, 25, 0, 100], true)] { - assert!(fill_from_desc(b"GdFl", &descriptor).is_none()); + let Some(Fill::Gradient { stops, .. }) = fill_from_desc(b"GdFl", &descriptor) else { panic!("variable noise gradient must stay a fill layer") }; + assert_eq!(stops, vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); } } diff --git a/crates/io/tests/psd_noise_fill_roundtrip.rs b/crates/io/tests/psd_noise_fill_roundtrip.rs new file mode 100644 index 0000000000..e8fb38b88d --- /dev/null +++ b/crates/io/tests/psd_noise_fill_roundtrip.rs @@ -0,0 +1,54 @@ +//! #6272: a Photoshop noise `GdFl` gradient that is not provably constant-RGB still opens as +//! a fill layer — the placeholder ramp stands in where the model has no stops, Photoshop's +//! own pixels ride along as the fill cache — and its raw `GdFl` block survives a re-save byte +//! for byte. Before the fix, `fill_from_desc` returned `None` for those gradients: the layer +//! imported as pixels and the export dropped the `GdFl` (`RASTER_STALE_KEYS`), so opening and +//! re-saving a file silently turned the fill layer into a plain pixel layer. + +use photocraft_doc::{Fill, LayerContent}; +use photocraft_io::*; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::testgen; +use photocraft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version}; + +/// Photoshop's default Noise gradient (RGB, min 0/0/0, max 100/100/100) as `GdFl` block data. +fn default_noise_gdfl() -> Vec { + let values = |v: [i32; 4]| Value::List(v.into_iter().map(Value::Integer).collect()); + let gradient = Descriptor::new("Grdn") + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("ClNs") }) + .with("ClrS", Value::Enumerated { type_id: Id::new("ClrS"), value: Id::new("RGBC") }) + .with("ShTr", Value::Boolean(false)) + .with("VctC", Value::Boolean(true)) + .with("RndS", Value::Integer(9052369)) + .with("Smth", Value::Integer(2048)) + .with("Mnm ", values([0, 0, 0, 0])) + .with("Mxm ", values([100, 100, 100, 100])); + let d = Descriptor::new("null") + .with("Grad", Value::Descriptor(gradient)) + .with("Angl", Value::UnitFloat { unit: *b"#Ang", value: 0.0 }) + .with("Type", Value::Enumerated { type_id: Id::new("Type"), value: Id::new("CstS") }); + VersionedDescriptor::new(d).to_bytes() +} + +#[test] +fn variable_noise_gradient_stays_a_fill_layer_and_keeps_its_gdfl() { + let _ = UnicodeString::new_nul("x"); + let mut f = testgen::small(Version::Psd, Compression::Raw); + let gdfl = default_noise_gdfl(); + f.layers_mut().push(LayerRecord { + name: b"noise fill".to_vec(), + blocks: vec![TaggedBlock::unicode_name("noise fill"), TaggedBlock::new(*b"GdFl", gdfl.clone())], + ..Default::default() + }); + for b in f.layers().last().unwrap().blocks.iter() { + b.check_structure().unwrap(); + } + let bytes = f.to_bytes().unwrap(); + let imp = import("in.psd", &bytes).unwrap(); + let l = imp.document.layers.iter().find(|l| l.name == "noise fill").unwrap(); + assert!(matches!(l.content, LayerContent::Fill(Fill::Gradient { .. })), "a variable noise GdFl opens as a gradient fill layer, not pixels"); + let out = export(&imp.document, "out.psd", &ExportOptions::default()).unwrap().bytes; + let back = PsdFile::from_bytes(&out).unwrap(); + let rec = back.layers().iter().find(|l| l.block(b"GdFl").is_some()).expect("the re-saved layer carries its GdFl"); + assert_eq!(rec.block(b"GdFl").unwrap().data, gdfl, "the raw GdFl survives the round trip byte for byte"); +} From 640873de7ba800dd6b3b8388e0f27c5bf2c63791 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:02:18 +0200 Subject: [PATCH 196/304] PNG: keep a stereo file's sTER pairing on save (#6063) (#6285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PNG: keep a stereo file's sTER pairing on save (#6063) Read the sTER chunk (PNG Extensions 1.4.0 §3.6) when a PNG opens, store it on the document's FileEncoding, and write it back when saving, so a stereo PNG still says it is a stereo pair. Covers the ordinary and the palette PNG encoders, including interlaced output. * io tests: pass the new sTER argument to encode_png_indexed Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: highperfocused Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/png.rs | 130 ++++++++++++++++++++++++++++++-- crates/codecs/src/lib.rs | 4 +- crates/codecs/src/options.rs | 32 ++++++++ crates/doc/src/lib.rs | 8 +- crates/engine/src/web_cmds.rs | 2 +- crates/io/src/flat.rs | 13 ++-- crates/io/tests/flat.rs | 25 +++++- crates/io/tests/modes.rs | 6 +- docs/file-format-parity.md | 5 +- 9 files changed, 202 insertions(+), 23 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 7c1a633d17..ede70d296d 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -7,7 +7,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed}; +use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed}; const F: Format = Format::Png; pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; @@ -127,6 +127,38 @@ pub fn palette(bytes: &[u8]) -> Result, CodecError> { Ok(Some(PngPalette { colors, transparent })) } +/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read +/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a +/// defined mode byte. The first valid chunk wins, as a later one would contradict it. +pub fn stereo(bytes: &[u8]) -> Option { + let mut rest = bytes.get(8..)?; + while let Some((length, tail)) = rest.split_first_chunk::<4>() { + let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break }; + let length = u32::from_be_bytes(*length); + if length > i32::MAX as u32 { + break; + } + let Ok(length) = usize::try_from(length) else { break }; + let Some(data) = tail.get(..length) else { break }; + let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break }; + rest = next; + if kind == b"IEND" { + break; + } + if kind != b"sTER" || data.len() != 1 { + continue; + } + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + if crc.sum() != u32::from_be_bytes(*checksum) { + continue; + } + return PngStereo::from_mode(data[0]); + } + None +} + fn map_png_err(e: png::DecodingError) -> CodecError { match e { png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()), @@ -346,6 +378,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, dpi: Option<(f32, f32)>, + stereo: Option, ) -> Result, CodecError> { if width == 0 || height == 0 || indices.len() != width as usize * height as usize { return Err(CodecError::InvalidImage("index data does not match the size".into())); @@ -603,12 +640,95 @@ pub fn encode_indexed( { let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(stereo) = stereo { + writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + } writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?; writer.finish().map_err(|e| CodecError::encode(F, e))?; } Ok(out) } +#[cfg(test)] +mod stereo_tests { + use super::*; + + /// One CRC-valid chunk as it appears in a file. + fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out + } + + fn png_with(stereo: &[u8]) -> Vec { + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0])); + if !stereo.is_empty() { + out.extend_from_slice(&chunk(b"sTER", stereo)); + } + out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0])); + out.extend_from_slice(&chunk(b"IEND", &[])); + out + } + + #[test] + fn reads_both_defined_views() { + assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal)); + assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical)); + assert_eq!(stereo(&png_with(&[])), None); + // Reserved and unknown mode bytes are not a pairing we can keep. + assert_eq!(stereo(&png_with(&[2])), None); + assert_eq!(stereo(&png_with(&[255])), None); + // A two-byte payload is not sTER data. + assert_eq!(stereo(&png_with(&[0, 0])), None); + assert_eq!(stereo(b"not a png"), None); + } + + #[test] + fn a_broken_crc_is_not_a_stereo_mark() { + let mut bytes = png_with(&[0]); + let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + // The CRC trails the type and the one-byte payload of the chunk. + bytes[kind + 8] ^= 0xff; + assert_eq!(stereo(&bytes), None); + } + + #[test] + fn encode_writes_the_chunk_before_idat_and_it_reads_back() { + let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap(); + for view in [PngStereo::Horizontal, PngStereo::Vertical] { + let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(view)); + let back = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(back.data(), img.data()); + let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap(); + assert!(ter < idat, "sTER before IDAT"); + } + let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert_eq!(stereo(&plain), None); + } + + #[test] + fn interlaced_and_indexed_encoders_keep_the_mark() { + let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap(); + let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::Vertical)); + let pal = [[255, 0, 0], [0, 0, 255]]; + let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal)); + assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec())); + } +} + #[cfg(test)] mod indexed_tests { use super::*; @@ -617,14 +737,14 @@ mod indexed_tests { fn png8_roundtrips_through_the_decoder() { let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]]; let idx = [0u8, 1, 2, 1, 0, 2]; - let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap(); + let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap(); let img = decode(&bytes, &Limits::default()).unwrap(); assert_eq!(img.dimensions(), (3, 2)); let px = img.convert(ChannelLayout::Rgba, SampleType::U8); assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]); assert_eq!(px.data()[11], 0, "transparent entry"); - assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err()); - assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err()); + assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err()); + assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err()); } fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec { @@ -643,7 +763,7 @@ mod indexed_tests { #[test] fn palette_reads_the_table_and_its_one_transparent_entry() { - let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap(); + let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap(); let colors = vec![[1, 2, 3], [4, 5, 6]]; assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) })); assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None })); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 86611599d5..67369d0266 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -28,7 +28,7 @@ pub mod web; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; -pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette}; +pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; @@ -38,7 +38,7 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; -pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; +pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index eee5b38c0a..e6ca3cb94f 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -100,6 +100,34 @@ pub enum PngCompression { Best, } +/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PngStereo { + /// Left and right views side by side, left first. + Horizontal, + /// Left and right views stacked, left on top. + Vertical, +} + +impl PngStereo { + /// The view for an `sTER` mode byte; reserved and unknown values are `None`. + pub fn from_mode(mode: u8) -> Option { + match mode { + 0 => Some(Self::Horizontal), + 1 => Some(Self::Vertical), + _ => None, + } + } + + /// The `sTER` mode byte. + pub fn mode(self) -> u8 { + match self { + Self::Horizontal => 0, + Self::Vertical => 1, + } + } +} + /// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the /// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -164,6 +192,9 @@ pub struct EncodeOptions { pub png_compression: PngCompression, /// Write Adam7-interlaced PNG. pub png_interlaced: bool, + /// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6), + /// so a stereo PNG keeps its mark when saved again (#6063). + pub png_stereo: Option, /// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see /// `codecs::vp8`) with [`Self::webp_quality`]. pub webp_lossless: bool, @@ -189,6 +220,7 @@ impl Default for EncodeOptions { jpeg_chroma_subsampling: true, png_compression: PngCompression::Default, png_interlaced: false, + png_stereo: None, webp_lossless: true, webp_quality: 80, tiff_compression: TiffCompression::None, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a1214e7938..268b0b6b43 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -912,8 +912,8 @@ pub struct Document { pub variables: Variables, /// Window › Timeline (None until a video timeline is created). pub timeline: Option, - /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so - /// a save writes it back the same way rather than at fixed defaults. + /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless, + /// PNG stereo pairing), so a save writes it back the same way rather than at fixed defaults. pub file_encoding: FileEncoding, } @@ -932,6 +932,10 @@ pub struct FileEncoding { /// Lossy WebP quality. #[serde(skip_serializing_if = "Option::is_none")] pub webp_quality: Option, + /// PNG stereo pairing (the `sTER` chunk's mode byte, PNG Extensions 1.4.0 §3.6): `Some(0)` + /// side by side, `Some(1)` stacked, so saving a stereo PNG keeps the mark (#6063). + #[serde(skip_serializing_if = "Option::is_none")] + pub png_stereo: Option, } impl FileEncoding { diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 29237fd5ad..755ba07320 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -586,7 +586,7 @@ pub fn optimize( let bytes = if st.format == WebFormat::Gif { photocraft_codecs::web::encode_gif_indexed(ww, hh, &idx, &pal, tt, st.interlaced).map_err(e)? } else { - photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)? + photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None, None).map_err(e)? }; if preview { indices = idx; diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 773e692729..76f53d9703 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -67,8 +67,9 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document } /// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality -/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its -/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`]. +/// (estimated from its quantisation tables), a WebP's lossless or lossy encoding with its +/// quality, and a PNG's `sTER` stereo pairing. A lossy WebP whose quality can't be read gets +/// [`DEFAULT_LOSSY_WEBP_QUALITY`]. pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { file_encoding_as(codecs::detect(bytes), bytes) } @@ -78,6 +79,7 @@ fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::Fil let mut e = photocraft_doc::FileEncoding::default(); match format { Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes), + Some(Format::Png) => e.png_stereo = codecs::png_stereo(bytes).map(codecs::PngStereo::mode), Some(Format::WebP) => match codecs::webp_encoding(bytes) { Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true), Some(codecs::WebpEncoding::Lossy { quality }) => { @@ -95,8 +97,8 @@ fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::Fil pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75; /// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a -/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or -/// lossless. +/// save that chose no quality writes, so a JPEG keeps its quality, a WebP stays lossy or lossless, +/// and a stereo PNG keeps its `sTER` pairing (#6063). pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { let e = &doc.file_encoding; if let Some(q) = e.jpeg_quality { @@ -108,6 +110,7 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { if let Some(q) = e.webp_quality { opts.webp_quality = q.min(100); } + opts.png_stereo = e.png_stereo.and_then(codecs::PngStereo::from_mode); } /// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the @@ -835,7 +838,7 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> })?; // Same rule as the ordinary PNG encoder: the resolution goes into `pHYs` with the metadata. let dpi = opts.encode.embed_metadata.then_some((doc.resolution_dpi, doc.resolution_dpi)); - let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi)?; + let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi, opts.encode.png_stereo)?; let mut warnings = vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())]; if opts.encode.embed_metadata && opts.xmp == XmpEmbed::All && !doc.metadata.text.is_empty() { warnings.push("text metadata is not supported by the palette PNG exporter; it will be dropped".into()); diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index edaff2ec81..e591fbd5c1 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -105,7 +105,7 @@ fn indexed_bmp_opens_with_its_color_table() { #[test] fn indexed_png_opens_with_its_color_table() { let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]]; - let png = photocraft_codecs::encode_png_indexed(2, 2, &[1, 0, 2, 1], &pal, Some(2), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(2, 2, &[1, 0, 2, 1], &pal, Some(2), None, None).unwrap(); let doc = import("indexed.png", &png).unwrap().document; assert_eq!(doc.mode, ColorMode::Indexed); assert_eq!(doc.depth, SampleType::U8); @@ -396,8 +396,8 @@ fn transparent_flat_images_open_as_layer_0() { ("ga.png", enc(ChannelLayout::GrayA, CS::U8, Format::Png), true), ("a.tif", enc(ChannelLayout::Rgba, CS::U8, Format::Tiff), true), ("ga.tif", enc(ChannelLayout::GrayA, CS::U16, Format::Tiff), true), - ("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0), None).unwrap(), true), - ("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None, None).unwrap(), false), + ("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0), None, None).unwrap(), true), + ("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None, None, None).unwrap(), false), ("rgb.png", enc(ChannelLayout::Rgb, CS::U8, Format::Png), false), ("g.png", enc(ChannelLayout::Gray, CS::U16, Format::Png), false), ("rgb.tif", enc(ChannelLayout::Rgb, CS::U8, Format::Tiff), false), @@ -1114,3 +1114,22 @@ fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { let r = export(&two, "tif", &ExportOptions::default()).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("2 layer(s) flattened")), "{:?}", r.warnings); } + +#[test] +fn a_stereo_png_keeps_its_stereo_mark_on_save() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS}; + let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap(); + for view in [PngStereo::Horizontal, PngStereo::Vertical] { + let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap(); + let doc = import("stereo.png", &bytes).unwrap().document; + assert_eq!(doc.file_encoding.png_stereo, Some(view.mode())); + // What File > Save does: reuse the settings the file was opened with (#6063). + let mut opts = ExportOptions::default(); + apply_file_encoding(&doc, &mut opts.encode); + let r = export(&doc, "png", &opts).unwrap(); + assert_eq!(photocraft_codecs::png_stereo(&r.bytes), Some(view), "{view:?}"); + } + // An ordinary PNG gains no mark. + let plain = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); + assert_eq!(photocraft_codecs::png_stereo(&plain.bytes), None); +} diff --git a/crates/io/tests/modes.rs b/crates/io/tests/modes.rs index c166766215..6760c7afe7 100644 --- a/crates/io/tests/modes.rs +++ b/crates/io/tests/modes.rs @@ -31,7 +31,7 @@ fn indexed_png_keeps_duplicate_palette_colors_opaque() { // choose the first red entry for both pixels, making the opaque one disappear on save. let palette = [[255, 0, 0], [0, 255, 0], [255, 0, 0], [0, 0, 255]]; for transparent in [0, 2] { - let png = photocraft_codecs::encode_png_indexed(4, 1, &[0, 1, 2, 3], &palette, Some(transparent), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(4, 1, &[0, 1, 2, 3], &palette, Some(transparent), None, None).unwrap(); let d = photocraft_io::import("in.png", &png).unwrap().document; assert_eq!(d.mode, ColorMode::Indexed); let before = d.color_table.clone(); @@ -102,7 +102,7 @@ fn indexed_png_keeps_the_last_palette_index() { palette[0] = [255, 0, 0]; palette[255] = [255, 0, 0]; for transparent in [0, 255] { - let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 255], &palette, Some(transparent), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 255], &palette, Some(transparent), None, None).unwrap(); let d = photocraft_io::import("in.png", &png).unwrap().document; let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); assert_eq!(photocraft_codecs::png_palette(&r.bytes).unwrap(), photocraft_codecs::png_palette(&png).unwrap()); @@ -112,7 +112,7 @@ fn indexed_png_keeps_the_last_palette_index() { #[test] fn indexed_png_keeps_a_single_transparent_entry() { - let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 0], &[[255, 0, 0]], Some(0), None).unwrap(); + let png = photocraft_codecs::encode_png_indexed(2, 1, &[0, 0], &[[255, 0, 0]], Some(0), None, None).unwrap(); let d = photocraft_io::import("in.png", &png).unwrap().document; let r = photocraft_io::export(&d, "out.png", &Default::default()).unwrap(); assert_eq!(photocraft_codecs::png_palette(&r.bytes).unwrap(), photocraft_codecs::png_palette(&png).unwrap()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..a2a21c9c37 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (PNG `sTER` stereo pairing kept on save, #6063; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -26,7 +26,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | -| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | | WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 5c513b8c88706b7dda113fb6d2d0f56d0146fcc4 Mon Sep 17 00:00:00 2001 From: Tarek Mohamed <30214297+seotarek@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:02:37 +0300 Subject: [PATCH 197/304] fix(actions): flush pending recorded steps on save (#7193) (#7244) When actions.save was called while recording was active, it exported only already-committed action steps, silently omitting any pending steps shown in the Actions panel. Call flush_recording at the beginning of actions.save to commit pending journal entries to the action and advance the recording pointer, ensuring all displayed recorded steps are saved while allowing ongoing recording to continue uninterrupted. Fixes #7193 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 1 + crates/engine/src/atn_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index d5c688f0a6..41badd86f3 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3227,6 +3227,7 @@ fn load(s: &mut Session, p: &Value) -> Result { fn save(s: &mut Session, p: &Value) -> Result { let cmd = "actions.save"; + crate::actions_cmds::flush_recording(s)?; let skip = match p.get("skipUnsupported") { None | Some(Value::Null) => false, Some(Value::Bool(b)) => *b, diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index c4fe00eb95..098dc9806d 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1033,6 +1033,26 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +#[test] +fn save_while_recording_includes_pending_steps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.record", json!({"name": "Pending"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Recorded"})).unwrap(); + + // While still recording (without calling actions.stop), save the action. + let saved = s.execute("actions.save", json!({"action": "Pending"})).unwrap(); + assert_eq!(saved["steps"], 1, "pending steps must be flushed and included in save"); + assert_eq!(saved["skipped"], json!([])); + + // After save, recording continues and additional steps are flushed on stop without duplication. + s.execute("layer.new.layer", json!({"name": "Second"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + + let action = s.execute("actions.get", json!({"action": "Pending"})).unwrap(); + assert_eq!(action["steps"].as_array().unwrap().len(), 2, "both steps must be present without duplication"); +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] From 96c151525a7755986765d52c281018d949ba5b92 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:02:53 +0200 Subject: [PATCH 198/304] feat(prefs): Show File Name In Title keeps the title bar to the app name (#5037) (#5129) Co-authored-by: highperfocused Co-authored-by: Brandon Thomas --- crates/engine/src/prefs.rs | 5 +++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels.rs | 49 +++++++++++++++++++++++++++-- 22 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3e87d43fc7..b551032e9d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -286,6 +286,10 @@ pub struct Interface { /// own one-row title bar (tiling window managers, desktops that draw their own decorations; /// #1271, #1316). Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, + /// Show the active document's file name in the title bar and the OS window title. The tabs + /// already name the file, so this can be turned off for a plain "PhotoCraft" title (#5037). + /// The unsaved-changes mark stays either way. + pub show_file_name_in_title: bool, /// Notices (the lower-right cards) hide themselves after /// [`Interface::notification_duration_seconds`] unless the pointer rests on them (#2022). pub notification_auto_hide: bool, @@ -317,6 +321,7 @@ impl Default for Interface { enable_animations: true, show_bounding_box_when_dragging_layer: false, system_title_bar: false, + show_file_name_in_title: true, notification_auto_hide: true, notification_duration_seconds: 6, } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..7da5cbc30e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3812,6 +3812,7 @@ navigator Zoom Zvětšení There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte. set name název sady {set} (modified) {set} (změněno) + Show file name in title Zobrazit název souboru v titulu Accept and Go to Conflict Přijmout a přejít ke konfliktu Changes undone. Změny vráceny. Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fbbceb440..6faa41f3ea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3208,6 +3208,7 @@ curves Smooth Glätten There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen. set name Name des Satzes {set} (modified) {set} (geändert) + Show file name in title Dateiname im Titel anzeigen Accept and Go to Conflict Übernehmen und zum Konflikt Changes undone. Änderungen rückgängig gemacht. Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d21c3ba6e4..ac3b9fbd36 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3904,6 +3904,7 @@ curves Smooth Εξομάλυνση There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα. set name όνομα συνόλου {set} (modified) {set} (τροποποιημένο) + Show file name in title Εμφάνιση ονόματος αρχείου στον τίτλο Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση Changes undone. Οι αλλαγές αναιρέθηκαν. Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4956fcd97b..b2e63bbc84 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4166,6 +4166,7 @@ navigator Zoom Zoom There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero. set name nombre del conjunto {set} (modified) {set} (modificado) + Show file name in title Mostrar nombre de archivo en el título Accept and Go to Conflict Aceptar e ir al conflicto Changes undone. Cambios deshechos. Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62b2e8ea26..2acba73d50 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3211,6 +3211,7 @@ curves Smooth Lisser set name nom du jeu {set} (modified) {set} (modifié) Random Aléatoire + Show file name in title Afficher le nom de fichier dans le titre Accept and Go to Conflict Accepter et aller au conflit Changes undone. Modifications annulées. Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a52cb5fdba..7393ba2954 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4123,6 +4123,7 @@ cameraRaw Yellow Sárga There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet. set name készlet neve {set} (modified) {set} (módosítva) + Show file name in title Fájlnév megjelenítése a címben Accept and Go to Conflict Elfogadás és ugrás az ütközéshez Changes undone. Változások visszavonva. Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 14e65b2b72..3246a9c371 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3198,6 +3198,7 @@ curves Smooth Haluskan set name nama set {set} (modified) {set} (diubah) Random Acak + Show file name in title Tampilkan nama file di judul Accept and Go to Conflict Terima dan Buka Konflik Changes undone. Perubahan dibatalkan. Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 857cc66d96..8b664de9f3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4325,6 +4325,7 @@ shape Appearance Aspetto There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno. set name nome del set {set} (modified) {set} (modificato) + Show file name in title Mostra nome file nel titolo Accept and Go to Conflict Accetta e vai al conflitto Changes undone. Modifiche annullate. Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b14f5c1192..c41d46a320 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3880,6 +3880,7 @@ curves Smooth スムーズ There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。 set name セット名 {set} (modified) {set}(変更あり) + Show file name in title タイトルにファイル名を表示 Accept and Go to Conflict 適用して競合へ移動 Changes undone. 変更を取り消しました。 Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d5d58b2ff2..21ef94cff1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3384,6 +3384,7 @@ curves Smooth 매끄럽게 There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요. set name 세트 이름 {set} (modified) {set}(수정됨) + Show file name in title 제목에 파일 이름 표시 Accept and Go to Conflict 적용 후 충돌 항목으로 이동 Changes undone. 변경 사항을 취소했습니다. Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 394aaecbbb..ca9253c349 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3964,6 +3964,7 @@ curves Smooth Vloeiend maken There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een. set name naam van de set {set} (modified) {set} (gewijzigd) + Show file name in title Bestandsnaam in titel weergeven Accept and Go to Conflict Accepteren en naar conflict Changes undone. Wijzigingen ongedaan gemaakt. Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e93bbb7..4f8194ccba 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3802,6 +3802,7 @@ curves Smooth Wygładź There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden. set name nazwa zestawu {set} (modified) {set} (zmieniony) + Show file name in title Pokaż nazwę pliku w tytule Accept and Go to Conflict Akceptuj i przejdź do konfliktu Changes undone. Zmiany cofnięte. Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 338c4fadb7..65b81cb194 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3994,6 +3994,7 @@ layerLabel No Color Sem cor There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro. set name nome do conjunto {set} (modified) {set} (modificado) + Show file name in title Exibir nome do arquivo no título Accept and Go to Conflict Aceitar e ir para o conflito Changes undone. Alterações desfeitas. Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11fd43811a..aa40b583b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3176,6 +3176,7 @@ curves Smooth Сгладить set name имя набора {set} (modified) {set} (изменён) Random Случайно + Show file name in title Показывать имя файла в заголовке Accept and Go to Conflict Принять и перейти к конфликту Changes undone. Изменения отменены. Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 25175aeff2..474fedaf22 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4277,6 +4277,7 @@ shape Appearance Izgled There are too many sets: delete one first. Previše skupova: prvo obrišite jedan. set name ime skupa {set} (modified) {set} (izmenjen) + Show file name in title Prikaži naziv datoteke u naslovu Accept and Go to Conflict Prihvati i idi na sukob Changes undone. Izmene su poništene. Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6af5510125..8fb253f142 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3934,6 +3934,7 @@ cameraData Lens Lens There are too many sets: delete one first. Çok fazla küme var: önce birini silin. set name küme adı {set} (modified) {set} (değiştirildi) + Show file name in title Başlıkta dosya adını göster Accept and Go to Conflict Kabul Et ve Çakışmaya Git Changes undone. Değişiklikler geri alındı. Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37851e90f3..ee29fb4a08 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3903,6 +3903,7 @@ curves Smooth Згладити There are too many sets: delete one first. Забагато наборів: спершу видаліть один. set name назва набору {set} (modified) {set} (змінено) + Show file name in title Показувати ім’я файла в заголовку Accept and Go to Conflict Прийняти й перейти до конфлікту Changes undone. Зміни скасовано. Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fd7950b930..03cdfb972b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3690,6 +3690,7 @@ curves Smooth Làm mượt There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước. set name tên bộ {set} (modified) {set} (đã sửa đổi) + Show file name in title Hiển thị tên tệp trong tiêu đề Accept and Go to Conflict Chấp nhận và đến xung đột Changes undone. Đã hoàn tác thay đổi. Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f242f8f63..42704b01b0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2826,6 +2826,7 @@ curves Smooth 平滑 set name 组名称 {set} (modified) {set}(已修改) Random 随机 + Show file name in title 标题中显示文件名 Accept and Go to Conflict 接受并转到冲突 Changes undone. 已撤消更改。 Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 621bee5518..1e1a8f04a4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2811,6 +2811,7 @@ curves Smooth 平滑 set name 組合名稱 {set} (modified) {set}(已修改) Random 隨機 + Show file name in title 標題中顯示檔案名稱 Accept and Go to Conflict 接受並前往衝突 Changes undone. 已還原變更。 Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a1b07f2b7..609007a5e4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -602,7 +602,14 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } - let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "PhotoCraft".into()); + // Preferences › Interface › Show File Name In Title: with it off the bar keeps the + // app name and the unsaved dot, since the tabs already name the file (#5037). + let show_name = app.session.prefs().interface.show_file_name_in_title; + let title = app + .session + .active() + .map(|d| format!("{}{}", if show_name { d.doc.name.as_str() } else { "PhotoCraft" }, if d.is_dirty() { " •" } else { "" })) + .unwrap_or_else(|| "PhotoCraft".into()); // The menus and the far-end controls are laid out first; the title is centred in // whatever room is left between them (`free`, left to right: menus then controls, or // controls then menus in a right-to-left UI), shortened or dropped rather than drawn @@ -728,8 +735,12 @@ pub fn system_title_bar(app: &PhotocraftApp) -> bool { /// The OS window title: the active document's name suffixed with the app name (a `*` prefix /// marks unsaved changes, like the `•` in the app-drawn title), or just the app name with no -/// document open. +/// document open. With Preferences › Interface › Show File Name In Title off, the file name +/// drops out and only the app name (and the `*`) remains (#5037). pub fn window_title(app: &PhotocraftApp) -> String { + if !app.session.prefs().interface.show_file_name_in_title { + return format!("{}PhotoCraft", if app.session.active().is_some_and(|d| d.is_dirty()) { "*" } else { "" }); + } app.session.active().map(|d| format!("{}{} \u{2014} PhotoCraft", if d.is_dirty() { "*" } else { "" }, d.doc.name)).unwrap_or_else(|| "PhotoCraft".into()) } @@ -820,6 +831,40 @@ mod title_tests { assert_eq!(window_title(&app), "*foo.psd \u{2014} PhotoCraft"); } + #[test] + fn hiding_the_file_name_keeps_the_app_name_and_the_dirty_mark() { + let mut app = app(true); + open_named(&mut app, "foo.psd"); + app.session.prefs.edit(|p| p.interface.show_file_name_in_title = false); + assert_eq!(window_title(&app), "PhotoCraft"); + if let Some(st) = app.session.active_mut() { + st.revision += 1; + } + assert_eq!(window_title(&app), "*PhotoCraft"); + } + + #[test] + fn hidden_file_name_leaves_the_app_name_in_the_drawn_title() { + let mut app = app(true); + open_named(&mut app, "foo.psd"); + let texts = |app: &mut crate::PhotocraftApp| { + let ctx = egui::Context::default(); + crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::title_bar(app, ui)); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for shape in &out.shapes { + collect_text(&shape.shape, &mut texts); + } + texts + }; + assert!(texts(&mut app).iter().any(|t| t.contains("foo.psd")), "shown by default"); + app.session.prefs.edit(|p| p.interface.show_file_name_in_title = false); + let hidden = texts(&mut app); + assert!(!hidden.iter().any(|t| t.contains("foo.psd")), "file name hidden: {hidden:?}"); + assert!(hidden.iter().any(|t| t.contains("PhotoCraft")), "app name kept: {hidden:?}"); + } + #[test] fn system_mode_hides_the_mark_and_the_centred_title() { for custom in [true, false] { From adbd40ef55032d1455db6101dbf0791ddea07192 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:03:20 -0400 Subject: [PATCH 199/304] Actions: Photoshop Shadows/Highlights steps play (#5830) (#5854) Imported actions stopped at Shadows/Highlights ("adaptCorrect has no PhotoCraft equivalent yet"), although Image > Adjustments > Shadows/Highlights exists. The step now maps to it both ways: shadowMode/highlightMode (amount, width, radius), black/white clip, midtone contrast and colour correction, each read as its char ID or string ID. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 54 +++++++++++++++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 43 +++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 41badd86f3..999ba5c193 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2372,6 +2372,52 @@ fn image_size_from(d: &Descriptor) -> Mapped { step("image.imageSize", Value::Object(p)) } +/// Shadows/Highlights (`adaptCorrect`, #5830): the shadow and highlight tones are `shadowMode` +/// and `highlightMode` objects (amount and width in %, radius in px), then the black and white +/// clips (%), midtone contrast (`center`) and colour correction. Each key is read as its char ID +/// or its string ID, whichever the file used; absent keys keep the command's defaults. +const SHADOWS_HIGHLIGHTS_TONES: [(&str, &str, &str); 2] = [("sdwM", "shadowMode", "shadow"), ("hglM", "highlightMode", "highlight")]; +const SHADOWS_HIGHLIGHTS_TONE: [(&str, &str, &str); 3] = [("Amnt", "amount", "Amount"), ("Wdth", "width", "Tone"), ("Rds ", "radius", "Radius")]; +const SHADOWS_HIGHLIGHTS: [(&str, &str, &str); 4] = + [("BlcC", "blackClip", "blackClip"), ("WhtC", "whiteClip", "whiteClip"), ("Cntr", "center", "midtone"), ("ClrC", "colorCorrection", "color")]; + +fn shadows_highlights_from(d: &Descriptor) -> Mapped { + let mut m = Map::new(); + for (code, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let Some(t) = get_obj(d, code).or_else(|| get_obj(d, name)) else { continue }; + for (code, name, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = get_num(t, code).or_else(|| get_num(t, name)) { + m.insert(format!("{prefix}{suffix}"), Value::from(v)); + } + } + } + for (code, name, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = get_num(d, code).or_else(|| get_num(d, name)) { + m.insert(param.into(), Value::from(v)); + } + } + step("image.adjustments.shadowsHighlights", Value::Object(m)) +} + +fn shadows_highlights_to(_: &str, p: &Value) -> Option { + let mut d = desc("null"); + for (_, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let mut t = desc("adaptCorrectTones"); + for (_, key, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = jnum(p, &format!("{prefix}{suffix}")) { + t = t.with(key, if suffix == "Radius" { long(v) } else { pct(v) }); + } + } + d = d.with(name, D::Descriptor(t)); + } + for (_, key, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = jnum(p, param) { + d = d.with(key, if matches!(param, "midtone" | "color") { long(v) } else { D::Double(v) }); + } + } + Some(d) +} + fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); if let Some(pc) = jnum(p, "percent") { @@ -2661,6 +2707,14 @@ const EVENTS: &[Event] = &[ ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). + Event { + code: "", + name: "adaptCorrect", + label: "Shadows/Highlights", + ids: &["image.adjustments.shadowsHighlights"], + from: shadows_highlights_from, + to: shadows_highlights_to, + }, Event { code: "Lvls", name: "levels", diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 098dc9806d..59dbbf13c8 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -186,6 +186,10 @@ fn every_mapped_command_exports_and_imports_back() { json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 0, "reds": {"hue": 5, "saturation": 0, "lightness": 0, "range": [315, 345, 15, 45]}}), ), ("image.adjustments.brightnessContrast", json!({"brightness": 10, "contrast": -5, "legacy": false})), + ( + "image.adjustments.shadowsHighlights", + json!({"shadowAmount": 60, "shadowTone": 40, "shadowRadius": 25, "highlightAmount": 10, "highlightTone": 50, "highlightRadius": 30, "blackClip": 0.02, "whiteClip": 0.05, "midtone": 15, "color": 20}), + ), ("image.adjustments.threshold", json!({"level": 100})), ("image.adjustments.posterize", json!({"levels": 4})), ("image.adjustments.invert", json!({})), @@ -1033,6 +1037,45 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// #5830: Shadows/Highlights (`adaptCorrect`) recorded by Photoshop, with string IDs, plays as +/// Image › Adjustments › Shadows/Highlights instead of stopping as a step with no equivalent. +#[test] +fn photoshop_shadows_highlights_steps_play() { + let tones = |amount: f64, width: f64, radius: i32| { + D::Descriptor(desc("adaptCorrectTones").with("amount", pct(amount)).with("width", pct(width)).with("radius", D::Integer(radius))) + }; + let d = desc("null") + .with("shadowMode", tones(35.0, 50.0, 30)) + .with("highlightMode", tones(20.0, 40.0, 24)) + .with("blackClip", D::Double(0.01)) + .with("whiteClip", D::Double(0.02)) + .with("center", D::Integer(5)) + .with("colorCorrection", D::Integer(20)); + let e = event_for(&item("adaptCorrect", "Sombra/Realce", None).event).expect("adaptCorrect is mapped"); + let Mapped::Steps(steps) = (e.from)(&d) else { panic!("Shadows/Highlights maps to a step") }; + assert_eq!(steps.len(), 1); + let (id, p) = &steps[0]; + assert_eq!(id, "image.adjustments.shadowsHighlights"); + let mut p = p.clone(); + normalise(&mut p); + assert_eq!( + p, + json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "highlightAmount": 20, "highlightTone": 40, "highlightRadius": 24, "blackClip": 0.01, "whiteClip": 0.02, "midtone": 5, "color": 20}) + ); + // The same settings as char IDs read the same; absent keys are left to the defaults. + let codes = desc("null") + .with("sdwM", D::Descriptor(desc("adaptCorrectTones").with("Amnt", pct(35.0)).with("Wdth", pct(50.0)).with("Rds ", D::Integer(30)))) + .with("BlcC", D::Double(0.01)); + let Mapped::Steps(steps) = (e.from)(&codes) else { panic!("char IDs map too") }; + let mut q = steps[0].1.clone(); + normalise(&mut q); + assert_eq!(q, json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "blackClip": 0.01})); + // And it runs on a document. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#404040"})).unwrap(); + s.execute(id, p).unwrap(); +} + #[test] fn save_while_recording_includes_pending_steps() { let mut s = Session::new(); From 94a135bc6b32b2dabc9fe0bf37bd27118b546791 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:03:26 +0200 Subject: [PATCH 200/304] Free Transform: show a flip as -100 % on the flipped axis (#7749) (#7750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The options bar read the box's X scale as never negative and dropped the sign of the Y scale, so Flip Horizontal read W 100 %, H 100 %, 180° (typing 0° then flipped the layer vertically) and Flip Vertical read the same as an untouched box. A mirrored box now reads as a negative W or H, whichever keeps the angle nearer 0, as in Photoshop (Flip Horizontal: W -100 % at 0°, Flip Vertical: H -100 % at 0°). Typed W/H divide by a sign-preserving guard instead of max(1e-9). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/transform_tool.rs | 57 ++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 45ef0b1528..561621e8c9 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -2614,9 +2614,23 @@ fn quad_scale(t: &TransformSession) -> f32 { } /// W and H (%), the angle (°) and the horizontal skew (°) the options bar shows for the box. +/// A mirrored box reads as a negative W or H, whichever keeps the angle nearer 0 (Photoshop: +/// Flip Horizontal is W -100 % at 0°, Flip Vertical H -100 % at 0°). Both are the same box: +/// negating W and H and turning by a half turn cancel out. fn readout(t: &TransformSession) -> (f64, f64, f64, f64) { let p = Parts::of(t); - (p.sx * 100.0, p.sy.abs() * 100.0, p.angle.to_degrees(), p.skew_h.to_degrees()) + let wrap = |a: f64| { + let a = a.rem_euclid(std::f64::consts::TAU); + if a > std::f64::consts::PI { a - std::f64::consts::TAU } else { a } + }; + let turned = wrap(p.angle + std::f64::consts::PI); + let (sx, sy, angle) = if p.sy < 0.0 && turned.abs() < wrap(p.angle).abs() { (-p.sx, -p.sy, turned) } else { (p.sx, p.sy, p.angle) }; + (sx * 100.0, sy * 100.0, angle.to_degrees(), p.skew_h.to_degrees()) +} + +/// `v`, kept off zero with its sign: a divisor for a W or H scale that may be negative. +fn nonzero(v: f64) -> f64 { + if v.abs() < 1e-9 { 1e-9f64.copysign(v) } else { v } } /// The box's affine part read as Photoshop's options bar does: the frame scaled (W, H), skewed @@ -2877,12 +2891,12 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let (w, h) = (size_pct(&u, w, side_w) as f32, size_pct(&u, h, side_h) as f32); if rw.changed() || rh.changed() { let (kx, ky) = if link { - let k = if rw.changed() { w as f64 / sx.max(1e-9) } else { h as f64 / sy.max(1e-9) }; + let k = if rw.changed() { w as f64 / nonzero(sx) } else { h as f64 / nonzero(sy) }; // Linked, the other side follows: keep it within its own limit too. let k = k.min(f64::from(wlim) / sx.abs().max(1e-9)).min(f64::from(hlim) / sy.abs().max(1e-9)); (k, k) } else { - (w as f64 / sx.max(1e-9), h as f64 / sy.max(1e-9)) + (w as f64 / nonzero(sx), h as f64 / nonzero(sy)) }; scale_about_pivot(app, kx, ky); } @@ -3342,6 +3356,43 @@ mod tests { assert!(eq6(skew, 20.0), "{skew}"); } + /// The test box flipped horizontally and vertically, as Edit › Transform › Flip leaves it. + const FLIPPED_H: [[f64; 2]; 4] = [[100.0, 0.0], [0.0, 0.0], [0.0, 50.0], [100.0, 50.0]]; + const FLIPPED_V: [[f64; 2]; 4] = [[0.0, 50.0], [100.0, 50.0], [100.0, 0.0], [0.0, 0.0]]; + + /// Photoshop puts a flip's minus on the flipped axis and keeps the angle at 0: Flip Horizontal + /// reads W -100 %, Flip Vertical H -100 % (it read 180° and nothing at all). + #[test] + fn a_flipped_box_reads_as_a_negative_scale_on_that_axis() { + let mut t = session(); + for (quad, want) in [(FLIPPED_H, (-100.0, 100.0)), (FLIPPED_V, (100.0, -100.0))] { + t.quad = quad; + let (w, h, angle, skew) = readout(&t); + assert!(eq6(w, want.0) && eq6(h, want.1) && eq6(angle, 0.0) && eq6(skew, 0.0), "{quad:?}: {w} {h} {angle} {skew}"); + } + // Flipped both ways is a half turn, not a mirror. + t.quad = [[100.0, 50.0], [0.0, 50.0], [0.0, 0.0], [100.0, 0.0]]; + let (w, h, angle, _) = readout(&t); + assert!(eq6(w, 100.0) && eq6(h, 100.0) && eq6(angle.abs(), 180.0), "{w} {h} {angle}"); + } + + /// The bar's fields act on a flipped box without undoing the flip: angle 0 keeps it as it is, + /// and a typed W scales it and keeps it mirrored. + #[test] + fn bar_fields_keep_a_flipped_box_flipped() { + let flip_h = |app: &mut PhotocraftApp| app.ui.transform.as_mut().unwrap().quad = FLIPPED_H; + let t = type_into_bar_field_with(flip_h, 4, "0"); + assert!(t.quad.iter().flatten().zip(FLIPPED_H.iter().flatten()).all(|(a, b)| eq6(*a, *b)), "{:?}", t.quad); + let t = type_into_bar_field_with(flip_h, 2, "-50"); + let (w, h, angle, _) = readout(&t); + assert!(eq6(w, -50.0) && eq6(h, 50.0) && eq6(angle, 0.0), "{w} {h} {angle}"); + assert!(eq6(t.quad[0][0] - t.quad[1][0], 50.0), "50 px wide, still mirrored: {:?}", t.quad); + let flip_v = |app: &mut PhotocraftApp| app.ui.transform.as_mut().unwrap().quad = FLIPPED_V; + let t = type_into_bar_field_with(flip_v, 3, "-200"); + let (w, h, _, _) = readout(&t); + assert!(eq6(w, 200.0) && eq6(h, -200.0), "{w} {h}"); + } + /// Typing H and V skews leans the box about the reference point; the angle, W and H fields keep /// their values, and V stays as typed. #[test] From baa005d4760ab69cf180848cdb84689ba9048cde Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:03:32 +0200 Subject: [PATCH 201/304] Type: formatting commands respect Lock All (#7718) (#7720) Character (type.setStyle), Warp Text, Anti-Alias, Orientation, OpenType and Load Default Type Styles all run through with_text_targets, which never checked Lock All, so they changed a locked type layer and added a history step. Photoshop refuses all of them ("the layer is locked"). Check every target with check_text_unlocked before the edit, as type.edit already does. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/type_cmds.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 712cc3c89d..439d1f008b 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -664,6 +664,10 @@ pub(crate) fn with_text_targets( mut f: impl FnMut(&mut TextLayer, (usize, usize)) -> Result<()>, ) -> Result> { let ids = format_targets(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + for id in &ids { + check_text_unlocked(doc, *id)?; + } let damage = s.edit(label, |doc, _| { let snapshot = doc.clone(); let mut damage = Some(photocraft_geom::Rect::EMPTY); @@ -1421,6 +1425,35 @@ mod tests { assert_eq!(s.active().unwrap().history.past_len(), history); } + #[test] + fn all_locked_type_layer_rejects_formatting_without_history() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 20, "y": 20, "text": "a", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("type.create", json!({"x": 20, "y": 60, "text": "b", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let before = text_layer(&s, id); + let history = s.active().unwrap().history.past_len(); + + for (cmd, p) in [ + ("type.setStyle", json!({"layer": id, "size": 40, "fauxBold": true})), + ("type.setStyle", json!({"layers": [other, id], "size": 40})), + ("type.warpText", json!({"layer": id, "style": "arc"})), + ("type.antiAlias.none", json!({"layer": id})), + ("type.orientation.vertical", json!({"layer": id})), + ("type.openType.swash", json!({"layer": id})), + ] { + let result = s.execute(cmd, p); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{cmd}: {result:?}"); + } + assert_eq!(text_layer(&s, id), before); + assert_eq!(text_layer(&s, other).size_pt, 12.0); + assert_eq!(s.active().unwrap().history.past_len(), history); + + s.execute("layer.setProps", json!({"layer": id, "locked": false})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "size": 40})).unwrap(); + assert_eq!(text_layer(&s, id).size_pt, 40.0); + } + #[test] fn trailing_empty_paragraph_styles_without_changing_previous_lines() { let mut s = session(); From c9d7f1d984512358b7fc2ee53998f0c7803dfea9 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:03:40 -0500 Subject: [PATCH 202/304] Docs: install the CLI and register it with MCP clients (#7663) --- book/src/automation/mcp.md | 3 +++ docs/mcp.md | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index 4f3d02496a..2c36b464c2 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -5,6 +5,9 @@ - **Headless:** an in-process `Headless` session owns the engine and performs file I/O. - **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control `. +For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register). +The installed binary survives `cargo clean`; a symlink into `target/release` does not. + Start the headless server with: ```sh diff --git a/docs/mcp.md b/docs/mcp.md index 3046a669bc..9a8c573ce6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,52 @@ # MCP conventions +## Install and register + +From the repository root, with the [source-build prerequisites](development.md#prerequisites) +installed, install the CLI independently of the build directory: + +```sh +cargo install --path apps/photocraft-cli --locked +# POSIX shells, for Cargo's default install location: +export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +photocraft-cli --help +photocraft-cli mcp --help +``` + +Cargo installs a binary, not a symlink into `target/`, so it survives `cargo clean`. +Re-run the install command after updating the checkout. +By default the executable goes in `$CARGO_HOME/bin` (`~/.cargo/bin` when unset), or +`%USERPROFILE%\.cargo\bin` on Windows. If you set `--root`, `CARGO_INSTALL_ROOT`, or +Cargo's `install.root`, use that installation root's `bin` directory instead. Add it +to the `PATH` inherited by the MCP client, not just an interactive shell, and restart +the client after changing its environment. + +Register the installed command explicitly (replace `/work/project` with an existing folder): + +```sh +claude mcp add photocraft -- photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project +``` + +Other MCP clients can use this bare-name configuration: + +```json +{"mcpServers":{"photocraft":{"command":"photocraft-cli","args":["mcp","--automation-read-root","/work/project","--automation-write-root","/work/project"]}}} +``` + +Linux `.deb` and `.rpm` packages already install `/usr/bin/photocraft-cli`; those users +can skip the source install. Other release locations are listed in the +[installed-release guide](../book/src/automation/mcp.md#from-an-installed-release). +Installing the CLI does not start or register an MCP server automatically. + +If a client reports `ENOENT` or "Executable not found in $PATH", verify the install with +`command -v photocraft-cli` (PowerShell: `Get-Command photocraft-cli`) in the client's +environment. If that environment cannot be changed, set JSON `command` to the absolute +installed binary path, not a literal `~` or `$HOME` expression. Avoid symlinks into +`target/release`: cleaning the build directory breaks them. A source build alone does +not put the CLI on `PATH`. + +## Start a session + PhotoCraft follows the same conventions as FilmCraft #28. Start a headless session: ```sh From 6385fced3df9653a0f4812b57e08b15fdad4b5c7 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Mon, 19 Oct 2026 13:03:48 -0400 Subject: [PATCH 203/304] refactor: use photocraft-color's srgb transfer functions in algo and proof simulation (#7604) * share the srgb transfer functions from photocraft-color * document the touched srgb helpers --- crates/algo/src/camera_raw.rs | 10 ++----- crates/algo/src/camera_raw/whites_blacks.rs | 4 +-- crates/algo/src/photo_util.rs | 22 ++++++++------ crates/engine/src/proof_sim.rs | 32 +++++++++++++++------ 4 files changed, 41 insertions(+), 27 deletions(-) diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index f855b08bfe..3cadd1d20c 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -67,22 +67,16 @@ impl Encoding { pub(crate) fn of(gamma: f32) -> Self { Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 }) } + /// A value in this encoding as linear light. pub(crate) fn decode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) } } + /// Linear light back into this encoding, the inverse of [`Encoding::decode`]. pub(crate) fn encode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) } } } -fn srgb_encode(v: f32) -> f32 { - linear_to_srgb(v) -} - -fn srgb_decode(v: f32) -> f32 { - srgb_to_linear(v) -} - fn luma_linear(c: [f32; 3]) -> f32 { luma(c) } diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs index 382ade50e9..93bb9cff31 100644 --- a/crates/algo/src/camera_raw/whites_blacks.rs +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco if per_channel { for c in 0..3 { let over = (lin[c] - 1.0).max(0.0); - q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over); + q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over); } } else { let y = super::luma_linear(lin).max(0.0); if y <= 1e-7 { continue; } - let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0); + let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0); let k = y2 / y; for c in 0..3 { q[c] = enc.encode(lin[c] * k); diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index bf65bc4459..b29612769b 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -183,15 +183,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 { (z >> 40) as f32 / (1u64 << 24) as f32 } -/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1). -pub(crate) fn srgb_to_linear(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - -/// sRGB transfer function, encode. -pub(crate) fn linear_to_srgb(v: f32) -> f32 { - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } -} +pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; /// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges. pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 { @@ -217,6 +209,18 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] { mod tests { use super::*; + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + #[test] fn dense_and_sparse_solvers_agree() { // Overdetermined line fit y = 2x + 1. diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index f225a5a7a9..6c7afc15d9 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -18,6 +18,7 @@ use photocraft_cms::{Builtin, Intent, Lut3d, Transform}; use photocraft_color::SampleType; +use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; use photocraft_doc::Document; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -91,14 +92,16 @@ impl Default for HdrPreview { } impl HdrPreview { + /// Whether the preview passes values through unchanged. pub fn is_identity(&self) -> bool { self.highlight_compression || (self.exposure == 0.0 && self.gamma == 1.0) } + /// One encoded channel value through the 32-bit preview's exposure and gamma. fn apply(&self, v: f32) -> f32 { if self.is_identity() { return v; } - let lin = srgb_decode(v) * 2f32.powf(self.exposure); + let lin = srgb_to_linear(v) * 2f32.powf(self.exposure); srgb_encode(lin.max(0.0).powf(1.0 / self.gamma).min(1.0)) } } @@ -115,13 +118,9 @@ pub fn signature(c: &ColorState, doc: &Document) -> String { format!("{:?} {:?}", pv.setup.kind, h) } -fn srgb_decode(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - +/// sRGB-encodes linear light after clamping it to 0..=1. fn srgb_encode(v: f32) -> f32 { - let v = v.clamp(0.0, 1.0); - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + linear_to_srgb(v.clamp(0.0, 1.0)) } /// Viénot et al. (1999): linear RGB → LMS. @@ -201,7 +200,7 @@ pub fn display_lut_with(c: &ColorState, doc: &Document, size: usize, include_hdr let protan = kind == ProofKind::Protanopia; Box::new(move |v| { let s = eval3(&to_srgb, &v); - let sim = dichromat(s.map(srgb_decode), protan).map(srgb_encode); + let sim = dichromat(s.map(srgb_to_linear), protan).map(srgb_encode); eval3(&to_mon, &sim) }) } @@ -262,6 +261,7 @@ fn run_kind(s: &mut Session, id: &str) -> Result { set_kind(s, kind) } +/// View › 32-bit Preview Options for the active document. fn preview_32(s: &mut Session, p: &Value) -> Result { const CMD: &str = "view.thirtyTwoBitPreviewOptions"; let id = s.active().ok_or(EngineError::NoDocument)?.doc.id; @@ -298,6 +298,7 @@ macro_rules! kind_spec { }; } +/// The View › Proof Setup commands. pub fn specs() -> Vec { vec![ kind_spec!("view.proofSetup.workingCyanPlate", "Working Cyan Plate"), @@ -325,6 +326,21 @@ pub fn specs() -> Vec { mod tests { use super::*; + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(srgb_encode(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + fn session(mode: &str, depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8, "mode": mode, "depth": depth})).unwrap(); From 5071472e4d9478ec668dd81aeeed15506b98801e Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:03:56 +0200 Subject: [PATCH 204/304] =?UTF-8?q?Layers:=20=E2=8C=98-click=20on=20the=20?= =?UTF-8?q?Background=20thumbnail=20is=20a=20plain=20click,=20as=20in=20Ph?= =?UTF-8?q?otoshop=20(#7165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layers: ⌘-click on the Background thumbnail is a plain click, as in Photoshop ⌘-clicking a layer thumbnail loads its transparency as a selection (⇧ adds, ⌥ subtracts, ⇧⌥ intersects) without changing the selected layer. Measured on Photoshop 25.4: the Background, which has no transparency, is the exception — the click loads nothing and selects the row like a plain click, where PhotoCraft selected the whole canvas. A hidden layer still loads. Co-Authored-By: Claude Fable 5.1 * Layers: the Background thumbnail's ⌘-click selects the Background alone Review: the fallback to the row handler kept ⌘ set, so `select_mode` toggled the Background into (or out of) the selected set instead of the plain click's replace. The Background thumbnail's ⌘-click now selects it alone; the test asserts the selected set, including when the Background was already part of a multi-selection. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/layer_shortcut_tests.rs | 66 ++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 14 +++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/layer_shortcut_tests.rs b/crates/ui-egui/src/layer_shortcut_tests.rs index a363c340bf..96ab2520dc 100644 --- a/crates/ui-egui/src/layer_shortcut_tests.rs +++ b/crates/ui-egui/src/layer_shortcut_tests.rs @@ -122,3 +122,69 @@ fn alt_click_on_a_layer_thumbnail_fits_the_layer() { assert_eq!(v.center, [310.0, 205.0], "centred on the layer's content"); assert!(v.zoom > before.zoom * 4.0, "zoomed in on 20 × 10 px: {} -> {}", before.zoom, v.zoom); } + +/// ⌘-click a layer's thumbnail loads its transparency as a selection without changing the +/// selected layer; on the Background, which has no transparency, it is a plain click, and a hidden +/// layer still loads (measured on Photoshop 25.4, 2026-10-17). +#[test] +fn command_click_on_a_thumbnail_loads_transparency_except_on_the_background() { + use egui::{Event, PointerButton, pos2, vec2}; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let background = s.active().unwrap().active_layer.unwrap().0; + let shape = s.execute("layer.new.layer", json!({"name": "Shape"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 100, "y": 50, "width": 120, "height": 100})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, layer: u64, modifiers: Modifiers| { + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == layer).unwrap().row; + let at = pos2(row.left() + 46.0, row.center().y); + h.hover_at(at); + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(1); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + }; + let bounds = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + st.doc.selection.as_ref().map(|s| s.content_bounds()) + }; + let top = h.state().session.active().unwrap().active_layer.unwrap(); + let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().selected_layers(); + click(&mut h, shape, Modifiers::COMMAND); + assert_eq!(bounds(&h), Some(photocraft_geom::Rect::new(100, 50, 220, 150)), "the layer's pixels are selected"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "the selected layer stays"); + assert_eq!(selected(&h), vec![top]); + click(&mut h, background, Modifiers::COMMAND); + assert_eq!( + bounds(&h), + Some(photocraft_geom::Rect::new(100, 50, 220, 150)), + "the Background has no transparency to load: a plain click, which leaves the selection and selects the row" + ); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(background))); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(background)], "the Background alone, not toggled into the set"); + // Already in a multi-selection: the ⌘-click still leaves it the only selected layer. + h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": background, "mode": "add"})).unwrap(); + h.run_steps(2); + assert_eq!(selected(&h).len(), 2); + click(&mut h, background, Modifiers::COMMAND); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(background)], "replaced, not removed from the set"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(background))); + h.state_mut().run("layer.setProps", json!({"layer": shape, "visible": false})).unwrap(); + click(&mut h, shape, Modifiers::COMMAND); + assert_eq!(bounds(&h), Some(photocraft_geom::Rect::new(100, 50, 220, 150)), "a hidden layer's transparency loads too"); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 609007a5e4..75ade83b8d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3144,7 +3144,9 @@ fn layer_row( if let Some(kind) = masks.hit(p) { return Some(crate::mask_thumbs_ui::load_params(l, kind, m)); } - thumb.expand(2.0).contains(p).then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) + // The Background has no transparency to load: Photoshop treats the click as a plain one. + (!doc.is_background(l.id) && thumb.expand(2.0).contains(p)) + .then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) }); // ⇧-click a mask thumbnail: disable / enable that mask; ⌥-click a layer mask: view it. let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { @@ -3156,14 +3158,17 @@ fn layer_row( } else if let Some(cmd) = mask_toggle { actions.push(cmd); } else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked { - let mode = select_mode(ui.input(|i| i.modifiers)); + let m = ui.input(|i| i.modifiers); + let pos = resp.interact_pointer_pos(); + let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p)); + // ⌘-click on the Background thumbnail stands in for the plain click (there is no + // transparency to load): it selects the Background alone, not toggled into the set. + 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. - let pos = resp.interact_pointer_pos(); let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p)); - let on_thumb = pos.is_some_and(|p| thumb.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). let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some_and(|v| v.layer == l.id); @@ -3186,7 +3191,6 @@ fn layer_row( actions.push(("ui.maskTarget".into(), json!(false))); } // ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does. - let m = ui.input(|i| i.modifiers); if on_thumb && m.alt && !m.shift && !m.command { actions.push(("view.fitLayersOnScreen".into(), Value::Null)); } From aa8621788eb9553c0c0ed224640b6889c872b5ba Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:02 +0200 Subject: [PATCH 205/304] Minimum / Maximum: CMYK spreads paper white with Maximum, ink with Minimum (#7074) (#7075) Co-authored-by: Claude Opus 5.5 --- crates/algo/src/lib.rs | 21 +++++++++++++++++++-- crates/algo/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index ac4b3b1fcc..6cefbbd4c1 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -745,8 +745,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed), FilterParams::Median { radius } => noise::median(src, out, *radius, None), FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)), - FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve), - FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve), + FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve), + FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve), FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined), FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), @@ -910,6 +910,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec Vec { + if !fxutil::subtractive(ctx) { + return other::min_max_preserve(src, out, radius, max, preserve); + } + let n = src.ch.max(1); + let cc = if ctx.alpha { n - 1 } else { n }; + let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v)); + let mut inv = src.clone(); + flip(&mut inv.data); + let mut res = other::min_max_preserve(&inv, out, radius, max, preserve); + flip(&mut res); + res +} + /// Output tile size used by [`apply`]. pub const TILE: i32 = 256; diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 06beabebbe..b7fc92113c 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -269,6 +269,32 @@ fn minimum_maximum_spread() { assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6); } +/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop +/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps +/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones). +#[test] +fn minimum_maximum_treat_cmyk_as_ink() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true)); + s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]); + s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]); + for preserve in [Preserve::Squareness, Preserve::Roundness] { + let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve }); + for x in 17..24 { + assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15)); + } + assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha"); + let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve }); + for x in 17..24 { + let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 }; + assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15)); + } + assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha"); + } + } +} + #[test] fn maximum_roundness_grows_a_point_into_a_disc() { // One white pixel on black: Maximum spreads it over the structuring element itself. From 4bf0625b04d10dff415a2d494649638db5e7ac00 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 20 Oct 2026 04:04:14 +1100 Subject: [PATCH 206/304] Export and import saved tool presets (#6560) (#6889) * Include tool presets in export and import so crop presets are portable. * Pass presets_io_body to the harness without a redundant closure (#6560). CI clippy -D warnings failed on clippy::redundant_closure in the presets export/import regression. --- crates/engine/src/edit_menu_cmds.rs | 42 +++++++++++++++++++-- crates/engine/src/edit_menu_cmds/tests.rs | 46 +++++++++++++++++++++++ crates/engine/src/presets/tools.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 23 ++++++++++-- 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 0b2aad8854..b620230a61 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -1354,6 +1354,7 @@ pub struct PresetFile { pub version: u32, pub brushes: Vec, pub custom_shapes: Vec, + pub tool_presets: Vec, } pub const PRESET_FORMAT: &str = "photocraft-presets"; @@ -1364,7 +1365,7 @@ fn export_import(s: &mut Session, p: &Value) -> Result { .get("kinds") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_else(|| ["brushes", "customShapes"].iter().map(|k| k.to_string()).collect()); + .unwrap_or_else(|| ["brushes", "customShapes", "tools"].iter().map(|k| k.to_string()).collect()); let want = |k: &str| kinds.iter().any(|x| x == k); match str_or(p, "action", "export") { "export" => { @@ -1383,8 +1384,14 @@ fn export_import(s: &mut Session, p: &Value) -> Result { Vec::new() }, custom_shapes: if want("customShapes") { s.edit_state.custom_shapes.clone() } else { Vec::new() }, + tool_presets: if want("tools") { + let builtins = crate::presets::tools::builtin(); + s.presets.tool_presets.iter().filter(|preset| bool_or(p, "includeBuiltins", false) || !builtins.contains(preset)).cloned().collect() + } else { + Vec::new() + }, }; - Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len()})) + Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len(), "tools": file.tool_presets.len()})) } "import" => { let data = p.get("data").cloned().ok_or_else(|| bad(cmd, "missing `data` (an exported preset file)"))?; @@ -1396,6 +1403,29 @@ fn export_import(s: &mut Session, p: &Value) -> Result { if file.format != PRESET_FORMAT { return Err(bad(cmd, format!("not a preset file (format `{}`)", file.format))); } + // Validate the whole tool import before changing any preset library. + let mut tools = s.presets.tool_presets.clone(); + let nt = if want("tools") { file.tool_presets.len() } else { 0 }; + if nt > crate::presets::tools::MAX_PRESETS { + return Err(bad(cmd, "too many tool presets")); + } + if want("tools") { + for mut preset in file.tool_presets { + preset.name = crate::presets::tools::checked_name(&preset.name, cmd)?; + if preset.tool.trim().is_empty() || !preset.options.is_object() { + return Err(bad(cmd, "tool presets require a non-empty tool and an options object")); + } + if let Some(old) = tools.iter_mut().find(|old| old.name == preset.name && old.tool.eq_ignore_ascii_case(&preset.tool)) { + *old = preset; + } else if tools.iter().any(|old| old.name == preset.name) { + return Err(bad(cmd, "a preset of that name belongs to another tool; rename it before importing")); + } else if tools.len() < crate::presets::tools::MAX_PRESETS { + tools.push(preset); + } else { + return Err(bad(cmd, "too many tool presets")); + } + } + } let (mut nb, mut ns) = (0, 0); if want("brushes") { for mut b in file.brushes { @@ -1420,7 +1450,11 @@ fn export_import(s: &mut Session, p: &Value) -> Result { ns += 1; } } - Ok(json!({"brushes": nb, "customShapes": ns})) + if want("tools") { + s.presets.tool_presets = tools; + s.presets_changed(); + } + Ok(json!({"brushes": nb, "customShapes": ns, "tools": nt})) } other => Err(bad(cmd, format!("unknown action `{other}` (export|import)"))), } @@ -1512,7 +1546,7 @@ pub fn specs() -> Vec { "Export/Import Presets…", ["Edit", "Presets"], None, - r##"{"action":"export|import","kinds":["brushes","customShapes"]?,"data":json (import),"includeBuiltins":bool=false}"##, + r##"{"action":"export|import","kinds":["brushes","customShapes","tools"]?,"data":json (import),"includeBuiltins":bool=false}"##, always, export_import ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 7564cc53d1..16e98b8719 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -693,3 +693,49 @@ fn fade_allocation_failure_keeps_document_and_history() { assert_eq!(*s.active().unwrap().doc, *before); } } + +#[test] +fn tool_presets_round_trip_through_export_import() { + let mut source = Session::new(); + source.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"2:3"}}})).unwrap(); + let exported = source.execute("edit.presets.exportImportPresets", json!({"action":"export"})).unwrap(); + assert_eq!(exported["tools"], 1); + assert_eq!(exported["data"]["toolPresets"][0]["name"], "2 : 3"); + let mut target = Session::new(); + target.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"1:1"}}})).unwrap(); + for _ in 0..2 { + let result = target.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":exported["data"]})).unwrap(); + assert_eq!(result["tools"], 1); + } + let selected = target.execute("tool.presets.select", json!({"preset":"2 : 3"})).unwrap(); + assert_eq!(selected["options"]["toolOptions"]["crop_ratio"], "2:3"); + assert_eq!(target.presets.tool_presets.iter().filter(|p| p.name == "2 : 3").count(), 1); + let only_brushes = source.execute("edit.presets.exportImportPresets", json!({"kinds":["brushes"]})).unwrap(); + assert_eq!(only_brushes["tools"], 0); + let with_builtins = source.execute("edit.presets.exportImportPresets", json!({"kinds":["tools"], "includeBuiltins":true})).unwrap(); + assert_eq!(with_builtins["tools"].as_u64().unwrap() as usize, source.presets.tool_presets.len()); +} + +#[test] +fn tool_preset_import_is_validated_before_mutation_and_accepts_old_files() { + let mut s = Session::new(); + let before = s.presets.tool_presets.clone(); + for preset in [ + json!({"name":"", "tool":"crop", "options":{}}), + json!({"name":"x".repeat(256), "tool":"crop", "options":{}}), + json!({"name":"Crop", "tool":" ", "options":{}}), + json!({"name":"Crop", "tool":"crop", "options":[]}), + ] { + let result = s.execute( + "edit.presets.exportImportPresets", + json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":[{"name":"Valid", "tool":"crop", "options":{}}, preset]}}), + ); + assert!(result.is_err()); + assert_eq!(s.presets.tool_presets, before); + } + let too_many: Vec<_> = (0..crate::presets::tools::MAX_PRESETS + 1).map(|i| json!({"name":format!("Crop {i}"), "tool":"crop", "options":{}})).collect(); + assert!(s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":too_many}})).is_err()); + assert_eq!(s.presets.tool_presets, before); + let result = s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "version":1}})).unwrap(); + assert_eq!(result["tools"], 0); +} diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index 796a66623e..2746c1576e 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -37,7 +37,7 @@ impl Named for ToolPreset { pub const MAX_PRESETS: usize = 1000; /// A preset name: trimmed, 1–[`MAX_NAME`](crate::brush_preset_cmds::MAX_NAME) characters. -fn checked_name(name: &str, cmd: &str) -> Result { +pub(crate) fn checked_name(name: &str, cmd: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err(bad(cmd, "the preset name is empty")); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 4156e380cd..7857b0f8f7 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -916,7 +916,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V Some(Ok(json!({"dialog": open_kind(app, "presets", "Preset Manager", json!({"kind": "brushes", "selected": 0, "newName": ""}))}))) } "edit.presets.exportImportPresets" => Some(Ok( - json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true}))}), + json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true, "tools": true}))}), )), _ => None, } @@ -2318,7 +2318,7 @@ fn presets_io_body(ui: &mut egui::Ui, f: &mut Map) { 180.0, ); }); - for (k, label) in [("brushes", tl!("Brushes")), ("customShapes", tl!("Custom Shapes"))] { + for (k, label) in [("brushes", tl!("Brushes")), ("customShapes", tl!("Custom Shapes")), ("tools", tl!("Tool Presets"))] { let mut on = f.get(k).and_then(Value::as_bool).unwrap_or(true); crate::widgets::checkbox(ui, &mut on, label); f.insert(k.into(), json!(on)); @@ -2442,7 +2442,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result Ok(Value::Null), "presetsIO" => { - let kinds: Vec<&str> = ["brushes", "customShapes"].into_iter().filter(|k| f.get(*k).and_then(Value::as_bool).unwrap_or(true)).collect(); + let kinds: Vec<&str> = ["brushes", "customShapes", "tools"].into_iter().filter(|k| f.get(*k).and_then(Value::as_bool).unwrap_or(true)).collect(); if f.get("action").and_then(Value::as_str) == Some("import") { app.pick_file_bytes(move |app, name, bytes| { let text = String::from_utf8(bytes).map_err(|_| format!("{name} is not a preset file"))?; @@ -3551,6 +3551,23 @@ mod tests { assert!(store2.lock().unwrap().as_ref().unwrap().contains("classic")); } + #[test] + fn preset_export_dialog_offers_tool_presets() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let opened = crate::menus::invoke(&mut app, &ctx, "edit.presets.exportImportPresets", json!({})).unwrap(); + let fields = app.ui.dialog_mut(opened["dialog"].as_u64().unwrap()).unwrap().fields.clone(); + assert_eq!(fields["tools"], true); + let mut h = Harness::builder().with_size(vec2(420.0, 260.0)).build_ui_state(presets_io_body, fields); + h.run_steps(2); + h.get_by_label("Tool Presets").click(); + h.run_steps(2); + assert_eq!(h.state()["tools"], false); + assert_eq!(h.state()["brushes"], true); + assert_eq!(h.state()["customShapes"], true); + } + #[test] fn brush_preset_store_attaches_when_loaded_and_persists() { use photocraft_engine::preset_store::{MemBackend, open}; From 7efaa504559a093e29b03d1d40c39f06a07e3029 Mon Sep 17 00:00:00 2001 From: codexistence Date: Mon, 19 Oct 2026 13:04:20 -0400 Subject: [PATCH 207/304] fix: match Photoshop Gradient Fill scale limits (#6797) --- crates/engine/src/gradient_fill_cmds.rs | 9 +++--- crates/engine/src/gradient_fill_cmds/tests.rs | 28 ++++++++++--------- crates/engine/src/presets/gradients.rs | 4 +-- .../src/gradient_ui/properties_panel.rs | 3 +- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index 041d311691..356d2252db 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -28,9 +28,10 @@ pub const GET: &str = "gradient.fill.get"; /// Largest coordinate accepted for a start/end point (pixels). const MAX_COORD: f32 = 1.0e7; -/// Scale range of a gradient fill (a fraction): Photoshop allows 10–150 % (the PSD stores a -/// percentage). A handle drag past it clamps, so the far handle stops where the scale does. -const SCALE_RANGE: (f32, f32) = (0.1, 1.5); + +/// Scale range of a Gradient Fill layer (a fraction): Photoshop allows 1–1000 %. +/// Gradient Overlay effects have a separate range; the live tool creates fill layers. +pub const SCALE_RANGE: (f32, f32) = (0.01, 10.0); fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } @@ -149,7 +150,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -fn clamp_scale(v: f32) -> f32 { +pub(crate) fn clamp_scale(v: f32) -> f32 { v.clamp(SCALE_RANGE.0, SCALE_RANGE.1) } diff --git a/crates/engine/src/gradient_fill_cmds/tests.rs b/crates/engine/src/gradient_fill_cmds/tests.rs index efcdb494d9..3c0c2f3c6a 100644 --- a/crates/engine/src/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/gradient_fill_cmds/tests.rs @@ -27,7 +27,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { let r = s .execute( CREATE, - json!({"from": [14, 16], "to": [34, 8], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), + json!({"from": [14, 16], "to": [200, -40], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), ) .unwrap(); assert_eq!(layer_count(&s), 2); @@ -42,7 +42,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { assert!(!align && dither); let g = s.execute(GET, json!({})).unwrap(); let (from, to) = (g["from"].as_array().unwrap(), g["to"].as_array().unwrap()); - for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 34.0, 8.0]) { + for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 200.0, -40.0]) { assert!((v.as_f64().unwrap() - want).abs() < 1e-3, "{g}"); } // One undo step removes the layer. @@ -225,11 +225,11 @@ fn stops_add_move_recolour_delete_and_midpoints() { #[test] fn preview_equals_the_committed_edit() { let mut s = session(8, "white"); - s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "diamond"})).unwrap(); + s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "radial"})).unwrap(); let d = s.active().unwrap(); let l = d.doc.layer(d.active_layer.unwrap()).unwrap().clone(); let LayerContent::Fill(f) = &l.content else { panic!() }; - let p = json!({"from": [10, 12], "to": [30, 2]}); + let p = json!({"from": [10, 12], "to": [150, -80]}); let preview = apply_set(&l, f, d.doc.bounds(), &p, [0.0; 4], [1.0; 4]).unwrap(); s.execute(SET, p).unwrap(); assert_eq!(active_fill(&s), preview); @@ -316,12 +316,12 @@ fn commands_fail_gracefully() { s.execute(SET, json!({"scale": 1e30, "angle": 1e9, "offset": [1e4, -1e4]})).unwrap(); let g = s.execute(GET, json!({})).unwrap(); assert!(g["scale"].as_f64().unwrap().is_finite() && g["from"][0].as_f64().unwrap().is_finite()); - // Scale stays within Photoshop's 10–150 %, also for a handle dragged far away. - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); + // Fill layers use Photoshop's 1–1000 %, also for a handle dragged far away. + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); let g = s.execute(SET, json!({"from": [0, 0], "to": [1e6, 0]})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); - let g = s.execute(SET, json!({"scale": 1})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}"); + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); + let g = s.execute(SET, json!({"scale": 0.1})).unwrap(); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{g}"); } /// Dragging one handle past the scale range stops it; the other handle stays put (#1243). @@ -332,17 +332,19 @@ fn a_clamped_handle_drag_leaves_the_other_handle_alone() { for style in STYLES { for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])] { - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); - // End dragged almost onto the start: far below 10 %. + // End dragged almost onto the start: below 1 % on this larger canvas. let g = s.execute(SET, json!({"to": short})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}"); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{style}: {g}"); assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from")); // The end stops on the line towards the pointer. let e = pt(&g, "to"); assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}"); // And the same for the start handle. - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); let g = s.execute(SET, json!({"from": short_start})).unwrap(); assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}"); diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..2a700aad99 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -604,7 +604,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD)?; let stops = g.doc_stops(s.tools.foreground, s.tools.background); let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0) as f32; - let scale = (p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.1, 1.5); + let scale = crate::gradient_fill_cmds::clamp_scale(p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { @@ -696,7 +696,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"## + r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":1..1000=100,"reverse":bool}} → {{layer}}"## )), enabled: super::has_doc, run: apply, diff --git a/crates/ui-egui/src/gradient_ui/properties_panel.rs b/crates/ui-egui/src/gradient_ui/properties_panel.rs index 39b961ee20..d975102539 100644 --- a/crates/ui-egui/src/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/gradient_ui/properties_panel.rs @@ -49,7 +49,8 @@ fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if let Some(value) = numeric_row(ui, "angle", "Angle", angle, -180.0..=180.0, "°") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "angle": value})); } - if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, 10.0..=150.0, "%") { + let (min, max) = cmds::SCALE_RANGE; + if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, min * 100.0..=max * 100.0, "%") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "scale": value})); } ui.horizontal_wrapped(|ui| { From 566dbb3f83ecf03c093a0d8219e2123865d577a6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:26 +0200 Subject: [PATCH 208/304] Histogram: count the document's pixels, not a blurred thumbnail (#6538) (#6724) The Histogram panel binned a 384 px area-averaged thumbnail, so the cell holding a black/white edge came out mid-grey: a spike near 128 and slightly wrong Mean/Std Dev. Documents up to PROXY_THUMBNAIL_PIXELS are now composited in bands and every pixel is counted (Cache Level 1, as Photoshop's uncached histogram); larger ones count a nearest-neighbour proxy composite, which only holds values the layers have, and report its cache level. The Pixels row shows the pixels actually counted. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/tone.rs | 186 ++++++++++++++++++++++++++++++++----- 1 file changed, 164 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index aaf742096a..f02c108dbb 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -24,6 +24,9 @@ static EMPTY: [u32; 256] = [0; 256]; pub struct Histograms { pub tag: u64, pub ch: Vec<[u32; 256]>, + /// Photoshop's histogram cache level of a composite histogram: 1 when every document pixel + /// was counted, `n` when a proxy 2^(n-1) times smaller stood in; 0 for a gray plane's. + pub level: u32, } impl std::ops::Index for Histograms { @@ -76,6 +79,7 @@ fn isolate(layers: &mut [Layer], id: LayerId) -> bool { /// Histograms of `doc` as seen by `source`, in the channels of `space`. pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms { + let mut level = 0; let mut h = vec![ [0u32; 256]; match space { @@ -100,14 +104,23 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) } } HistSource::BelowLayer(_) | HistSource::Layer(_) => { - composite_bins(doc, source, space, &mut h); + level = composite_bins(doc, source, space, &mut h); } } - Histograms { tag: tag(source, space), ch: h } + Histograms { tag: tag(source, space), ch: h, level } } /// Bins of the composited image below (`BelowLayer`) or of one layer alone (`Layer`), in `space`. -fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) { +/// Returns the cache level counted: 1 when every document pixel was counted, higher when a +/// downsampled proxy stood in (like Photoshop's histogram cache levels). +/// +/// The pixels are counted as they are, never blended: an area-averaged thumbnail invented greys +/// that aren't in the image wherever an edge fell inside one of its cells (#6538). Documents up to +/// [`photocraft_compose::PROXY_THUMBNAIL_PIXELS`] (and those a proxy can't reduce faithfully) are +/// composited in bands and counted exactly, which costs what the thumbnail did (it reduced the +/// same full composite). Larger ones count a nearest-neighbour proxy composite of about the old +/// thumbnail's work: it only holds values the layers have. +fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) -> u32 { let mut d = doc.clone(); match source { HistSource::BelowLayer(hide) => { @@ -120,33 +133,87 @@ fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut } HistSource::Channel { .. } => {} } - let img = photocraft_compose::thumbnail(&d, 384); + let longest = d.size.width.max(d.size.height); + let k = longest / 768; + let (d, level) = if k >= 2 && d.size.area() > photocraft_compose::PROXY_THUMBNAIL_PIXELS && photocraft_compose::proxy::proxy_faithful(&d) { + (photocraft_compose::proxy::proxy_document(&d, k), k.next_power_of_two().ilog2() + 1) + } else { + (d, 1) + }; + let _ = photocraft_compose::render_bands(&d, d.bounds(), 0, |band| -> Result<(), ()> { + #[cfg(not(target_arch = "wasm32"))] + let part = { + use rayon::prelude::*; + band.px + .par_chunks(64 * 1024) + .map(|px| { + let mut part = vec![[0u32; 256]; h.len()]; + count_pixels(px, space, &mut part); + part + }) + .reduce(|| vec![[0u32; 256]; h.len()], add_bins) + }; + #[cfg(target_arch = "wasm32")] + let part = { + let mut part = vec![[0u32; 256]; h.len()]; + count_pixels(&band.px, space, &mut part); + part + }; + for (dst, src) in h.iter_mut().zip(&part) { + for (a, b) in dst.iter_mut().zip(src) { + *a = a.saturating_add(*b); + } + } + Ok(()) + }); + level +} + +/// Bin-wise sum of two sets of histograms (saturating: a hostile canvas can exceed `u32`). +#[cfg(not(target_arch = "wasm32"))] +fn add_bins(mut a: Vec<[u32; 256]>, b: Vec<[u32; 256]>) -> Vec<[u32; 256]> { + for (dst, src) in a.iter_mut().zip(&b) { + for (x, y) in dst.iter_mut().zip(src) { + *x = x.saturating_add(*y); + } + } + a +} + +/// Counts straight-alpha composite pixels into `h` (one histogram per channel of `space`); +/// transparent pixels are skipped. +fn count_pixels(px: &[[f32; 4]], space: ToneSpace, h: &mut [[u32; 256]]) { let bin = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize; - for p in img.pixels.as_chunks::<4>().0 { - if p[3] == 0 { + let mut add = |c: usize, b: usize| { + if let Some(n) = h.get_mut(c).and_then(|ch| ch.get_mut(b)) { + *n = n.saturating_add(1); + } + }; + for p in px { + // Skips NaN alpha too. + if p[3].is_nan() || p[3] <= 0.0 { continue; } match space { ToneSpace::Rgb => { - for c in 0..3 { - h[c + 1][p[c] as usize] += 1; + let q = [p[0], p[1], p[2]].map(bin); + for (c, v) in q.iter().enumerate() { + add(c + 1, *v); } - h[0][((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as usize] += 1; + add(0, (q[0] + q[1] + q[2]) / 3); } ToneSpace::Cmyk => { - let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0); - let ink = photocraft_color::convert::rgb_to_cmyk(rgb); + let ink = photocraft_color::convert::rgb_to_cmyk([p[0], p[1], p[2]].map(|v| v.clamp(0.0, 1.0))); for (c, v) in ink.iter().enumerate() { - h[c + 1][bin(1.0 - v)] += 1; + add(c + 1, bin(1.0 - v)); } - h[0][bin(1.0 - ink.iter().sum::() / 4.0)] += 1; + add(0, bin(1.0 - ink.iter().sum::() / 4.0)); } ToneSpace::Lab => { - let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0); - let l = photocraft_color::convert::srgb_to_lab(rgb); - h[0][bin(l[0] / 100.0)] += 1; - h[1][bin((l[1] + 128.0) / 255.0)] += 1; - h[2][bin((l[2] + 128.0) / 255.0)] += 1; + let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]].map(|v| v.clamp(0.0, 1.0))); + add(0, bin(l[0] / 100.0)); + add(1, bin((l[1] + 128.0) / 255.0)); + add(2, bin((l[2] + 128.0) / 255.0)); } } } @@ -351,9 +418,8 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } let Some((_, _, _, h)) = app.doc_hist.clone() else { return }; - let size = app.session.active().map_or(photocraft_doc::Size::new(0, 0), |s| s.doc.size); - // Statistics come from a downsampled cache (like Photoshop's cache levels). - let level = (size.width.max(size.height) as f32 / 384.0).max(1.0).log2().ceil() as u32 + 1; + // 1 when the statistics count every pixel; higher when a downsampled proxy stood in. + let level = h.level.max(1); let ch = channel_picker(ui, egui::Id::new("histogram-panel-ch"), "Channel:"); let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 100.0), Sense::hover()); @@ -383,7 +449,7 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { (tl!("Mean:"), format!("{mean:.2}")), (tl!("Std Dev:"), format!("{:.2}", var.sqrt())), (tl!("Median:"), median.unwrap_or(0).to_string()), - (tl!("Pixels:"), (size.width as u64 * size.height as u64).to_string()), + (tl!("Pixels:"), n.to_string()), (tl!("Cache Level:"), level.to_string()), ] { ui.label(egui::RichText::new(k).color(t.text_dim).size(11.5)); @@ -486,6 +552,82 @@ mod tests { let tiny = (h[128] as f32 / top).min(1.0); assert!(tiny < 0.05, "tiny bin draws near-flat, got {tiny}"); } + + /// A white document with black filled over columns `0..split` (a layer above the background). + fn split_doc(w: u32, h: u32, split: u32, depth: Option) -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + let mut p = json!({"width": w, "height": h}); + if let Some(bits) = depth { + p["depth"] = json!(bits); + } + s.execute("file.new", p).unwrap(); // white + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": split, "height": h})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s + } + + fn panel_histograms(s: &photocraft_engine::Session) -> Histograms { + compute_histograms(&s.active().unwrap().doc, HistSource::BelowLayer(LayerId(u64::MAX)), ToneSpace::Rgb) + } + + fn mean(h: &[u32; 256]) -> f64 { + let n: u64 = h.iter().map(|v| u64::from(*v)).sum(); + h.iter().enumerate().map(|(i, v)| i as f64 * f64::from(*v)).sum::() / n as f64 + } + + /// #6538: a black/white split counts only black and white, wherever the edge falls (the old + /// 384 px area-averaged thumbnail turned the cell holding the edge mid-grey), and counts + /// every pixel: cache level 1. + #[test] + fn split_image_histogram_counts_only_black_and_white() { + for (w, h, split) in [(1000, 1000, 334), (1000, 1000, 500), (1000, 600, 1), (777, 333, 389), (2000, 50, 1999), (64, 64, 32)] { + let s = split_doc(w, h, split, None); + let hist = panel_histograms(&s); + assert_eq!(hist.level, 1, "{w}x{h}: every pixel is counted"); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "{w}x{h} split {split}, channel {c}: invented values {stray:?}"); + assert_eq!(u64::from(hist[c][0]), u64::from(split) * u64::from(h), "{w}x{h} split {split}: black pixels"); + assert_eq!(u64::from(hist[c][255]), u64::from(w - split) * u64::from(h), "{w}x{h} split {split}: white pixels"); + } + } + let half = panel_histograms(&split_doc(1000, 1000, 500, None)); + assert!((mean(&half[0]) - 127.5).abs() < 1e-9, "a 50/50 split has mean 127.5, got {}", mean(&half[0])); + } + + /// The same at 16 and 32 bits per channel: the count does not go through an 8-bit thumbnail. + #[test] + fn split_image_histogram_is_exact_at_high_bit_depths() { + for bits in [16, 32] { + let s = split_doc(1000, 400, 334, Some(bits)); + assert_eq!(s.active().unwrap().doc.depth.bits(), bits, "the document is {bits}-bit"); + let hist = panel_histograms(&s); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "{bits}-bit channel {c}: invented values {stray:?}"); + assert_eq!(hist[c][0], 334 * 400); + assert_eq!(hist[c][255], 666 * 400); + } + } + } + + /// Above the exact-count size a nearest-neighbour proxy stands in (cache level > 1): it is + /// smaller, yet still only holds black and white. + #[test] + fn large_split_image_counts_a_nearest_proxy_without_inventing_greys() { + let s = split_doc(6000, 3000, 2001, None); + let hist = panel_histograms(&s); + assert!(hist.level > 1, "a proxy stood in, level {}", hist.level); + let n: u64 = hist[0].iter().map(|v| u64::from(*v)).sum(); + assert!(n > 0 && n < 6000 * 3000, "counted {n} proxy pixels"); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "channel {c}: invented values {stray:?}"); + } + assert!((mean(&hist[0]) - 255.0 * 3999.0 / 6000.0).abs() < 1.0, "mean {}", mean(&hist[0])); + } } #[cfg(test)] From bf321f0f5e1b7487d739017564f58bb5b93f1c1b Mon Sep 17 00:00:00 2001 From: Perchenko Anton <101743951+PerchAnton@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:04:32 +0300 Subject: [PATCH 209/304] Affinity: bound compound geometry expansion and reject cycles (#6486) * fix(affinity): bound compound geometry expansion and reject cycles * fix(affinity): charge every compound operand slot before filtering --- crates/affinity/README.md | 7 + crates/affinity/src/geometry.rs | 110 ++++++++++-- crates/affinity/src/model.rs | 12 +- crates/affinity/tests/compound_limits.rs | 212 +++++++++++++++++++++++ 4 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 crates/affinity/tests/compound_limits.rs diff --git a/crates/affinity/README.md b/crates/affinity/README.md index e881352c8b..535c3736c6 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -144,6 +144,13 @@ stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. +Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across +the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot +visits (including nulls and non-object values), and four million points created or copied into +compounds. Operand slots are charged before filtering, without first collecting an operand list. +A compound that crosses a limit or contains a cycle is omitted +with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform. + ## Privacy Affinity documents can hold the folder they were saved in, user names, original image paths and diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 08554134db..6c8ea602c9 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value}; /// Nodes per curve object. const MAX_NODES: usize = 4_000_000; +const MAX_COMPOUND_DEPTH: usize = 64; +const MAX_OUTLINE_VISITS: usize = 500_000; +const MAX_OUTLINE_POINTS: usize = 4_000_000; + +/// Geometry is expanded per occurrence, since parametric outlines can depend on their world +/// transform. Bound work across the entire document instead of memoizing by object id alone. +pub(crate) struct Budget { + visits_left: usize, + points_left: usize, +} + +impl Default for Budget { + fn default() -> Self { + Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS } + } +} + +impl Budget { + /// Charge an outline call or a raw compound operand slot before inspecting it. + fn visit(&mut self) -> Result<(), &'static str> { + self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?; + Ok(()) + } + + /// Charge both leaf geometry and each copy into a compound before allocating that copy. + fn path(&mut self, path: &Path) -> Result<(), &'static str> { + let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3))); + let Some(left) = self.points_left.checked_sub(points) else { + // Do not rebuild an expensive leaf for every subsequent root after exhaustion. + self.visits_left = 0; + return Err("compound geometry budget exhausted"); + }; + self.points_left = left; + Ok(()) + } +} /// The node-space outline of a curve, shape or compound node, and whether it fills even-odd. /// Instances (master-page or symbol copies) take the geometry of their master. pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { + match outline_inner(r, id, class, world, &mut Vec::new()) { + Ok(path) => path, + Err(reason) => { + r.warn(reason); + None + } + } +} + +fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec) -> Result, &'static str> { + r.geometry_budget.visit()?; + if active.contains(&id) { + return Err("cyclic compound shape"); + } + // Independent of the layer-tree limit: Comp operands do not pass through Reader::node. + if active.len() >= MAX_COMPOUND_DEPTH { + return Err("compound depth exceeds 64 levels"); + } + if class != Tag::of(b"Comp") { + let path = leaf_outline(r, id, class, world); + if let Some((path, _)) = &path { + r.geometry_budget.path(path)?; + } + return Ok(path); + } + active.push(id); + let result = compound(r, id, world, active); + active.pop(); + result +} + +fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec) -> Result, &'static str> { + let s = r.s; + // One path of all operands' contours, filled even-odd, as before. A safety failure rejects + // the whole outline: a partial add/subtract shape could silently change its meaning. + let mut path = Path::default(); + // Charge every slot before filtering nulls or malformed values. Stream::objs would scan + // and allocate the whole list first, repeating uncharged work for each shared occurrence. + let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] }; + for child in children { + r.geometry_budget.visit()?; + let Value::Obj(Some(c)) = child else { continue }; + let c = *c; + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let Some(class) = s.class(c) else { return Ok(None) }; + if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { + r.warn("compound shapes that intersect or exclude (imported as add)"); + } + if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? { + r.geometry_budget.path(&p)?; + path.subpaths.extend(p.transformed(local).subpaths); + } + } + Ok(Some((path, true))) +} + +fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { let s = r.s; match &class.0.to_be_bytes() { b"PCrv" | b"TxtC" => { @@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O let multi = path.subpaths.len() > 1; Some((path, multi)) } - b"Comp" => { - // One path of all the operands' contours in the compound's space, filled even-odd: this - // matches add and subtract operands in every public sample; a true union of overlapping - // added operands would differ. - let mut path = Path::default(); - for c in s.objs(id, b"Chld") { - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let class = s.class(c)?; - if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { - r.warn("compound shapes that intersect or exclude (imported as add)"); - } - if let Some((p, _)) = outline(r, c, class, local.then(world)) { - path.subpaths.extend(p.transformed(local).subpaths); - } - } - Some((path, true)) - } _ => { let (shape, box_owner) = match s.obj(id, b"Shpe") { Some(sh) => (sh, id), diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 7fba5a4597..7c05072419 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -298,6 +298,7 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, active: HashSet, + pub(crate) geometry_budget: crate::geometry::Budget, /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so /// far hold). pixels_left: usize, @@ -305,7 +306,16 @@ pub(crate) struct Reader<'s, 'a, 'b> { impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes } + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + nodes: 0, + active: HashSet::new(), + geometry_budget: crate::geometry::Budget::default(), + pixels_left: limits.max_pixel_bytes, + } } /// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's diff --git a/crates/affinity/tests/compound_limits.rs b/crates/affinity/tests/compound_limits.rs new file mode 100644 index 0000000000..76f2e6e4a1 --- /dev/null +++ b/crates/affinity/tests/compound_limits.rs @@ -0,0 +1,212 @@ +//! #6423: compound operands form a graph, not necessarily a tree. +#![cfg(feature = "synth")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use photocraft_affinity::model::{Document, Kind}; +use photocraft_affinity::synth::{self, F, Method, tag}; +use photocraft_affinity::{Limits, read}; +use std::time::{Duration, Instant}; + +fn compound(id: u32, children: Vec) -> F { + F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))]) +} + +fn rectangle() -> F { + F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))]) +} + +fn document_bytes(definitions: Vec, roots: Vec) -> Vec { + let data = synth::stream(&[ + (tag(b"Defs"), F::Shared(definitions)), + ( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))], + )]), + )], + ), + ), + ]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) +} + +fn document(definitions: Vec, roots: Vec) -> Document { + read(&document_bytes(definitions, roots), Limits::default()).unwrap() +} + +// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang) +// inside a child process, so the regression fails without killing the entire test runner. +fn isolated(name: &str, check: impl FnOnce()) { + if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) { + check(); + return; + } + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--nocapture"]) + .env("PHOTOCRAFT_COMPOUND_CASE", name) + .spawn() + .unwrap(); + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "compound import failed: {status}"); + break; + } + if start.elapsed() > Duration::from_secs(10) { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("compound import exceeded ten seconds"); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn unsupported(doc: &Document, warning: &str) { + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings); +} + +#[test] +fn self_referencing_compound_is_reported_without_aborting() { + isolated("self_referencing_compound_is_reported_without_aborting", || { + let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]); + unsupported(&doc, "cyclic compound"); + assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes"); + }); +} + +#[test] +fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() { + isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || { + let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]); + unsupported(&doc, "cyclic compound"); + }); +} + +fn nested(levels: u32, repeated: bool) -> Document { + // Flat definitions bypass the stream parser's nesting cap: only references form the depth. + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..200 + levels { + let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] }; + definitions.push(compound(id, children)); + previous = id; + } + document(definitions, vec![F::Ref(previous)]) +} + +#[test] +fn deep_compound_graph_is_bounded_on_a_small_stack() { + isolated("deep_compound_graph_is_bounded_on_a_small_stack", || { + std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap(); + }); +} + +#[test] +fn shared_operands_cannot_expand_exponentially() { + isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget")); +} + +#[test] +fn shared_empty_operands_also_have_a_work_limit() { + isolated("shared_empty_operands_also_have_a_work_limit", || { + let mut definitions = vec![compound(100, vec![])]; + let mut previous = 100; + for id in 200..240 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget"); + }); +} + +#[test] +fn geometry_budget_is_shared_across_layer_roots() { + isolated("geometry_budget_is_shared_across_layer_roots", || { + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..214 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + let mut roots = vec![F::Ref(previous); 12]; + roots.push(F::Ref(100)); + let doc = document(definitions, roots); + let nodes = &doc.spreads[0].nodes; + assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports"); + assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget"); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn ordinary_shared_operands_keep_both_transformed_copies() { + let mut translated = compound(201, vec![F::Ref(100)]); + let F::Def(_, _, fields) = &mut translated else { unreachable!() }; + fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0]))); + let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) { + assert_eq!(b.x, a.x + 30.0); + assert_eq!(b.y, a.y + 40.0); + } +} + +#[test] +fn null_operand_slots_cannot_bypass_geometry_budget() { + isolated("null_operand_slots_cannot_bypass_geometry_budget", || { + let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]); + let start = Instant::now(); + let doc = read(&bytes, Limits::default()).unwrap(); + eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64()); + unsupported(&doc, "compound geometry budget"); + assert_eq!(doc.spreads[0].nodes.len(), 20_000); + assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported))); + }); +} + +#[test] +fn null_operand_slots_share_budget_across_layer_roots() { + isolated("null_operand_slots_share_budget_across_layer_roots", || { + let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]); + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid"); + assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn null_operand_slots_cannot_leave_a_partial_shape() { + isolated("null_operand_slots_cannot_leave_a_partial_shape", || { + let mut slots = vec![F::Ref(100)]; + slots.extend(vec![F::Null; 1_000_000]); + let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]); + unsupported(&doc, "compound geometry budget"); + }); +} + +#[test] +fn non_object_operand_slots_also_consume_budget() { + isolated("non_object_operand_slots_also_consume_budget", || { + let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]); + unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget"); + }); +} + +#[test] +fn ordinary_null_operand_slots_preserve_shared_contours() { + let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[0], path.subpaths[1]); +} From ac78e0bbcad7111a542f3ac890825a2e97237d5e Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:04:38 -0400 Subject: [PATCH 210/304] Color Settings: the working-space menus list the installed profiles of their space (#6323) (#6359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Color Settings: the working-space menus list the installed profiles of their space (#6323) Like Convert to Profile and Proof Setup, Edit › Color Settings' RGB, CMYK and Gray working-space menus now list the profiles installed in the system after the built-ins, by description, so a press condition (GRACoL, SWOP, FOGRA39) can be the CMYK working space as in Photoshop. Each menu lists only profiles of its colour space; RGB lists display and colour-space profiles, not scanner or printer ones. The engine already took .icc paths for these settings. Co-Authored-By: Claude Opus 5.5 * rustfmt: format after merging main (CI Format step) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/filter_dialog.rs | 65 +++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index d09caf4953..14f24bc52e 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use photocraft_doc::Document; use photocraft_engine::color_cmds::ColorSettings; +use photocraft_engine::installed_profiles::InstalledProfile; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -632,6 +633,29 @@ fn channel_index(v: &Value, names: &[String]) -> Option { /// Schema dialogs whose `profile` menu lists the installed profiles after the built-ins. const PROFILE_DIALOGS: &[&str] = &["edit.assignProfile", "edit.convertToProfile", "view.proofSetup"]; +/// Which installed profiles a menu of `cmd` lists after its built-ins, if it lists any: every +/// destination profile for the `profile` menus of [`PROFILE_DIALOGS`], and for Color Settings' +/// working spaces those of that space, as Photoshop lists them (#6323). An RGB working space is a +/// display or colour-space profile, not a scanner's or a printer's. +fn installed_filter(cmd: &str, key: &str) -> Option bool> { + use photocraft_cms::{ColorSpace, ProfileClass}; + match (cmd, key) { + (c, "profile") if PROFILE_DIALOGS.contains(&c) => Some(|_| true), + ("edit.colorSettings", "workingRgb") => { + Some(|i| i.color_space == ColorSpace::Rgb && matches!(i.class, ProfileClass::Display | ProfileClass::ColorSpace)) + } + ("edit.colorSettings", "workingCmyk") => Some(|i| i.color_space == ColorSpace::Cmyk), + ("edit.colorSettings", "workingGray") => Some(|i| i.color_space == ColorSpace::Gray), + _ => None, + } +} + +/// The `installed` profiles a menu of `cmd` lists (see [`installed_filter`]), by path and description. +fn installed_choices(cmd: &str, key: &str, installed: &[InstalledProfile]) -> Vec<(String, String)> { + let Some(fits) = installed_filter(cmd, key) else { return Vec::new() }; + installed.iter().filter(|i| i.is_destination() && fits(i)).map(|i| (i.path.clone(), i.description.clone())).collect() +} + /// Commands whose `angle` is a direction in document angles (counter-clockwise from three /// o'clock): a light or a blur direction, edited on a dial as in Photoshop's Motion Blur, Emboss /// and Global Light (#3152). Rotation amounts (Twirl, Rotate Arbitrary, Crop, Lens Correction, @@ -819,14 +843,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { cur = channel_choice(i); } } - // Like Photoshop, the profile menus also list every profile installed in - // the system (printer and paper profiles), by description. - let installed = if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) { - photocraft_engine::installed_profiles::installed().into_iter().filter(|i| i.is_destination()).collect() - } else { - Vec::new() - }; - opts.extend(installed.iter().map(|i| (i.path.clone(), i.description.as_str()))); + // Like Photoshop, the profile menus also list the profiles installed in the + // system (printer and paper profiles, press conditions), by description. + let lists_installed = installed_filter(&cmd, &p.key).is_some(); + let installed = + if lists_installed { installed_choices(&cmd, &p.key, &photocraft_engine::installed_profiles::installed()) } else { Vec::new() }; + opts.extend(installed.iter().map(|(path, description)| (path.clone(), description.as_str()))); // Assign Profile: only what the document's colour mode can take (#4951). if p.key == "profile" && let Some(allowed) = f.get("__profiles").and_then(Value::as_array) @@ -836,7 +858,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // A profile given by path that isn't listed (typed by an agent, or spelled with // different case) still shows, by its file name, instead of a blank menu. let stem = std::path::Path::new(&cur).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) && !opts.iter().any(|(v, _)| *v == cur) { + if lists_installed && !opts.iter().any(|(v, _)| *v == cur) { opts.push((cur.clone(), stem.as_str())); } crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0); @@ -1150,6 +1172,31 @@ impl FilterPreviewKey { mod tests { use super::*; + #[test] + fn color_settings_working_spaces_list_installed_profiles_of_their_space() { + // #6323: a press condition installed in the system can be the CMYK working space, as in + // Photoshop's Color Settings; each working-space menu lists only profiles of its space. + use photocraft_cms::{ColorSpace, ProfileClass}; + let p = |path: &str, class, color_space| InstalledProfile { path: path.into(), description: path.to_uppercase(), class, color_space }; + let installed = [ + p("gracol.icc", ProfileClass::Output, ColorSpace::Cmyk), + p("monitor.icc", ProfileClass::Display, ColorSpace::Rgb), + p("printer-rgb.icc", ProfileClass::Output, ColorSpace::Rgb), + p("scanner.icc", ProfileClass::Input, ColorSpace::Rgb), + p("gray.icc", ProfileClass::Output, ColorSpace::Gray), + p("link.icc", ProfileClass::DeviceLink, ColorSpace::Cmyk), + ]; + let paths = |key: &str| installed_choices("edit.colorSettings", key, &installed).into_iter().map(|(path, _)| path).collect::>(); + assert_eq!(paths("workingCmyk"), ["gracol.icc"]); + assert_eq!(paths("workingRgb"), ["monitor.icc"]); + assert_eq!(paths("workingGray"), ["gray.icc"]); + assert!(paths("policyCmyk").is_empty() && paths("intent").is_empty()); + assert_eq!(installed_choices("edit.colorSettings", "workingCmyk", &installed)[0].1, "GRACOL.ICC", "listed by description"); + // The profile dialogs keep listing every destination profile. + assert_eq!(installed_choices("edit.convertToProfile", "profile", &installed).len(), 5); + assert!(installed_choices("filter.blur.gaussianBlur", "profile", &installed).is_empty()); + } + /// #5710: the seed field's Random button always gives a different seed the field can hold. #[test] fn random_seeds_stay_in_range_and_change() { From bc4f2a783d15e20c1d147e5593c03d10984d8348 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:46 +0200 Subject: [PATCH 211/304] Scripts: Flatten All Layer Effects bakes with the document's Global Light and patterns (#5719) (#5720) `bake` rendered each layer through `render_layer`, which has no document context (default Global Light, no patterns, the render rect as canvas). It now uses the new doc-aware `render_layer_in`. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 25 ++++++++++ crates/engine/src/file_cmds.rs | 20 ++++---- crates/engine/src/file_cmds/tests.rs | 68 ++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3cf2f1dc19..e35d5ad402 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -349,6 +349,31 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { buf } +/// [`render_layer`] in `doc`'s context: the layer alone over `rect`, with the document's patterns, +/// Global Light, colour mode, depth and canvas (fill layers and gradients are laid out on it), as +/// the canvas composites it. Bakes (Scripts › Flatten All Layer Effects) render through this so +/// the pixels match what was on screen (#5719). Rects past [`MAX_RENDER_PIXELS`] give an empty +/// buffer. +pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer { + if rect.is_empty() { + return Buffer::transparent(rect); + } + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + // A lone layer: a deep knockout reveals transparency, not the document's Background. + let cx = Ctx { doc: None, ..Ctx::for_doc(doc, &patterns) }; + let lab = doc.mode == photocraft_color::ColorMode::Lab; + let hdr = doc.depth == photocraft_color::SampleType::F32; + let cmyk = cmyk_space(doc); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let mut buf = Buffer::transparent(rect); + in_blend_space(lab, hdr, || composite_stack_at(std::slice::from_ref(layer), &mut buf, &cx, advanced::Scope::ROOT)); + buf + }) +} + /// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is /// faithful; smaller ones are reduced from the exact composite. pub const PROXY_THUMBNAIL_PIXELS: u64 = 16 << 20; diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 1fb3fcf300..2299aa3e49 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1293,10 +1293,12 @@ fn has_live_effects(l: &Layer) -> bool { l.effects.enabled && l.effects.items.iter().any(|e| e.enabled()) } -/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. With `apply_mask` -/// off the layer's pixel and vector masks are left out of the render, so a caller that keeps them -/// live on the baked raster does not mask the pixels twice. -fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mask: bool) -> Surface { +/// The layer of `doc` rendered alone (keeping what `keep` says) with the document's Global Light, +/// patterns and canvas, as a surface in the document's format. With `apply_mask` off the layer's +/// pixel and vector masks are left out of the render, so a caller that keeps them live on the +/// baked raster does not mask the pixels twice. +fn bake(doc: &Document, l: &Layer, keep_effects: bool, apply_mask: bool) -> Surface { + let canvas = doc.bounds(); let mut tmp = l.clone(); tmp.opacity = 1.0; tmp.blend = photocraft_color::BlendMode::Normal; @@ -1313,7 +1315,7 @@ fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mas // Render over the union of the canvas and the content so off-canvas pixels survive. let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas)); let area = if keep_effects { area.inflate(256) } else { area }; - buffer_surface(&photocraft_compose::render_layer(&tmp, area), fmt) + buffer_surface(&photocraft_compose::render_layer_in(doc, &tmp, area), doc.pixel_format()) } fn walk_ids(doc: &Document) -> Vec { @@ -1323,8 +1325,6 @@ fn walk_ids(doc: &Document) -> Vec { fn flatten_all_effects(s: &mut Session) -> Result { let mut n = 0; s.edit("Flatten All Layer Effects", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) { @@ -1332,7 +1332,7 @@ fn flatten_all_effects(s: &mut Session) -> Result { } // The pixel and vector masks stay on the layer (baking must not destroy them), so the // pixels are baked without them and each mask then applies once to the baked raster. - let px = bake(l, canvas, fmt, true, false); + let px = bake(doc, l, true, false); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.effects = Default::default(); @@ -1349,8 +1349,6 @@ fn flatten_all_effects(s: &mut Session) -> Result { fn flatten_all_masks(s: &mut Session) -> Result { let (mut n, mut skipped) = (0, 0); s.edit("Flatten All Masks", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; let masked = l.mask.as_ref().is_some_and(|m| m.enabled) || l.vector_mask.is_some(); @@ -1363,7 +1361,7 @@ fn flatten_all_masks(s: &mut Session) -> Result { skipped += 1; continue; } - let px = bake(l, canvas, fmt, false, true); + let px = bake(doc, l, false, true); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.mask = None; diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3ab50c4a77..b07c649850 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -747,6 +747,74 @@ fn flatten_all_layer_effects_keeps_masks() { } } +/// Flattens all layer effects on a red square carrying `fx` (in a document prepared by `setup`) +/// and asserts the composite did not change: the bake uses the document's own context (#5719). +fn assert_flatten_effects_is_a_no_op(depth: u32, setup: impl FnOnce(&mut Document), fx: photocraft_doc::Effect) { + let mut s = session(40, 40, depth); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 14, "y": 14, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("fx", |doc, _| { + setup(doc); + let l = doc.layer_mut(id).unwrap(); + l.effects.enabled = true; + l.effects.items.push(fx); + Ok(()) + }) + .unwrap(); + let before = photocraft_compose::flatten(doc(&s)); + assert_eq!(s.execute("file.scripts.flattenAllLayerEffects", json!({})).unwrap()["flattened"], 1); + let after = photocraft_compose::flatten(doc(&s)); + for (i, (a, b)) in after.px.iter().zip(&before.px).enumerate() { + for k in 0..4 { + assert!((a[k] - b[k]).abs() < 0.02, "depth {depth}: pixel ({}, {}) changed: {b:?} -> {a:?}", i % 40, i / 40); + } + } +} + +/// A Drop Shadow that uses Global Light is baked at the document's angle, not the default 120°. +#[test] +fn flatten_all_layer_effects_uses_the_documents_global_light() { + for depth in [8, 16, 32] { + let shadow = photocraft_doc::Effect::DropShadow(photocraft_doc::effects::Shadow { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::rgb(0.0, 0.0, 1.0), + angle: 120.0, + use_global_light: true, + distance: 10.0, + spread: 0.0, + size: 0.0, + contour: photocraft_doc::effects::Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + }); + assert_flatten_effects_is_a_no_op(depth, |d| d.global_light.angle = 0.0, shadow); + } +} + +/// A Pattern Overlay of a document pattern survives the bake instead of vanishing. +#[test] +fn flatten_all_layer_effects_keeps_a_pattern_overlay() { + for depth in [8, 16, 32] { + let mut tile = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Rgb, photocraft_color::SampleType::U8, true)); + tile.fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 1.0, 0.0, 1.0]); + let pat = photocraft_doc::Pattern::new("green", tile, 2, 2); + let overlay = photocraft_doc::Effect::PatternOverlay { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + name: pat.name.clone(), + id: pat.id.clone(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }; + assert_flatten_effects_is_a_no_op(depth, |d| d.patterns.push(pat), overlay); + } +} + #[test] fn layers_to_files_writes_each_visible_layer() { let dir = tmp("layers"); From 5c1b9425d132bdf3075f716529834429cc9cc2f5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:04:52 +0200 Subject: [PATCH 212/304] Gradient Overlay: repaint the whole layer when a stroke moves its frame (#4053) (#5340) A Gradient Overlay (and gradient strokes, linked patterns, linked bevel textures) is laid out over the layer's bounds. Painting grows those bounds, but the stroke commands reported only the stroke as damage, and the canvas's committed-stroke path repainted only what the live stroke had not shown. The canvas therefore kept the old gradient around earlier strokes: one gradient per stroke instead of one over the whole layer. Session dispatch now grows a command's reported damage by photocraft_compose::relayout_damage(before, after): the whole composite of any visible layer whose effects span its frame, when that frame moved. The canvas adds the same area to a committed live stroke. Strokes inside the existing bounds and frame-independent effects keep tight damage. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/effects.rs | 19 ++++ crates/compose/src/lib.rs | 54 +++++++---- crates/compose/src/tests.rs | 28 ++++++ crates/engine/src/jobs.rs | 17 ++++ .../engine/tests/gradient_overlay_strokes.rs | 89 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 29 ++++-- crates/ui-egui/src/stroke_timing_tests.rs | 32 +++++++ 7 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 crates/engine/tests/gradient_overlay_strokes.rs diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 5acdd79314..20657a2535 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -37,6 +37,25 @@ pub fn has_effects(layer: &Layer) -> bool { layer.effects.enabled && layer.effects.items.iter().any(Effect::enabled) } +/// Whether an enabled effect is laid out over the layer's frame ([`crate::effects_frame`]), so +/// growing or shrinking the layer repaints it everywhere, not only where its pixels changed: a +/// Gradient Overlay, a gradient stroke (its frame is the shape's extent), and patterns linked with +/// the layer without an effects reference point (overlays, pattern strokes and glows, and bevel +/// textures tile from the frame's top-left). +pub fn spans_frame(layer: &Layer) -> bool { + let unanchored = layer.effects.reference.is_none(); + let pattern = |p: &FxPaint| unanchored && matches!(p, FxPaint::Pattern { .. }); + has_effects(layer) + && layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e { + Effect::GradientOverlay { .. } => true, + Effect::PatternOverlay { link, .. } => *link && unanchored, + Effect::Stroke(s) => matches!(s.paint, FxPaint::Gradient(_)) || pattern(&s.paint), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => pattern(&g.paint), + Effect::BevelEmboss(b) => unanchored && b.texture.as_ref().is_some_and(|t| t.link), + _ => false, + }) +} + /// Pixels an effect stack can reach beyond the layer's shape. pub fn margin(layer: &Layer) -> i32 { let mut m = 0.0f32; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e35d5ad402..7c0ce9098e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -890,6 +890,38 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } +/// The frame a layer's effects are laid out in: [`paint_bounds`] for a shape, else the layer's +/// bounds ([`layer_bounds`]). A Gradient Overlay spans it, linked patterns tile from its corner. +pub fn effects_frame(layer: &Layer, canvas: Rect) -> Rect { + paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas)) +} + +/// The pixels a change from `a` to `b` repaints beyond those it changed: where a layer whose +/// effects span its frame ([`effects::spans_frame`]) moved that frame, the layer's whole composite +/// before and after. Painting on a layer with a Gradient Overlay lays the one gradient out again +/// over the grown layer, as Photoshop does after each stroke; damage that covers only the stroke +/// leaves the old gradient around earlier strokes (#4053). Empty when no such frame moved. +pub fn relayout_damage(a: &Document, b: &Document) -> Rect { + let mut out = Rect::EMPTY; + let mut add = |r: Rect| { + if !r.is_empty() { + out = if out.is_empty() { r } else { out.union(&r) }; + } + }; + for (_, _, y) in b.walk() { + if !y.visible || !effects::spans_frame(y) { + continue; + } + let Some(x) = a.layer(y.id) else { continue }; + if effects_frame(x, a.bounds()) == effects_frame(y, b.bounds()) { + continue; + } + add(change_bounds(x, a.bounds()).unwrap_or_else(|| a.bounds())); + add(change_bounds(y, b.bounds()).unwrap_or_else(|| b.bounds())); + } + out +} + /// The bounding box of a shape layer's vector content: its path control bounds plus stroke width. pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { @@ -1514,15 +1546,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff advanced::composite_clipped(clipped, &mut content, cx); let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - backdrop, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); } /// The coverage-mixing gamma of a layer: the text blending gamma @@ -1780,7 +1804,7 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s } fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> { - let sb = paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)); + let sb = effects_frame(layer, cx.canvas); effects::TextureCtx { rect: region, patterns: cx.patterns.source(), anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) } } @@ -1835,15 +1859,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - &mut opaque, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); if let Some(m) = &hide { let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; advanced::apply_hiding_mask(&mut opaque, &plain, m); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ddd4871548..bd7d3ceba7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1082,6 +1082,34 @@ fn glow_gradient_follows_the_shape() { assert!(edge[0] > 0.8 && edge[2] < 0.2, "{edge:?}"); } +// A Gradient Overlay spans the layer's frame: growing the layer repaints all of it, not just the +// new pixels (#4053). Effects that don't depend on the frame repaint only what changed. +#[test] +fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { + let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }; + let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| { + let mut b = a.clone(); + let old = b.layers.last().unwrap().clone(); + let mut l = solid_layer("sq", rect, rgba); + (l.id, l.effects) = (old.id, old.effects); + *b.layers.last_mut().unwrap() = l; + b + }; + let a = fx_doc(vec![overlay.clone()]); + let b = grown(&a, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0]); + let d = crate::relayout_damage(&a, &b); + assert!(d.contains_rect(&Rect::new(10, 10, 36, 34)), "{d:?}"); + // Same bounds, other pixels: the frame stays, the pixels' own damage is enough. + assert_eq!(crate::relayout_damage(&a, &grown(&a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0])), Rect::EMPTY); + // A Color Overlay doesn't depend on the frame. + let c = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }]); + assert_eq!(crate::relayout_damage(&c, &grown(&c, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); + // Nor does a disabled overlay. + let mut off = a.clone(); + off.layers.last_mut().unwrap().effects.enabled = false; + assert_eq!(crate::relayout_damage(&off, &grown(&off, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); +} + #[test] fn gradient_overlay_follows_angle_and_reverse() { let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() }; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b30e56da3a..5098b958b4 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -732,6 +732,7 @@ impl Session { // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; + let before = self.active().map(|st| st.doc.clone()); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. @@ -759,12 +760,28 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { + if let Some(before) = before { + self.add_relayout_damage(&before); + } self.after_command(id, params, spec.journal, Some(nested_from)); Ok(Started::Done(v)) } } } + /// Grows the damage a command reported for the pixels it changed by what effects laid out + /// over a layer's frame repaint when it moved ([`photocraft_compose::relayout_damage`]): a + /// stroke on a layer with a Gradient Overlay reports the stroke, but the gradient is laid out + /// again over the whole grown layer (#4053). `before` is the active document before. + fn add_relayout_damage(&mut self, before: &Arc) { + let Some(st) = self.active_mut().filter(|st| st.doc.id == before.id && !Arc::ptr_eq(&st.doc, before)) else { return }; + let Some(d) = st.last_damage.filter(|d| !d.is_empty()) else { return }; + let extra = photocraft_compose::relayout_damage(before, &st.doc); + if !extra.is_empty() { + st.last_damage = Some(d.union(&extra)); + } + } + /// A command that failed on a layer id the document doesn't have says which ids it does have /// (#5058): ids are not positions (an opened PNG's Background is not layer 1), and an agent /// should not need `document.inspect` to recover. diff --git a/crates/engine/tests/gradient_overlay_strokes.rs b/crates/engine/tests/gradient_overlay_strokes.rs new file mode 100644 index 0000000000..e233f8984d --- /dev/null +++ b/crates/engine/tests/gradient_overlay_strokes.rs @@ -0,0 +1,89 @@ +//! A Gradient Overlay spans one gradient over the layer's current bounds, so a stroke that grows +//! the layer re-lays the gradient out over all of it (#4053). The damage a stroke reports must +//! cover that, or a canvas that redraws only the damage keeps the old gradient around the earlier +//! strokes: one gradient per stroke instead of one over the whole layer. + +use photocraft_engine::Session; +use serde_json::json; + +/// What a canvas that redraws only each edit's damage shows: the cold render before the edit, +/// with the edit's damage (grown by the effects' reach, as the canvas grows it) rendered again. +fn redraw_damage(shown: &mut photocraft_compose::Buffer, s: &Session) { + let st = s.active().unwrap(); + let reach = st.doc.layers.iter().filter(|l| photocraft_compose::effects::has_effects(l)).map(photocraft_compose::effects::margin).max().unwrap_or(0); + let damage = st.last_damage.expect("a stroke reports bounded damage").inflate(reach).intersect(&st.doc.bounds()); + let (w, h) = (st.doc.size.width, st.doc.size.height); + let patch = photocraft_compose::render_reduced_damage(&st.doc, w, h, damage); + for y in patch.rect.y0..patch.rect.y1 { + for x in patch.rect.x0..patch.rect.x1 { + let i = (y * w as i32 + x) as usize; + shown.px[i] = patch.get(x, y); + } + } +} + +fn session(effect: &str, params: serde_json::Value) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 160, "height": 80, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute(effect, params).unwrap(); + s +} + +const GREY: &str = "#808080"; + +/// Each stroke redrawn over the last cold render equals a cold render after it. +fn strokes_redraw_like_a_cold_render(mut s: Session, strokes: &[serde_json::Value]) { + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + for (i, p) in strokes.iter().enumerate() { + let st = s.active().unwrap(); + let mut shown = photocraft_compose::render(&st.doc, st.doc.bounds()); + s.execute("paint.stroke", p.clone()).unwrap(); + redraw_damage(&mut shown, &s); + let st = s.active().unwrap(); + let cold = photocraft_compose::render(&st.doc, st.doc.bounds()); + let worst = shown.px.iter().zip(&cold.px).map(|(a, b)| (0..4).map(|c| (a[c] - b[c]).abs()).fold(0.0f32, f32::max)).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "after stroke {i} the redrawn canvas differs from a cold render by {worst}"); + } +} + +#[test] +fn a_gradient_overlay_is_laid_out_again_after_each_stroke() { + let s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + strokes_redraw_like_a_cold_render( + s, + &[ + // Far from the first: the layer grows to the right and down. + json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY}), + // Up and to the left of both. + json!({"points": [[6, 6], [10, 8]], "size": 6, "hardness": 1.0, "color": GREY}), + // Erasing the far stroke shrinks the layer again. + json!({"points": [[110, 40], [150, 74]], "size": 40, "hardness": 1.0, "erase": true}), + ], + ); +} + +#[test] +fn a_stroke_inside_the_layer_keeps_its_own_damage() { + let mut s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + s.execute("paint.stroke", json!({"points": [[10, 10], [150, 70]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + // Within the layer's bounds: the gradient's frame stays, so only the stroke repaints. + let r = s.execute("paint.stroke", json!({"points": [[60, 40], [70, 40]], "size": 6, "hardness": 1.0, "color": GREY})).unwrap(); + let d = &r["damage"]; + let reported = photocraft_geom::Rect::new( + d[0].as_i64().unwrap() as i32, + d[1].as_i64().unwrap() as i32, + (d[0].as_i64().unwrap() + d[2].as_i64().unwrap()) as i32, + (d[1].as_i64().unwrap() + d[3].as_i64().unwrap()) as i32, + ); + assert_eq!(s.active().unwrap().last_damage, Some(reported)); +} + +#[test] +fn effects_not_laid_out_over_the_layer_keep_the_stroke_damage() { + let mut s = session("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})); + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + s.execute("paint.stroke", json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + let d = s.active().unwrap().last_damage.unwrap(); + assert!(d.x0 >= 100, "a Color Overlay repaints only the stroke: {d:?}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db11fc730e..474864746b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -516,12 +516,22 @@ pub(crate) struct CommittedStroke { pub(crate) revision: u64, key: u64, damage: Vec, + /// What the commit repaints beyond the stroke whatever a cache showed: the layer's whole + /// composite when effects laid out over its frame moved with it (a Gradient Overlay spans the + /// grown layer, #4053; `photocraft_compose::relayout_damage`). + relayout: DRect, } impl CommittedStroke { #[cfg(test)] pub(crate) fn for_test(key: u64, damage: Vec) -> Self { - Self { doc: photocraft_doc::DocId(0), revision: 0, key, damage } + Self { doc: photocraft_doc::DocId(0), revision: 0, key, damage, relayout: DRect::EMPTY } + } + + /// The raw preview key of the live stroke's last step: what a cache showed at release. + #[cfg(test)] + pub(crate) fn last_key(&self) -> u64 { + self.key + self.damage.len() as u64 } /// What a cache that showed raw preview `key` of this stroke at the revision before misses. @@ -1472,6 +1482,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no && let Some(c) = app.committed_stroke.as_ref().filter(|c| c.doc == st.doc.id && c.revision == now.0) && let Some(r) = c.since(seen.1 ^ display_key) { + let r = if c.relayout.is_empty() { + r + } else if r.is_empty() { + c.relayout + } else { + r.union(&c.relayout) + }; return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); } if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 { @@ -5845,6 +5862,7 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } } let LiveStroke { stroke, doc, key, damage, .. } = l; + let before = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.doc.clone()); if let EngineStroke::Brush(stroke) = stroke && app.session.offer_live_stroke(*stroke).is_err() { @@ -5855,14 +5873,15 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); app.session.clear_live_stroke(); let Ok(r) = result else { return }; - let revision = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.revision); + let after = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| (st.revision, st.doc.clone())); if r.get("fromLive").and_then(serde_json::Value::as_bool) == Some(true) - && let Some(revision) = revision + && let Some((revision, after)) = after { - app.committed_stroke = Some(CommittedStroke { doc, revision, key, damage }); + let relayout = before.map_or(DRect::EMPTY, |b| photocraft_compose::relayout_damage(&b, &after)); + app.committed_stroke = Some(CommittedStroke { doc, revision, key, damage, relayout }); } else { // Rendered again from scratch: the commit's damage rect refreshes the stroke. - let shown = CommittedStroke { doc, revision: 0, key, damage }; + let shown = CommittedStroke { doc, revision: 0, key, damage, relayout: DRect::EMPTY }; // Raw preview key 0 = the document itself (its colour display folded in). shown_as_document(app, doc, |k| shown.since(k).is_some()); } diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index a472a4125c..88f218c3ba 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -338,6 +338,38 @@ fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() { assert_eq!(c.since(99), None, "not a key of this stroke"); } +/// A stroke that grows a layer with a Gradient Overlay lays the one gradient out again over the +/// whole layer (#4053): the canvas, which already showed the live stroke, still repaints where +/// the earlier strokes are, not just the new stroke's own pixels (it repainted nothing, leaving +/// one gradient per stroke). +#[test] +fn a_committed_stroke_repaints_a_gradient_overlay_over_the_whole_layer() { + let mut h = harness(); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().run("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 10, "hardness": 1.0}})).unwrap(); + let drag = |h: &mut Harness<'static, PhotocraftApp>, pts: &[(f32, f32)]| { + move_to(h, pts[0].0, pts[0].1); + button(h, pts[0].0, pts[0].1, true); + for (x, y) in &pts[1..] { + move_to(h, *x, *y); + } + let (x, y) = pts[pts.len() - 1]; + button(h, x, y, false); + h.run_steps(2); + }; + drag(&mut h, &[(20.0, 20.0), (30.0, 25.0), (40.0, 30.0)]); + drag(&mut h, &[(150.0, 60.0), (160.0, 70.0), (170.0, 80.0)]); + let app = h.state(); + let idx = app.session.active_index().unwrap(); + let st = app.session.active().unwrap(); + let c = app.committed_stroke.as_ref().expect("the commit took the live stroke's pixels"); + assert_eq!(c.revision, st.revision); + // A cache that showed the live stroke's last step. + let missed = crate::canvas::damage_since(app, idx, (st.revision - 1, c.last_key()), (st.revision, 0), 0, st.last_damage).unwrap(); + assert!(missed.contains(25, 25), "the gradient around the first stroke is repainted: {missed:?}"); +} + #[test] fn the_stroke_trace_times_each_frame_of_a_stroke_and_ends_on_release() { // The app's hooks around the canvas (`raw_input_hook` starts a frame, `ui` ends it); the From f4a81d3c169a3067b8bcef91d5053332c7f3d66f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:04:59 -0600 Subject: [PATCH 213/304] Add Liquify's Show Backdrop Use and Mode (#7609) (#7644) * Add Liquify's Show Backdrop Use and Mode (#7609) Show Backdrop only had an Opacity: it always drew the other layers' composite behind the image, faded. Photoshop's Liquify also has Use (All Layers, or one layer) and Mode (In Front, Behind, Blend). Use and Mode are now in the View Options and the control channel (backdropUse, backdropMode), and each mode layers the backdrop and the image as Photoshop's does: In Front lays the backdrop over the image at the Opacity, Behind the image over the backdrop, Blend fades the image to 100 - Opacity under the backdrop. All Layers is the document as it is, as in Photoshop. Co-Authored-By: Claude Opus 5.5 * Liquify backdrop: show a hidden Use layer, forget the layer between documents A layer picked in Use, or one in a hidden group, now shows: the one-layer backdrop makes it and its groups visible, as Photoshop does. The remembered settings keep the backdrop Mode and Opacity but no longer a layer id, which belongs to one document: Liquify opens on All Layers, as Photoshop's does. Co-Authored-By: Claude Opus 5.5 * Liquify backdrop: a control-channel Use naming no layer is All Layers Co-Authored-By: Claude Opus 5.5 * Liquify backdrop: normalize an unknown Use even when another setting fails Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/liquify_ui.rs | 195 ++++++++++++++++++++++++--- crates/ui-egui/src/transform_tool.rs | 8 +- 22 files changed, 249 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7da5cbc30e..9cf6344d24 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3839,3 +3839,6 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + Use Použít + In Front Vpředu + Behind Vzadu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6faa41f3ea..627c9eced5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3231,3 +3231,7 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + Use Verwenden + In Front Davor + Behind Dahinter + Blend Mischen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ac3b9fbd36..4cd75535d8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3931,3 +3931,6 @@ curves Smooth Εξομάλυνση Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + Use Χρήση + In Front Μπροστά + Behind Πίσω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b2e63bbc84..a48e224832 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4193,3 +4193,6 @@ navigator Zoom Zoom Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + Use Usar + In Front Delante + Behind Detrás diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2acba73d50..caff033e38 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3239,3 +3239,6 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + Use Utiliser + In Front Devant + Behind Derrière diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7393ba2954..c2b1e3e293 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4150,3 +4150,6 @@ cameraRaw Yellow Sárga Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + Use Használat + In Front Elöl + Behind Mögött diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3246a9c371..9836f2262a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3226,3 +3226,6 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + Use Gunakan + In Front Di Depan + Behind Di Belakang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8b664de9f3..09e20955ca 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4348,3 +4348,6 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + Use Usa + In Front Davanti + Behind Dietro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c41d46a320..f43978bdfe 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3908,3 +3908,6 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + Use 使用 + In Front 前面 + Behind 背面 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 21ef94cff1..43dcbd7184 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3412,3 +3412,7 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + Use 사용 + In Front 앞 + Behind 뒤 + Blend 혼합 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ca9253c349..2bbe405810 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3991,3 +3991,6 @@ curves Smooth Vloeiend maken Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + Use Gebruiken + In Front Voor + Behind Achter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4f8194ccba..e086a07d93 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3830,3 +3830,6 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + Use Użyj + In Front Z przodu + Behind Z tyłu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 65b81cb194..cb16fa0326 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4021,3 +4021,6 @@ layerLabel No Color Sem cor Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + Use Usar + In Front Na frente + Behind Atrás diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index aa40b583b9..943ebcb9c9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3204,3 +3204,6 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + Use Использовать + In Front Спереди + Behind Сзади diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 474fedaf22..389469a2fe 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4304,3 +4304,6 @@ shape Appearance Izgled Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + Use Koristi + In Front Ispred + Behind Iza diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8fb253f142..c27af05e43 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3961,3 +3961,6 @@ cameraData Lens Lens Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + Use Kullan + In Front Önde + Behind Arkada diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ee29fb4a08..9f68595f57 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3939,3 +3939,6 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + Use Використати + In Front Спереду + Behind Ззаду diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 03cdfb972b..e6f6312760 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3718,3 +3718,6 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + Use Dùng + In Front Phía trước + Behind Phía sau diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 42704b01b0..418d631a0f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2854,3 +2854,7 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + Use 使用 + In Front 前面 + Behind 后面 + Blend 混合 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1e1a8f04a4..cffc27969b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2839,3 +2839,7 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + Use 使用 + In Front 前面 + Behind 後面 + Blend 混合 diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 07c1e5a7b6..9f46020b11 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -10,7 +10,7 @@ use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, ProxyImage, auto_cell}; -use photocraft_doc::LayerId; +use photocraft_doc::{Document, LayerId}; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -40,10 +40,32 @@ pub struct LiquifyOpts { pub mesh_size: usize, pub show_mask: bool, pub show_backdrop: bool, + /// Show Backdrop › Use: the layer shown as the backdrop (its id), `None` for All Layers. + pub backdrop_layer: Option, + pub backdrop_mode: BackdropMode, pub backdrop_opacity: f32, pub reconstruct_amount: f32, } +/// Show Backdrop › Mode, as Photoshop draws them (#7609): In Front lays the backdrop over the +/// image at the Opacity, Behind lays the image over the backdrop at the Opacity, and Blend fades +/// the image to 100 − Opacity under the backdrop at the Opacity. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BackdropMode { + #[default] + InFront, + Behind, + Blend, +} + +impl BackdropMode { + const ALL: [(BackdropMode, &'static str); 3] = [(BackdropMode::InFront, "inFront"), (BackdropMode::Behind, "behind"), (BackdropMode::Blend, "blend")]; + + fn key(self) -> &'static str { + Self::ALL.iter().find(|(m, _)| *m == self).map_or("inFront", |(_, k)| k) + } +} + impl Default for LiquifyOpts { fn default() -> Self { LiquifyOpts { @@ -56,6 +78,8 @@ impl Default for LiquifyOpts { mesh_size: 1, show_mask: true, show_backdrop: false, + backdrop_layer: None, + backdrop_mode: BackdropMode::InFront, backdrop_opacity: 50.0, reconstruct_amount: 100.0, } @@ -88,6 +112,13 @@ impl LiquifyOpts { *slot = b; } } + // `"all"` (or anything but a layer id) is All Layers. + if let Some(u) = v.get("backdropUse") { + self.backdrop_layer = u.as_u64(); + } + if let Some(m) = v.get("backdropMode").and_then(Value::as_str).and_then(|m| BackdropMode::ALL.iter().find(|(_, k)| *k == m)) { + self.backdrop_mode = m.0; + } if let Some(m) = v.get("meshSize").and_then(Value::as_str) { self.mesh_size = match m { "small" => 0, @@ -114,6 +145,8 @@ impl LiquifyOpts { "meshSize": mesh, "showMask": self.show_mask, "showBackdrop": self.show_backdrop, + "backdropUse": self.backdrop_layer.map_or(json!("all"), |id| json!(id)), + "backdropMode": self.backdrop_mode.key(), "backdropOpacity": self.backdrop_opacity, }) } @@ -143,7 +176,8 @@ pub struct LiquifyDialog { view_source_scratch: Vec<[f32; 4]>, view_tex: Option, view_dirty: Option, - backdrop: Option, + /// The backdrop and the Use it shows (see [`LiquifyOpts::backdrop_layer`]). + backdrop: Option<(Option, TextureHandle)>, mask_tex: Option, mask_dirty: bool, pub opts: LiquifyOpts, @@ -495,6 +529,8 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> if let Some(saved) = app.session.prefs().dialogs.get(REMEMBERED) { let _ = d.opts.apply(saved); } + // Layer ids belong to one document: a remembered backdrop Use starts at All Layers. + d.opts.backdrop_layer = None; d.upload(ctx); app.distort.liquify = Some(d); Ok(()) @@ -536,8 +572,16 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { cancel(app); return Ok(json!({"cancelled": true})); } + // A `backdropUse` that names no layer of the document is All Layers, as the Use list shows it. + let missing = |id: u64| app.session.active().is_none_or(|st| st.doc.layer(LayerId(id)).is_none()); + let unknown_use = ui.get("backdropUse").and_then(Value::as_u64).is_some_and(missing); let d = app.distort.liquify.as_mut().ok_or(tl!("Liquify is not open"))?; - d.opts.apply(ui)?; + // `apply` takes every setting it can before a bad one fails it, so normalize first. + let applied = d.opts.apply(ui); + if unknown_use { + d.opts.backdrop_layer = None; + } + applied?; let num = |k: &str| ui.get(k).and_then(Value::as_f64).map(|v| v as f32); let flag = |k: &str| ui.get(k).and_then(Value::as_bool); if flag("undo") == Some(true) { @@ -710,26 +754,85 @@ fn shortcut(t: LiquifyTool) -> &'static str { } } -fn load_backdrop(app: &PhotocraftApp, ctx: &egui::Context, layer: LayerId, canvas: Rect) -> Option { +/// The backdrop for Show Backdrop › Use `of`: the document as it is (All Layers, the liquified +/// layer's own pixels included, as in Photoshop) or that one layer alone. A layer that is gone +/// shows All Layers. +fn load_backdrop(app: &PhotocraftApp, ctx: &egui::Context, of: Option, canvas: Rect) -> Option { let st = app.session.active()?; - let doc = crate::distort_ui::without_layer(&st.doc, layer); - let buf = photocraft_compose::render(&doc, canvas); + let buf = photocraft_compose::render(&backdrop_doc(&st.doc, of), canvas); let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); let flat: Vec = buf.px.iter().flat_map(|p| *p).collect(); s.write_region(canvas, &flat); Some(ctx.load_texture("liquify-backdrop", crate::distort_ui::surface_image(&s, canvas, PROXY_SIDE), egui::TextureOptions::LINEAR)) } +/// The document [`load_backdrop`] renders for Use `of`. +fn backdrop_doc(doc: &Document, of: Option) -> std::borrow::Cow<'_, Document> { + match of.map(LayerId).filter(|id| doc.layer(*id).is_some()) { + Some(id) => { + let mut d = doc.clone(); + crate::transform_tool::solo_layer(&mut d.layers, id); + // A hidden layer, or one in a hidden group, shows too: Use lists every layer. + reveal(&mut d.layers, id); + std::borrow::Cow::Owned(d) + } + None => std::borrow::Cow::Borrowed(doc), + } +} + +/// Makes layer `id` and the groups holding it visible; whether it was found. +fn reveal(layers: &mut [photocraft_doc::Layer], id: LayerId) -> bool { + layers.iter_mut().any(|l| { + let found = l.id == id + || match &mut l.content { + photocraft_doc::LayerContent::Group(g) => reveal(&mut g.children, id), + _ => false, + }; + if found { + l.visible = true; + } + found + }) +} + +/// How the preview layers the image and the backdrop texture `T`: the image's tint, and the +/// backdrop drawn behind it or in front of it, with its tint. +#[derive(Debug, PartialEq)] +struct Layering { + image: Color32, + behind: Option<(T, Color32)>, + front: Option<(T, Color32)>, +} + +/// The [`Layering`] for the backdrop `backdrop` (when shown) in `mode` at `opacity` (0..1): +/// Photoshop's modes, measured on its Liquify (#7609). +fn backdrop_layering(backdrop: Option, mode: BackdropMode, opacity: f32) -> Layering { + let alpha = |a: f32| Color32::from_white_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let (image, behind, front) = match (backdrop, mode) { + (None, _) => (Color32::WHITE, None, None), + (Some(b), BackdropMode::InFront) => (Color32::WHITE, None, Some((b, alpha(opacity)))), + (Some(b), BackdropMode::Behind) => (alpha(opacity), Some((b, Color32::WHITE)), None), + (Some(b), BackdropMode::Blend) => (alpha(1.0 - opacity), None, Some((b, alpha(opacity)))), + }; + Layering { image, behind, front } +} + /// Draws the dialog (a full-window layer over the app). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); - if app.distort.liquify.as_ref().is_some_and(|d| d.opts.show_backdrop && d.backdrop.is_none()) { - let tex = app.distort.liquify.as_ref().map(|d| (d.layer, d.canvas)).and_then(|(layer, canvas)| load_backdrop(app, ctx, layer, canvas)); + // The backdrop is built when it's first shown and again when its Use changes. + let wanted = app.distort.liquify.as_ref().filter(|d| d.opts.show_backdrop && d.backdrop.as_ref().is_none_or(|(of, _)| *of != d.opts.backdrop_layer)); + if let Some((of, canvas)) = wanted.map(|d| (d.opts.backdrop_layer, d.canvas)) { + let tex = load_backdrop(app, ctx, of, canvas); if let Some(d) = app.distort.liquify.as_mut() { - d.backdrop = tex; + d.backdrop = tex.map(|t| (of, t)); } } + // Show Backdrop › Use: All Layers or one layer, bottom to top as Photoshop lists them. + let backdrop_layers: Vec<(Option, String)> = std::iter::once((None, tl!("All Layers").to_string())) + .chain(app.session.active().into_iter().flat_map(|st| st.doc.walk().into_iter().map(|(_, _, l)| (Some(l.id.0), l.name.clone())).collect::>())) + .collect(); let mut action: Option<&str> = None; let mut events: Vec = Vec::new(); let mut mods = egui::Modifiers::NONE; @@ -818,6 +921,16 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::checkbox(ui, &mut d.opts.show_mask, tl!("Show Mask")); widgets::checkbox(ui, &mut d.opts.show_backdrop, tl!("Show Backdrop")); if d.opts.show_backdrop { + ui.horizontal(|ui| { + ui.label(tl!("Use")); + let names: Vec<(Option, &str)> = backdrop_layers.iter().map(|(id, n)| (*id, n.as_str())).collect(); + widgets::dropdown_literal(ui, "liquify-backdrop-use", &mut d.opts.backdrop_layer, &names, 150.0); + }); + ui.horizontal(|ui| { + ui.label(tl!("Mode")); + let modes = [(BackdropMode::InFront, tl!("In Front")), (BackdropMode::Behind, tl!("Behind")), (BackdropMode::Blend, tl!("Blend"))]; + widgets::dropdown(ui, "liquify-backdrop-mode", &mut d.opts.backdrop_mode, &modes, 150.0); + }); widgets::slider_row(ui, tl!("Opacity"), &mut d.opts.backdrop_opacity, 0.0..=100.0, "", None); } widgets::hairline(ui); @@ -875,10 +988,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let clip = painter.with_clip_rect(area); widgets::checker(&clip, img.intersect(area), 8.0); let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - if d.opts.show_backdrop - && let Some(b) = &d.backdrop - { - clip.image(b.id(), img, uv, Color32::from_white_alpha((d.opts.backdrop_opacity / 100.0 * 255.0) as u8)); + let backdrop = d.backdrop.as_ref().filter(|_| d.opts.show_backdrop).map(|(_, t)| t.id()); + let Layering { image: image_tint, behind, front } = backdrop_layering(backdrop, d.opts.backdrop_mode, d.opts.backdrop_opacity / 100.0); + if let Some((b, c)) = behind { + clip.image(b, img, uv, c); } if d.zoom > 1.0 && let (Some(bounds), Some(tex)) = (d.view_bounds, &d.view_tex) @@ -887,7 +1000,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { to_screen(d, area, [f64::from(bounds.x0), f64::from(bounds.y0)]), to_screen(d, area, [f64::from(bounds.x1), f64::from(bounds.y1)]), ); - clip.image(tex.id(), detail, uv, Color32::WHITE); + clip.image(tex.id(), detail, uv, image_tint); } else { // The proxy covers whole proxy pixels, a little past the canvas edge. let pr = ERect::from_min_max( @@ -895,9 +1008,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { img.min + vec2(d.proxy.w as f32 * d.proxy.scale as f32 * d.zoom, d.proxy.h as f32 * d.proxy.scale as f32 * d.zoom), ); if let Some(tex) = &d.tex { - clip.image(tex.id(), pr, uv, Color32::WHITE); + clip.image(tex.id(), pr, uv, image_tint); } } + if let Some((b, c)) = front { + clip.image(b, img, uv, c); + } if d.opts.show_mask && let Some(m) = &d.mask_tex { @@ -1111,6 +1227,55 @@ mod tests { assert!(d.preview_ms.is_finite()); } + /// #7609: Show Backdrop › Use and Mode, as in Photoshop: the settings, how each mode layers the + /// backdrop and the image, and what each Use shows. + #[test] + fn backdrop_use_and_mode() { + let mut o = LiquifyOpts::default(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::InFront), "All Layers, In Front"); + o.apply(&json!({"backdropUse": 3, "backdropMode": "behind"})).unwrap(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (Some(3), BackdropMode::Behind)); + assert_eq!((&o.to_json()["backdropUse"], &o.to_json()["backdropMode"]), (&json!(3), &json!("behind"))); + o.apply(&json!({"backdropUse": "all", "backdropMode": "nope"})).unwrap(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::Behind), "a bad mode is ignored"); + let (w, a) = (Color32::WHITE, |x: f32| Color32::from_white_alpha((x * 255.0).round() as u8)); + let l = |image, behind, front| Layering { image, behind, front }; + assert_eq!(backdrop_layering(Some(1), BackdropMode::InFront, 0.25), l(w, None, Some((1, a(0.25)))), "backdrop over the image"); + assert_eq!(backdrop_layering(Some(1), BackdropMode::Behind, 0.25), l(a(0.25), Some((1, w)), None), "image over the backdrop"); + assert_eq!(backdrop_layering(Some(1), BackdropMode::Blend, 0.25), l(a(0.75), None, Some((1, a(0.25)))), "faded together"); + assert_eq!(backdrop_layering::(None, BackdropMode::Blend, 0.25), l(w, None, None), "no backdrop: the image as is"); + // All Layers is the document with the liquified layer in it; one layer is that layer alone. + let app = app_with_layer(); + let st = app.session.active().unwrap(); + let (layer, background) = (st.active_layer.unwrap(), st.doc.layers[0].id); + let at = |doc: &Document| photocraft_compose::render(doc, Rect::new(10, 10, 11, 11)).px[0]; + let own = at(&st.doc); + assert_eq!(at(&backdrop_doc(&st.doc, None)), own, "All Layers"); + assert_eq!(at(&backdrop_doc(&st.doc, Some(layer.0))), own, "the liquified layer alone"); + assert_ne!(at(&backdrop_doc(&st.doc, Some(background.0))), own, "the Background alone"); + assert_eq!(at(&backdrop_doc(&st.doc, Some(9999))), own, "a layer that's gone: All Layers"); + // A hidden layer shows when it is the one used. + let mut hidden = (*st.doc).clone(); + hidden.layer_mut(layer).unwrap().visible = false; + assert_eq!(at(&backdrop_doc(&hidden, Some(layer.0))), own, "the hidden liquified layer, alone"); + // A remembered Use is a layer of another document: Liquify opens on All Layers. + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"backdropUse": 42, "backdropMode": "blend"}))); + open(&mut app, &ctx).unwrap(); + let o = &app.distort.liquify.as_ref().unwrap().opts; + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::Blend), "the mode is kept, the layer is not"); + // Over the control channel, a layer of the document is kept and one that isn't is All Layers. + let layer = app.session.active().unwrap().active_layer.unwrap().0; + control(&mut app, &json!({"backdropUse": layer})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, Some(layer)); + control(&mut app, &json!({"backdropUse": 4242})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, None, "no such layer: All Layers"); + control(&mut app, &json!({"backdropUse": layer})).unwrap(); + assert!(control(&mut app, &json!({"backdropUse": 4242, "tool": "nope"})).is_err()); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, None, "also when another setting fails"); + } + /// #418: the brush settings survive Cancel, OK and Esc, and come back the next time Liquify /// opens (also after a restart: they live in the preferences). #[test] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 561621e8c9..bf0c4d8e87 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -793,7 +793,7 @@ fn preview_image( _ => { let mut solo = doc.clone(); if layer.is_some() { - show_preview_target(&mut solo.layers, id); + solo_layer(&mut solo.layers, id); } else { // Keep the previous fallback for an invalid id: render the document, with only // top-level clipping disabled. Production callers get ids from this document. @@ -991,7 +991,9 @@ fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { /// Leave only `id`, its descendants and the groups holding it visible. Every layer on that path /// is detached from its clipping base because the base is hidden in this isolated render (#5239). -fn show_preview_target(layers: &mut [photocraft_doc::Layer], id: LayerId) { +/// Hide every layer but `id` and the groups holding it, and unclip those, so a render shows that +/// layer alone (the Free Transform preview, Liquify's one-layer backdrop). +pub(crate) fn solo_layer(layers: &mut [photocraft_doc::Layer], id: LayerId) { for l in layers { if l.id == id { l.clipped = false; @@ -1000,7 +1002,7 @@ fn show_preview_target(layers: &mut [photocraft_doc::Layer], id: LayerId) { match &mut l.content { LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)) => { l.clipped = false; - show_preview_target(&mut g.children, id); + solo_layer(&mut g.children, id); } _ => l.visible = false, } From 125d15961e697bacd2e054c8a41c30cad5f7f7da Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:08:50 +0200 Subject: [PATCH 214/304] Actions: Smart Blur quality uses Photoshop's SBQL/SBQM/SBQH codes (#6312) (#6313) A Smart Blur step saved to .atn wrote its quality as `Hgh `/`Mdm `/`Low `, codes Photoshop ignores (it plays them back with its fallback, low quality), and Photoshop's own `SmBQ` values did not import as a quality. The quality table now writes `SBQL`/`SBQM`/`SBQH` (enum type `SmBQ`) and reads both those and the old `Hgh `/`Mdm `/`Low `, so actions PhotoCraft wrote before still load. Unknown codes stay kept-verbatim Photoshop steps. Measured in Photoshop by rendering: Smart Blur applied through Action Manager with each code; SBQH and SBQM each give a distinct image, while `Hgh `, `Mdm `, `Low `, a nonsense code and SBQL give the identical (fallback) image. The smart filter Photoshop saves uses the same SmBQ values (applied through Action Manager to a smart object, saved, filterFX read back). Tests: every quality round-trips through .atn as SBQ*; Photoshop-shaped SmrB steps and the legacy codes import as the right quality; unknown, wrong-type, NaN and missing values never panic; Smart Blur joins the export/import round-trip table. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 5 ++- crates/engine/src/atn_cmds/tests.rs | 69 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 999ba5c193..040afaca42 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -922,7 +922,10 @@ const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center" const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; const PROFILES: &[(&str, &str)] = &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; -const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; +/// Smart Blur quality. Photoshop's codes come first, so they are what an export writes (measured in +/// Photoshop: `Hgh `/`Mdm `/`Low ` play back as its fallback, low quality). The old codes after +/// them still read, for actions PhotoCraft wrote before. +const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("SBQL", "low"), ("SBQM", "medium"), ("SBQH", "high"), ("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; const SMART_BLUR_MODE: &[(&str, &str)] = &[("SBMN", "normal"), ("SBME", "edgeOnly"), ("SBMO", "overlayEdge")]; const RADIAL_METHOD: &[(&str, &str)] = &[("Spn ", "spin"), ("Zm ", "zoom")]; const RADIAL_QUALITY: &[(&str, &str)] = &[("Drft", "draft"), ("Gd ", "good"), ("Bst ", "best")]; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 59dbbf13c8..f50189fb0f 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -174,6 +174,7 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), ("filter.noise.median", json!({"radius": 2})), ("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})), + ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), ( @@ -416,7 +417,6 @@ fn every_mapped_command_exports_and_imports_back() { "filter.blur.blurMore", "filter.blur.boxBlur", "filter.blur.radialBlur", - "filter.blur.smartBlur", "filter.blur.surfaceBlur", "filter.noise.despeckle", "filter.noise.dustAndScratches", @@ -1037,6 +1037,73 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in +/// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. +#[test] +fn smart_blur_quality_uses_photoshop_codes() { + let smart_blur = + |quality: D, mode: &str| desc("null").with("Rds ", D::Double(6.0)).with("Thsh", D::Double(60.0)).with("SmBQ", quality).with("SmBM", enm("SmBM", mode)); + // Every quality writes Photoshop's code, as a double radius/threshold and both enums, + // and reads back as the same step. + for (quality, code) in [("low", "SBQL"), ("medium", "SBQM"), ("high", "SBQH")] { + let params = json!({"radius": 6, "threshold": 60, "quality": quality, "mode": "edgeOnly"}); + let a = Action::new("Q", vec![("filter.blur.smartBlur".into(), params.clone())]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let it = &file.actions[0].items[0]; + assert_eq!(it.event.id(), "SmrB"); + assert_eq!(it.descriptor.as_ref(), Some(&smart_blur(enm("SmBQ", code), "SBME")), "{quality}"); + let back = import(&bytes).unwrap(); + assert!(back.unsupported.is_empty(), "{quality}"); + assert_eq!(back.actions[0].steps, vec![("filter.blur.smartBlur".to_string(), params)]); + } + + // Photoshop-shaped steps (each quality and mode, as Photoshop stores them) and the codes + // PhotoCraft wrote before this fix read as the right quality. + let cases = [ + ("SBQH", "SBMN", "high", "normal"), + ("SBQM", "SBMO", "medium", "overlayEdge"), + ("SBQL", "SBME", "low", "edgeOnly"), + ("Hgh ", "SBMN", "high", "normal"), + ("Mdm ", "SBMN", "medium", "normal"), + ("Low ", "SBMN", "low", "normal"), + ]; + let items = cases.iter().map(|(q, m, _, _)| item("SmrB", "Smart Blur", Some(smart_blur(enm("SmBQ", q), m)))).collect(); + let data = set_of("Blurs", vec![("Smart", items)]); + let mut s = Session::new(); + s.execute("actions.load", json!({"data": data})).unwrap(); + let steps = &s.actions.list[0].steps; + for ((q, _, quality, mode), (id, p)) in cases.iter().zip(steps) { + assert_eq!(id, "filter.blur.smartBlur", "{q}"); + assert_eq!(p, &json!({"radius": 6, "threshold": 60, "quality": quality, "mode": mode}), "{q}"); + } + + // An unknown code, a quality of the wrong type, NaN and a missing quality never panic: the + // unknown ones stay Photoshop steps, kept verbatim. + let odd = [ + smart_blur(enm("SmBQ", "Xxxx"), "SBMN"), + smart_blur(D::Integer(2), "SBMN"), + smart_blur(enm("SmBQ", "SBQH"), "Xxxx"), + desc("null").with("Rds ", D::Double(f64::NAN)).with("SmBQ", enm("SmBQ", "SBQM")), + desc("null"), + ]; + let items: Vec = odd.iter().map(|d| item("SmrB", "Smart Blur", Some(d.clone()))).collect(); + let imported = import(&photocraft_paint::tile::b64_decode(&set_of("Odd", vec![("Odd", items)])).unwrap()).unwrap(); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), odd.len()); + assert_eq!(steps[0].0, PHOTOSHOP_STEP, "an unknown quality is kept verbatim"); + assert_eq!(steps[2].0, PHOTOSHOP_STEP, "an unknown mode is kept verbatim"); + assert_eq!(imported.unsupported.iter().map(|u| u.step).collect::>(), [0, 2]); + // A wrong-type quality is left out (the filter's default applies), as for every enum field. + assert_eq!(steps[1], ("filter.blur.smartBlur".to_string(), json!({"radius": 6, "threshold": 60, "mode": "normal"}))); + // An unknown quality or a non-string one is not exported. + for bad in [json!({"quality": "best"}), json!({"quality": 3}), json!({"radius": "x"})] { + let a = Action::new("Bad", vec![("filter.blur.smartBlur".into(), bad.clone())]); + assert!(export("S", &[&a], &[], false).is_err(), "{bad}"); + } +} + /// #5830: Shadows/Highlights (`adaptCorrect`) recorded by Photoshop, with string IDs, plays as /// Image › Adjustments › Shadows/Highlights instead of stopping as a step with no equivalent. #[test] From b892c0412e2e67c402af073dc3a3ff1f2b9d1cd3 Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 13:16:53 -0400 Subject: [PATCH 215/304] Choose the graphics adapter on multi-GPU Linux machines (#1885) With the backend on auto, Linux installed no adapter selector, so wgpu took the first discrete GPU the driver listed. On a machine with an eGPU, a laptop dGPU and an iGPU that was not the one the user wanted. - performance.gpuAdapter: a case-insensitive piece of the adapter's name (several words must all appear); empty is automatic. - PHOTOCRAFT_GPU_ADAPTER overrides it for one launch. - Preferences > Performance > Advanced > GPU adapter lists the adapters the instance found; a saved adapter that is absent stays listed, marked. - A missing adapter, or one that can't present to the window, falls back to the automatic choice with a note in System Info. CPU mode ignores it. - Shown on Linux only; tested on Linux only. Windows and macOS need their own work (the startup code honours the setting there, but it is untested). Co-Authored-By: Claude Sonnet 5.5 --- apps/photocraft/src/gpu_startup.rs | 114 ++++++++++++++++++++++++++-- apps/photocraft/src/main.rs | 7 +- crates/engine/src/prefs.rs | 8 +- crates/engine/src/prefs/tests.rs | 20 ++++- crates/ui-egui/src/gpu_canvas.rs | 14 ++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/prefs_ui.rs | 32 +++++++- docs/development.md | 3 +- docs/hardware-parity.md | 2 + 29 files changed, 246 insertions(+), 15 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index e79b4bafac..a709e99676 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -228,6 +228,8 @@ pub struct Candidate { pub backend: wgpu::Backend, /// Can present to the window. pub surface_ok: bool, + /// Its name matches the adapter the user asked for ([`adapter_matches`]). + pub wanted: bool, } /// Rank of an adapter (lower is better): high-performance device types first (egui's power @@ -259,9 +261,38 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { (fallback, ty, be) } -/// The adapter to use among `adapters` (`None` when there is none). +/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for +/// win over the rest when any of them can present; otherwise the ranking decides (see +/// [`wanted_missed`]). pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option { - adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i) + let usable = |a: &&Candidate| a.surface_ok; + let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted); + adapters + .iter() + .enumerate() + .filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)) + .min_by_key(|(i, a)| (rank(a, backend, os), *i)) + .map(|(i, _)| i) +} + +/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive +/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds +/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing. +pub fn adapter_matches(name: &str, wanted: &str) -> bool { + let name = name.to_lowercase(); + let mut words = wanted.split_whitespace().peekable(); + words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) +} + +/// The user asked for an adapter and none of `adapters` that can present is it. +pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool { + wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted) +} + +/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else +/// `performance.gpuAdapter`; `None` is automatic. +pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option { + [env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(str::to_string) } /// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the @@ -285,7 +316,7 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool { /// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter /// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the /// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default). -pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc>>) { +pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option, sentinel: SharedSentinel, note: Arc>>) { if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { if let Some(b) = backends(plan, os) { create.instance_descriptor.backends = b; @@ -298,7 +329,9 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel let env = std::env::var("WGPU_DX12_COMPILER").ok(); create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent)); } - let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)); + // CPU mode wants a software adapter, whatever the preference says. + let wanted = wanted.filter(|_| plan.backend != GpuBackend::Cpu); + let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows))); if select { let backend = plan.backend; create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| { @@ -311,10 +344,15 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel device_type: i.device_type, backend: i.backend, surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)), + wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)), } }) .collect(); let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?; + if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) { + *note.lock().unwrap_or_else(PoisonError::into_inner) = + Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice")); + } if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) { *note.lock().unwrap_or_else(PoisonError::into_inner) = Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into()); @@ -443,6 +481,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin .unwrap_or_default() } +/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window +/// opens; empty (automatic) when the file or value is missing. +pub fn read_gpu_adapter(path: Option<&Path>) -> String { + let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); + v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect() +} + /// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless /// turned on), read before the window opens: it picks the surface's present mode. pub fn read_low_latency(path: Option<&Path>) -> bool { @@ -604,7 +649,60 @@ mod tests { } fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate { - Candidate { vendor, device_type, backend, surface_ok: true } + Candidate { vendor, device_type, backend, surface_ok: true, wanted: false } + } + + #[test] + fn a_requested_adapter_beats_the_automatic_ranking() { + use wgpu::{Backend as B, DeviceType as T}; + // The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU. + let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; + assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc"); + a[2].wanted = true; + assert_eq!(pick(&a, Auto, Os::Other), Some(2)); + a[2].wanted = false; + a[0].wanted = true; + assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for"); + // A wanted adapter that can't present is skipped, not an error. + a[0].surface_ok = false; + assert_eq!(pick(&a, Auto, Os::Other), Some(1)); + assert!(wanted_missed(&a, Some("iris"))); + assert!(!wanted_missed(&a, None)); + a[0].surface_ok = true; + assert!(!wanted_missed(&a, Some("iris"))); + // Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides. + let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)]; + gl.iter_mut().for_each(|c| c.wanted = true); + assert_eq!(pick(&gl, Auto, Os::Other), Some(1)); + } + + #[test] + fn adapter_names_match_loosely_and_the_environment_wins() { + assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); + assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M ")); + assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc")); + assert!(!adapter_matches("anything", "")); + assert!(!adapter_matches("anything", " ")); + assert_eq!(wanted_adapter(None, ""), None); + assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into())); + assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into())); + } + + #[test] + fn gpu_adapter_is_read_before_the_window_opens() { + let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("preferences.json"); + let read = |json: &str| { + std::fs::write(&file, json).unwrap(); + read_gpu_adapter(Some(&file)) + }; + assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia"); + assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), ""); + assert_eq!(read(r#"{"performance":{}}"#), ""); + assert_eq!(read("not json"), ""); + assert_eq!(read_gpu_adapter(None), ""); + let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -894,7 +992,7 @@ mod tests { // The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path). let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); let plan = plan(Auto, None, None, false, Os::Windows); - configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; if std::env::var_os("WGPU_DX12_COMPILER").is_none() { let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler { @@ -918,13 +1016,13 @@ mod tests { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") }; let before = before.instance_descriptor.flags; - configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched"); } if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); - configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default()); + configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default()); let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") }; assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); } diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index b0b00a865c..befff83026 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -678,7 +678,11 @@ fn main() -> eframe::Result { } let gpu_note: std::sync::Arc>> = Default::default(); // The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU. - gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone()); + let wanted_adapter = gpu_startup::wanted_adapter( + std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), + &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()), + ); + gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone()); // Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about // 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers. if gpu_startup::read_low_latency(services::prefs_file().as_deref()) { @@ -774,6 +778,7 @@ fn main() -> eframe::Result { info.canvas = "cpu".into(); if let Some(rs) = cc.wgpu_render_state.clone() { app.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + app.perf.gpu_info.set_available(&rs.available_adapters); let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu; if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu { app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into()); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3e87d43fc7..eed66a3247 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -525,6 +525,11 @@ pub struct Performance { pub rendering_mode: Option, /// Graphics backend (applies at next launch; see [`GpuBackend`]). pub gpu_backend: GpuBackend, + /// Which graphics adapter the window and canvas use on a machine with several (applies at + /// next launch): part of the adapter's name, matched case-insensitively ("nvidia", "arc"). Empty + /// is automatic. `PHOTOCRAFT_GPU_ADAPTER` overrides it for one launch. If nothing matches or the + /// match can't present to the window, startup falls back to automatic and says so. + pub gpu_adapter: String, /// Live previews of large documents (adjustment and filter dialogs, an adjustment layer's /// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off: /// they render at full resolution. @@ -570,6 +575,7 @@ impl Default for Performance { use_gpu: true, rendering_mode: None, gpu_backend: GpuBackend::Auto, + gpu_adapter: String::new(), low_resolution_previews: true, low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, @@ -1059,7 +1065,7 @@ pub fn is_hidden(path: &str) -> bool { /// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They /// still load, save and round-trip on every platform. -pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer"]; +pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"]; /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> { diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 5314d1f197..bbb9df4ff2 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -493,6 +493,22 @@ fn right_click_with_painting_tools_pref() { assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker); } +#[test] +fn gpu_adapter_round_trips_and_defaults_to_automatic() { + let mut s = session(); + assert_eq!(s.prefs().performance.gpu_adapter, ""); + s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": "NVIDIA"})).unwrap(); + assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA"); + assert!(s.execute("prefs.set", json!({"path": "performance.gpuAdapter", "value": 3})).is_err()); + assert_eq!(s.prefs().performance.gpu_adapter, "NVIDIA"); + let mut t = Session::new(); + t.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(t.prefs().performance.gpu_adapter, "NVIDIA"); + let mut u = Session::new(); + u.load_prefs_json(r#"{"performance": {"useGpu": true}}"#).unwrap(); + assert_eq!(u.prefs().performance.gpu_adapter, ""); +} + #[test] fn gpu_backend_round_trips_and_validates() { let mut s = session(); @@ -553,7 +569,9 @@ fn notification_autohide_preferences() { fn linux_only_preferences_show_only_on_linux() { assert_eq!(is_hidden("performance.linuxDisplayServer"), !cfg!(target_os = "linux")); assert!(!is_hidden("performance.gpuBackend")); - assert!(LINUX_ONLY.iter().all(|p| choices(p).is_some()), "every Linux-only preference is a real one"); + assert!(LINUX_ONLY.contains(&"performance.gpuAdapter")); + let free_text = ["performance.gpuAdapter"]; + assert!(LINUX_ONLY.iter().all(|p| free_text.contains(p) || choices(p).is_some()), "every Linux-only preference is a real one"); } #[test] diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 65388d5049..476137ff3d 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1058,6 +1058,9 @@ pub struct GpuInfo { pub video_memory_mb: Option, /// `performance.gpuBackend` at launch. pub preference: String, + /// Every graphics adapter the window could have used, by name (Preferences › Performance › + /// Graphics Adapter lists them). Empty in hosts that don't set it. + pub adapters: Vec, /// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`). pub selected: String, /// Why startup didn't use the preference as is (previous start crashed, `--safe-gpu`, @@ -1073,6 +1076,17 @@ pub struct GpuInfo { } impl GpuInfo { + /// Record the adapters the instance found, without duplicates (one GPU shows up once per backend). + pub fn set_available(&mut self, adapters: &[wgpu::Adapter]) { + self.adapters.clear(); + for a in adapters { + let name = a.get_info().name; + if !name.is_empty() && !self.adapters.contains(&name) { + self.adapters.push(name); + } + } + } + /// Fill the adapter fields from `info`. pub fn set_adapter(&mut self, info: &wgpu::AdapterInfo) { self.adapter = info.name.clone(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..f4d8798b40 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3838,3 +3838,6 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + +# Graphics Adapter preference (#1885) + GPU adapter Adaptér GPU diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fbbceb440..fd72546283 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3230,3 +3230,6 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + +# Graphics Adapter preference (#1885) + GPU adapter GPU-Adapter diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d21c3ba6e4..3f31bec6c9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3930,3 +3930,6 @@ curves Smooth Εξομάλυνση Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + +# Graphics Adapter preference (#1885) + GPU adapter Προσαρμογέας GPU diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4956fcd97b..3dcfb2568d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4192,3 +4192,6 @@ navigator Zoom Zoom Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + +# Graphics Adapter preference (#1885) + GPU adapter Adaptador de GPU diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62b2e8ea26..9f15ac1084 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3238,3 +3238,6 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + +# Graphics Adapter preference (#1885) + GPU adapter Adaptateur GPU diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a52cb5fdba..ad4d3c19fd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4149,3 +4149,6 @@ cameraRaw Yellow Sárga Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 14e65b2b72..e63f82bafb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3225,3 +3225,6 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + +# Graphics Adapter preference (#1885) + GPU adapter Adaptor GPU diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 857cc66d96..6c754f1b3e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4347,3 +4347,6 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + +# Graphics Adapter preference (#1885) + GPU adapter Adattatore GPU diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b14f5c1192..5204f384eb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3907,3 +3907,6 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + +# Graphics Adapter preference (#1885) + GPU adapter GPU アダプター diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d5d58b2ff2..ff7165f709 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3411,3 +3411,6 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + +# Graphics Adapter preference (#1885) + GPU adapter GPU 어댑터 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 394aaecbbb..41bc8ca4fe 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3990,3 +3990,6 @@ curves Smooth Vloeiend maken Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e93bbb7..097f1d1050 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3829,3 +3829,6 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + +# Graphics Adapter preference (#1885) + GPU adapter Karta GPU diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 338c4fadb7..967efd5e61 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4020,3 +4020,6 @@ layerLabel No Color Sem cor Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + +# Graphics Adapter preference (#1885) + GPU adapter Adaptador de GPU diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11fd43811a..5b52178d62 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3203,3 +3203,6 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + +# Graphics Adapter preference (#1885) + GPU adapter Адаптер GPU diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 25175aeff2..0b9bdb75ab 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4303,3 +4303,6 @@ shape Appearance Izgled Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + +# Graphics Adapter preference (#1885) + GPU adapter GPU adapter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6af5510125..278623f6ad 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3960,3 +3960,6 @@ cameraData Lens Lens Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + +# Graphics Adapter preference (#1885) + GPU adapter GPU bağdaştırıcısı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37851e90f3..605cf3df6f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3938,3 +3938,6 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + +# Graphics Adapter preference (#1885) + GPU adapter Адаптер GPU diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fd7950b930..3a9f2ba4eb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3717,3 +3717,6 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + +# Graphics Adapter preference (#1885) + GPU adapter Bộ điều hợp GPU diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f242f8f63..604786a294 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2853,3 +2853,6 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + +# Graphics Adapter preference (#1885) + GPU adapter GPU 适配器 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 621bee5518..e61580cef6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2838,3 +2838,6 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + +# Graphics Adapter preference (#1885) + GPU adapter GPU 介面卡 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5948bd4edf..5634dfa72b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1013,6 +1013,7 @@ impl PhotocraftApp { let tile = self.session.prefs().performance.cache_tile_size; // Escaped driver/setup panics must leave the session and CPU canvas alive. self.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + self.perf.gpu_info.set_available(&rs.available_adapters); let gpu = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)))) { Ok(gpu) => gpu, Err(payload) => { diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 4156e380cd..1b59083261 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1058,6 +1058,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, system: Option, system: Option, obj: &mut Map) { +fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, adapters: Option<&Value>, obj: &mut Map) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); ui.label(RichText::new(tl!("Graphics")).font(crate::theme::semibold(12.5)).color(t.text)); @@ -1331,9 +1332,34 @@ fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, obj: &mut Map, obj: &mut Map) { + let mut current = obj.get("gpuAdapter").and_then(Value::as_str).unwrap_or("").to_string(); + let mut names: Vec = adapters.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let missing = !current.is_empty() && !names.contains(¤t); + if missing { + names.push(current.clone()); + } + let automatic = tl!("Automatic"); + let labels: Vec = names.iter().map(|n| if missing && *n == current { format!("⚠ {n}") } else { n.clone() }).collect(); + let mut pairs: Vec<(String, &str)> = vec![(String::new(), automatic)]; + pairs.extend(names.iter().cloned().zip(labels.iter().map(String::as_str))); + ui.horizontal(|ui| { + ui.label(tl!("GPU adapter")); + crate::widgets::dropdown_literal(ui, "graphics-adapter", &mut current, &pairs, 320.0); + }); + ui.label(RichText::new(tl!("Applies at next launch.")).color(Tokens::get(ui.ctx()).text_faint)); + obj.insert("gpuAdapter".into(), json!(current)); +} + /// One user-facing choice; legacy flags remain compatible with older settings. fn rendering_mode_row(ui: &mut egui::Ui, obj: &mut Map) { let mut current = rendering_mode_value(obj); @@ -1566,7 +1592,7 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" - || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) + || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "gpuAdapter" | "renderingMode")) // The browser presents frames itself: Low-Latency Drawing does nothing there. || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") || (section == "export" && !export_field_visible(obj, k)) diff --git a/docs/development.md b/docs/development.md index 6891e88a9c..bf4898482d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,7 @@ On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.l ## Graphics startup and device loss -`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. +`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. On a machine with several GPUs (say an eGPU, a discrete laptop GPU and an iGPU), `auto` takes the first discrete adapter the driver lists; choose another in Preferences › Performance › Advanced › GPU adapter (`performance.gpuAdapter`, Linux; a case-insensitive piece of the adapter's name, empty is automatic), or for one launch with `PHOTOCRAFT_GPU_ADAPTER=nvidia`. An adapter that is missing or can't present to the window falls back to the automatic choice, with a note in System Info. Help › System Info shows the adapter, backend, driver and fallback state. On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712). @@ -116,6 +116,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | | `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | +| `PHOTOCRAFT_GPU_ADAPTER=nvidia` | Use the graphics adapter whose name contains this (case-insensitive); overrides `performance.gpuAdapter` for one launch | | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 60175de1e5..1417821f0d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,6 +22,7 @@ HDR display to verify. | GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects | partial | | Documents larger than the GPU texture limit | yes (tiled) | CPU fallback; tiling in progress (#49) | partial | | CPU-only fallback when no GPU | yes, reduced | `cpu` canvas preference; fails to start without an adapter on some Linux systems (#2519, #2412) | partial | +| Choosing the GPU on a multi-GPU machine | Preferences › Performance › Use Graphics Processor | Linux: Preferences › Performance › Advanced › GPU adapter and `PHOTOCRAFT_GPU_ADAPTER` (#1885); Windows and macOS not yet | partial | | Old / integrated GPUs | yes | black line on Intel HD (#2426), OpenCore Macs fail (#1859), Rgba32Float panic (#2020) | partial | ## Pen tablets @@ -62,3 +63,4 @@ HDR display to verify. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | First version | +| 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) | From b8e856dffe835d9589b98f4c9bf27c9d0d3677f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 17:18:46 +0000 Subject: [PATCH 216/304] Integration: #7022 write_region stays serial; the text rasterizer uses the parallel variant --- crates/raster/src/lib.rs | 61 ++++++++++++++++++++++++++++++--- crates/text/src/render.rs | 2 +- docs/text-raster-performance.md | 9 +++-- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 1393a8236e..958ab38b53 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -641,16 +641,34 @@ impl Surface { out } - /// Write interleaved normalised floats into a rectangle. + /// Write interleaved normalised floats into a rectangle, on the calling thread. + /// + /// This never enters Rayon, so it is safe inside a `OnceLock` initializer or any other + /// section that must not run another Rayon job on its stack. [`Surface::write_region_par`] + /// writes the same bytes on all cores. pub fn write_region(&mut self, r: Rect, data: &[f32]) { let n = self.channels(); let w = r.width() as usize; assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); let sample = self.format.sample; - // Each tile's own span of `data` is converted on its own: the tiles are disjoint, so - // converting them in any order (and on any thread) writes exactly the same bytes. - // `take_tiles` reserves the whole result before removing anything, and gives the missing - // tiles as one shared default-filled tile, copied per tile by `Arc::make_mut` below. + for tc in r.tiles() { + write_tile(self.tile_mut(tc), tc, r, data, w, n, sample); + } + } + + /// [`Surface::write_region`] with the tiles converted on all cores (serially on the web). + /// + /// This enters Rayon: while it waits for its tiles, the calling worker may run any other + /// queued Rayon job. Callers inside a `OnceLock` initializer or another section that must + /// not do that use [`Surface::write_region`]. + pub fn write_region_par(&mut self, r: Rect, data: &[f32]) { + let n = self.channels(); + let w = r.width() as usize; + assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); + let sample = self.format.sample; + // The tiles are disjoint, so converting them in any order (and on any thread) writes + // exactly the same bytes. `take_tiles` gives the missing tiles as one shared + // default-filled tile, copied per tile by `Arc::make_mut` in `convert_tiles`. let mut tiles = self.take_tiles(r); convert_tiles(&mut tiles, r, data, w, n, sample); self.put_tiles(tiles); @@ -1310,19 +1328,52 @@ mod tests { .collect(); let snapshot = actual.clone(); let snapshot_bytes = snapshot.to_interleaved(rect.inflate(1)); + let mut par = actual.clone(); actual.write_region(rect, &data); + par.write_region_par(rect, &data); write_region_reference(&mut expected, rect, &data); assert_eq!(actual, expected, "{fmt:?}, {rect:?}"); + assert_eq!(par, expected, "parallel {fmt:?}, {rect:?}"); assert_eq!(snapshot.to_interleaved(rect.inflate(1)), snapshot_bytes, "COW snapshot changed"); // Repeat against already materialised, unshared tiles too. actual.write_region(rect, &data); + par.write_region_par(rect, &data); assert_eq!(actual, expected); + assert_eq!(par, expected); } } } } } + /// Mask builds call `write_region` inside a `OnceLock` initializer, where entering Rayon can + /// run another tile's job that waits on the same lock. With every global worker held, a write + /// that queued work on Rayon could not finish. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn write_region_does_not_enter_rayon() { + use std::sync::{Barrier, mpsc}; + let workers = rayon::current_num_threads(); + let held = Arc::new(Barrier::new(workers + 1)); + let release = Arc::new(Barrier::new(workers + 1)); + let (h, rel) = (held.clone(), release.clone()); + rayon::spawn_broadcast(move |_| { + h.wait(); + rel.wait(); + }); + held.wait(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let r = Rect::new(0, 0, 2 * TILE_SIZE, 2 * TILE_SIZE); + let mut s = Surface::new(PixelFormat::RGBA8); + s.write_region(r, &vec![0.5; (r.width() * r.height() * 4) as usize]); + tx.send(()).ok(); + }); + let done = rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(); + release.wait(); + assert!(done, "write_region waited on the Rayon pool"); + } + #[test] fn from_rgba_into_matches_from_rgba() { for fmt in [PixelFormat::RGBA8, PixelFormat::GRAY8, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::new(ColorMode::Lab, SampleType::F32, true)] { diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 5cf6baea12..39eef20757 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -283,7 +283,7 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } // Unpremultiply. unpremultiply(&mut acc, n, stride, format.sample); - surface.write_region(rect, &acc); + surface.write_region_par(rect, &acc); surface.prune(); Rendered { surface, rect } } diff --git a/docs/text-raster-performance.md b/docs/text-raster-performance.md index 87ccdf849f..5bc446ead1 100644 --- a/docs/text-raster-performance.md +++ b/docs/text-raster-performance.md @@ -25,8 +25,10 @@ reads and writes only its own slots. ## What runs in parallel now -`composite`, `unpremultiply` and the tile conversion of `write_region` run on all cores (rayon, -native only; the web build keeps the serial loops, since wasm has no threads). Nothing about them +`composite`, `unpremultiply` and the tile conversion run on all cores (rayon, native only; the web +build keeps the serial loops, since wasm has no threads). The rasterizer writes its tiles with +`Surface::write_region_par`; `Surface::write_region` stays serial, because mask builds call it inside +a `OnceLock` initializer, which must not enter rayon. Nothing about them is order-dependent, so each pixel still sees the same operations on the same inputs — the pixels are identical, not merely close. @@ -37,7 +39,8 @@ are identical, not merely close. `crates/text/src/tests.rs::parallel_raster_matches_a_single_thread` renders the shapes the pipeline can take (plain, faux bold italic, no antialias, wide gamut, a wrapping paragraph box; 8/16/32-bit) on a one-thread pool and on the default pool and compares the surfaces; `crates/raster`'s existing -region proptests compare `write_region` against its serial reference implementation, tile for tile. +`region_writes_match_scalar_bytes_for_every_format` compares `write_region` and `write_region_par` +against a scalar reference implementation, tile for tile. ## What stays on one thread, and why From 7b99c329234a5d40985a14c9ab0d17578426879b Mon Sep 17 00:00:00 2001 From: Ahmed Hosny Date: Mon, 19 Oct 2026 20:30:56 +0300 Subject: [PATCH 217/304] Color Lookup: warn when it opens without its usable table (part of #1763) (#6322) * fix(io): warn when a Color Lookup opens without its usable table A Color Lookup whose table is shorter than its size-cubed grid (or missing altogether) silently rendered as the identity. Native .pcraft loads and native-path automation opens carried no import warnings, so the loss was silent. The new io::open_warnings reports it naming the layer, and both load seams surface it. Also add a Photoshop-layout clrL regression test: a version-1 block carrying a CUBE descriptor round-trips through PSB and reopens as a working Color Lookup that recolors a red backdrop (#1763). * cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/src/files.rs | 6 ++- crates/io/src/lib.rs | 33 +++++++++++++- crates/io/tests/adjust_warnings.rs | 69 ++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index cd34ab3410..fc4526eedb 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -19,7 +19,8 @@ pub struct Opened { /// Decode a document that was read through a filesystem capability. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) { - return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } let r = photocraft_io::import(name, bytes)?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) @@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { if path.is_dir() { - return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); + let document = photocraft_format::load_path(path)?; + return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 3543cd359c..02381d9831 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -69,7 +69,7 @@ pub mod vector_map; pub use photocraft_codecs::DpxBits; use photocraft_codecs::{CodecError, EncodeOptions}; -use photocraft_doc::Document; +use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; @@ -140,6 +140,34 @@ pub(crate) fn unequal_resolution_warning(x: f64, y: f64) -> Option { .then(|| format!("the vertical resolution ({y:.2} ppi) differs from the horizontal ({x:.2} ppi); the document keeps {x:.2} ppi for both")) } +/// Warnings about a loaded document's layers that render without their stored effect, for +/// sources that carry no import warnings of their own (a crafted `.pcraft`): a Color Lookup +/// whose table can't be applied silently renders as the identity instead (#1763). +pub fn open_warnings(doc: &Document) -> Vec { + let mut out = Vec::new(); + // Iterative, so no document's nesting can make this recurse. + let mut stack: Vec<&Layer> = doc.layers.iter().rev().collect(); + while let Some(l) = stack.pop() { + if let LayerContent::Adjustment(Adjustment::ColorLookup { name: _, size, lut, .. }) = &l.content { + match lut.as_deref().zip(photocraft_doc::adjust::lut3d_len(*size)) { + Some((table, len)) if table.len() >= len => {} + Some((table, len)) => out.push(format!( + "layer \"{}\": its Color Lookup table has {} values, but a {size}³ lookup needs {len}; the layer renders without the lookup", + l.name, + table.len() + )), + None => { + out.push(format!("layer \"{}\": its Color Lookup has no usable table for a {size}³ lookup; the layer renders without the lookup", l.name)) + } + } + } + if let Some(children) = l.children() { + stack.extend(children.iter().rev()); + } + } + out +} + /// Result of [`export`]. #[derive(Debug, Clone)] pub struct ExportResult { @@ -276,7 +304,8 @@ pub fn import_with_svg_group_depth_and_interrupt( fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { - return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(ImportResult { warnings: open_warnings(&document), document, source_read_only: false, preview_only: false }); } if is_psd(bytes) { // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. diff --git a/crates/io/tests/adjust_warnings.rs b/crates/io/tests/adjust_warnings.rs index 9b91f40bcf..c5bba01827 100644 --- a/crates/io/tests/adjust_warnings.rs +++ b/crates/io/tests/adjust_warnings.rs @@ -5,6 +5,8 @@ use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_geom::Size; use photocraft_io::*; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use photocraft_raster::Surface; fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec) { let mut d = Document::new("a", Size::new(16, 16), ColorMode::Rgb, SampleType::U8); @@ -40,3 +42,70 @@ fn readable_adjustments_open_without_warnings() { assert!(!warnings.iter().any(|w| w.contains("Teal Look")), "{warnings:?}"); } } + +/// The raw bytes of a `clrL` block laid out the way Photoshop writes it (Adobe spec): version 1, +/// then a version 16 descriptor carrying a small `.cube` LUT file (its entry for red maps to +/// cyan, so the table is not the identity). +fn photoshop_clr_l_block() -> Vec { + let cube = b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) }; + let text = |t: &str| Value::Text(UnicodeString::new_nul(t)); + let desc = Descriptor::new("null") + .with("NM ", text("Frost")) + .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) + .with("LUT3DFileName", text("frost.cube")) + .with("LUT3DFileData", Value::RawData(cube.to_vec())); + let mut raw = vec![0, 1]; + raw.extend(VersionedDescriptor::new(desc).to_bytes()); + assert_eq!(&raw[..6], &[0, 1, 0, 0, 0, 16], "version 1, then the descriptor's version 16"); + raw +} + +#[test] +fn photoshop_layout_color_lookup_round_trips_through_psb() { + // A kept-raw `clrL` block in Photoshop's layout survives a PSB round trip and reopens as a + // working Color Lookup, with no warning naming the layer (#1763). + let raw = photoshop_clr_l_block(); + let opts = ExportOptions { force_psb: true, ..Default::default() }; + let (doc, warnings) = reopen(Adjustment::Unsupported { psd_key: "clrL".into(), raw: raw.clone() }, "x.psb", &opts); + assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}"); + let LayerContent::Adjustment(Adjustment::ColorLookup { name, size, lut: Some(table), tetrahedral: false, dither: false }) = &doc.layers[0].content else { + panic!("{:?}", doc.layers[0].content) + }; + assert_eq!(name, "frost.cube"); + assert_eq!(*size, 2); + let want: Vec = [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0].into(); + assert_eq!(table.as_slice(), want.as_slice()); + // The exporter wrote the block verbatim (the importer keeps it for saving; the block writer + // pads to Photoshop's four-byte alignment, which the spec requires). + let kept = doc.layers[0].psd_blocks.iter().find(|(k, _)| k == b"clrL").map(|(_, d)| d.as_slice()); + let Some(kept) = kept else { panic!("{:?}", doc.layers[0].psd_blocks) }; + assert!(kept.starts_with(&raw) && kept[raw.len()..].iter().all(|&b| b == 0), "{kept:?}"); + // The reopened lookup really applies: a red backdrop renders as the table's cyan. + let mut d = doc; + let mut bg = Surface::new(d.pixel_format()); + bg.fill_rect(d.bounds(), &[1.0, 0.0, 0.0, 1.0]); + d.layers.insert(0, Layer::new("bg", LayerContent::Raster(bg))); + let px = photocraft_compose::flatten(&d).px[0]; + assert!(px[0] < 0.05 && (px[1] - 1.0).abs() < 0.05 && (px[2] - 1.0).abs() < 0.05, "{px:?}"); +} + +#[test] +fn short_color_lookup_table_warns_on_open() { + // A stored table shorter than size³×3 renders as the identity; opening must say so, naming + // the layer (#1763). + let short = Adjustment::ColorLookup { name: "t.cube".into(), size: 3, lut: Some(std::sync::Arc::new(vec![0.5; 7])), tetrahedral: false, dither: false }; + let (doc, warnings) = reopen(short, "x.pcraft", &ExportOptions::default()); + assert!(matches!(&doc.layers[0].content, LayerContent::Adjustment(Adjustment::ColorLookup { .. })), "{:?}", doc.layers[0].content); + let w: Vec<&String> = warnings.iter().filter(|w| w.contains("Teal Look")).collect(); + assert_eq!(w.len(), 1, "{warnings:?}"); + assert!(w[0].contains("Color Lookup") && w[0].contains("7 values") && w[0].contains("81"), "{}", w[0]); + // A table long enough for its size opens without the note. + let ok = Adjustment::ColorLookup { name: "t.cube".into(), size: 2, lut: Some(std::sync::Arc::new(vec![0.5; 24])), tetrahedral: false, dither: false }; + let (_, warnings) = reopen(ok, "x.pcraft", &ExportOptions::default()); + assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}"); + // A missing table renders as the identity too. + let none = Adjustment::ColorLookup { name: "t.cube".into(), size: 2, lut: None, tetrahedral: false, dither: false }; + let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default()); + assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}"); +} From acb3a66fd598f19ebb3a293f5fe767692780af13 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:31:10 +0200 Subject: [PATCH 218/304] Crop: a preset picked in Tool Presets fits the frame to its ratio (#7143) (#7144) select_tool_preset only merged the preset's tool options, so a crop preset's ratio was set without refitting the frame; the options bar's dropdown already refits. It now compares the ratio before and after and calls crop_ui::apply_ratio when it changed, as the dropdown does. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/preset_panels.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 1cd542dbf3..2741f9170c 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1002,6 +1002,7 @@ fn tool_id(t: Tool) -> String { /// Apply a tool preset: the engine sets the brush/colour, the shell switches tool and options. pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { + let before_ratio = crate::crop_ui::preset_ratio(app); let Some(r) = run(app, "tool.presets.select", json!({"preset": name})) else { return }; if let Some(tool) = r["tool"].as_str().and_then(Tool::from_name) { crate::tool_memory::select(app, tool); @@ -1023,6 +1024,10 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { app.ui.tool_options = opts; } } + // As the options bar does: a crop preset's ratio (or W x H) fits the frame at once. + if crate::crop_ui::preset_ratio(app) != before_ratio { + crate::crop_ui::apply_ratio(app); + } } /// The `tool.presets.new` params for the + button: `tool`'s own options only, so choosing the @@ -1319,6 +1324,20 @@ mod tests { assert_eq!(Tool::from_name(&tool_id(Tool::CustomShape)), Some(Tool::CustomShape)); } + /// A crop preset picked in Tool Presets fits the frame to its ratio at once, as the options + /// bar's dropdown does. + #[test] + fn a_crop_tool_preset_fits_the_frame_to_its_ratio() { + let (mut app, _) = app(); + app.ui.tool = Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 64.0, 48.0])); + app.run("tool.presets.new", json!({"name": "Square", "tool": "crop", "options": {"toolOptions": {"crop_ratio": "1:1"}}})).unwrap(); + select_tool_preset(&mut app, "Square"); + assert_eq!(app.ui.tool_options.crop_ratio, "1:1"); + assert_eq!(app.ui.crop_rect, Some([8.0, 0.0, 56.0, 48.0])); + } + /// #7356: a preset made with + keeps only its own tool's options, so choosing it doesn't /// reset the Crop tool's Delete Cropped Pixels or the Move tool's Auto-Select. #[test] From 5b1f9372874cd2823788ad9e99c537e04d416c26 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:31:26 -0600 Subject: [PATCH 219/304] Actions: a function key can take Shift and Command (#7646) (#7674) Photoshop's Action Options pair the function key with Shift and Command checkboxes. The Actions row menu offered plain F-keys only, and .atn import dropped any key with a modifier. The row menu gets Shift and Command (Ctrl off the Mac) toggles, and .atn load and save keep the modifiers. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 37 ++++++++++++++---- crates/engine/src/atn_cmds/tests.rs | 28 ++++++++++++++ crates/ui-egui/src/actions.rs | 60 ++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/ru.tsv | 1 + 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 040afaca42..ffa29aec14 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3123,8 +3123,8 @@ pub fn import(bytes: &[u8]) -> std::result::Result { let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; for a in &file.actions { let action = convert_action(a, &set, &mut out.unsupported); - if a.function_key > 0 && !a.shift && !a.command { - out.function_keys.push((action.name.clone(), format!("F{}", a.function_key))); + if a.function_key > 0 { + out.function_keys.push((action.name.clone(), function_key_name(a.function_key, a.shift, a.command))); } out.actions.push(action); } @@ -3163,8 +3163,32 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) } +/// An action's key as a shortcut string: `F5`, with Photoshop's Shift and Command options +/// `Shift+F5`, `Cmd+F5` or `Cmd+Shift+F5` (#7646). +pub fn function_key_name(key: u16, shift: bool, command: bool) -> String { + format!("{}{}F{key}", if command { "Cmd+" } else { "" }, if shift { "Shift+" } else { "" }) +} + +/// The `.atn` function key (1…15), Shift and Command of a shortcut string, or `None` for +/// anything else. +pub fn parse_function_key(key: &str) -> Option<(u16, bool, bool)> { + let (mut shift, mut command) = (false, false); + let mut rest = key; + loop { + if let Some(r) = rest.strip_prefix("Cmd+").filter(|_| !command) { + (command, rest) = (true, r); + } else if let Some(r) = rest.strip_prefix("Shift+").filter(|_| !shift) { + (shift, rest) = (true, r); + } else { + break; + } + } + let n = rest.strip_prefix('F')?.parse::().ok().filter(|n| (1..=15).contains(n))?; + Some((n, shift, command)) +} + /// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key -/// (`F1`…`F15`). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. +/// (`F1`…`F15`, with `Shift+` and `Cmd+` as [`function_key_name`] writes them). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. pub fn export( set: &str, actions: &[&Action], @@ -3175,11 +3199,8 @@ pub fn export( let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() }; for a in actions { let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; - if let Some((_, key)) = function_keys.iter().find(|(n, _)| n == &a.name) - && let Some(n) = key.strip_prefix('F').and_then(|n| n.parse::().ok()) - && (1..=15).contains(&n) - { - out.function_key = n; + if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { + (out.function_key, out.shift, out.command) = (n, shift, command); } for (i, (id, params)) in a.steps.iter().enumerate() { let Some(mut item) = export_item(id, params, set) else { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index f50189fb0f..1b91235428 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1219,3 +1219,31 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); } } + +/// #7646: an action's function key keeps Photoshop's Shift and Command options through +/// `.atn` load and save, as `Shift+F5`, `Cmd+F5` and `Cmd+Shift+F5` shortcuts. +#[test] +fn function_keys_keep_shift_and_command() { + let mut file = sample(); + file.actions[1].function_key = 5; + file.actions[1].shift = true; + file.actions[1].command = true; + file.actions[0].shift = true; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["functionKeys"], json!([{"action": "Soften", "key": "Shift+F3"}, {"action": "Finish", "key": "Cmd+Shift+F5"}])); + let shortcuts = &s.prefs().shortcuts; + assert_eq!(shortcuts.get("actions.play:Soften").map(String::as_str), Some("Shift+F3")); + assert_eq!(shortcuts.get("actions.play:Finish").map(String::as_str), Some("Cmd+Shift+F5")); + let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap(); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let keys: Vec<_> = saved.actions.iter().map(|a| (a.function_key, a.shift, a.command)).collect(); + assert_eq!(keys, [(3, true, false), (5, true, true)]); + + assert_eq!(super::parse_function_key("Cmd+F12"), Some((12, false, true))); + assert_eq!(super::parse_function_key("Shift+Cmd+F2"), Some((2, true, true))); + for bad in ["F16", "F0", "Alt+F2", "Cmd+Cmd+F2", "Shift+", "Cmd+K", ""] { + assert_eq!(super::parse_function_key(bad), None, "{bad}"); + } + assert_eq!(super::function_key_name(7, false, false), "F7"); +} diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index cdffc378dd..8b52ba88c5 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -824,16 +824,28 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.actions.expanded[i] = !expanded; } } - resp.context_menu(|ui| { + // Clicks inside stay open, so Shift and Command can both be ticked; every item that + // finishes closes the menu itself. + egui::Popup::context_menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.label(tl!("Keyboard Shortcuts")); let current = app.session.prefs().shortcuts.get(&shortcut_id(name)).cloned().unwrap_or_default(); - for key in std::iter::once(String::new()).chain((1..=12).map(|n| format!("F{n}"))) { - let label = if key.is_empty() { tl!("None").to_string() } else { key.clone() }; - if ui.selectable_label(current == key, label).clicked() { - let _ = assign_shortcut(app, name, &key); + // Photoshop's Action Options: a function key, with Shift and Command (#7646). + let (key, mut shift, mut command) = photocraft_engine::atn_cmds::parse_function_key(¤t).unwrap_or((0, false, false)); + for n in std::iter::once(0).chain(1..=12) { + let label = if n == 0 { tl!("None").to_string() } else { format!("F{n}") }; + if ui.selectable_label(key == n, label).clicked() { + let chosen = if n == 0 { String::new() } else { photocraft_engine::atn_cmds::function_key_name(n, shift, command) }; + let _ = assign_shortcut(app, name, &chosen); ui.close(); } } + ui.add_enabled_ui(key > 0, |ui| { + let command_label = if cfg!(target_os = "macos") { tl!("Command") } else { tl!("Ctrl") }; + let toggled = ui.checkbox(&mut shift, tl!("Shift")).changed() | ui.checkbox(&mut command, command_label).changed(); + if toggled { + let _ = assign_shortcut(app, name, &photocraft_engine::atn_cmds::function_key_name(key, shift, command)); + } + }); ui.separator(); ui.menu_button(tl!("Color"), |ui| { for (id, label) in [ @@ -1034,6 +1046,44 @@ mod tests { assert!(crate::dock::Group::History.scrolls_itself(1)); } + /// #7646: an action's function key can take Shift and Command, as in Photoshop's Action + /// Options, and only that combination plays it. + #[test] + fn a_function_key_with_shift_and_command_plays_its_action() { + use egui::{Event, Key, Modifiers}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.session.actions.list.push(Action { + name: "Action 1".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let key = photocraft_engine::atn_cmds::function_key_name(2, true, true); + assert_eq!(key, "Cmd+Shift+F2"); + assign_shortcut(&mut app, "Action 1", &key).unwrap(); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, modifiers| { + ctx.begin_pass(egui::RawInput { + events: vec![ + Event::ModifiersChanged(modifiers), + Event::Key { key: Key::F2, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::F2, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len(); + let before = layers(&app); + press(&mut app, Modifiers::NONE); + press(&mut app, Modifiers::SHIFT); + assert_eq!(layers(&app), before, "F2 and Shift+F2 alone don't play it"); + press(&mut app, Modifiers::SHIFT | Modifiers::COMMAND); + assert_eq!(layers(&app), before + 1, "Cmd+Shift+F2 plays it"); + } + #[test] fn double_clicking_an_action_name_renames_it_in_place() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 943ebcb9c9..50b0081146 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3204,6 +3204,7 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + Command Command Use Использовать In Front Спереди Behind Сзади From 81d5c514e2a8a4f26bfef8302279deb5307b5ba3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 23:01:33 +0530 Subject: [PATCH 220/304] perf(photomerge): build multi-band bands in bounded batches (#7758) (#7763) * perf(photomerge): build multi-band bands in bounded batches (#7758) panorama::multiband built every source's Laplacian and weight pyramids at once and kept them all until accumulation, so Photomerge's peak memory grew with the image count (~5 GB for four 24 MP sources). Build the bands in batches under a 2 GiB estimate (at least one image) and add each batch before building the next. Each pixel still adds the bands in image order, so the output is bit-identical. Planes now move into the pyramids instead of being cloned, and the coverage and push-pull masks are freed once used: with one image built at a time those clones were serial passes. seam_blend shifts its ROIs in place instead of cloning every source for the blend. Peak during the blend: 4x24 MP 5.04 -> 2.81 GB, 8x12 MP 5.50 -> 2.61 GB, at +5% time (2x24 MP unchanged). New example bench_multiband measures it. Co-Authored-By: Claude Opus 5.5 * perf(photomerge): let up to four 24 MP sources build their bands together A 2 GiB band budget built every 24 MP source alone, which made the usual 2-4 image Photomerge 12-36% slower on a 32-thread desktop. The native budget is now 5 GiB, so those still build side by side and only larger stacks are batched. wasm32 has no threads, so it adds each band as soon as it is built. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/algo/examples/bench_multiband.rs | 42 +++++++ crates/algo/src/panorama.rs | 156 +++++++++++++++++------- crates/engine/src/photo_cmds.rs | 14 ++- 3 files changed, 164 insertions(+), 48 deletions(-) create mode 100644 crates/algo/examples/bench_multiband.rs diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs new file mode 100644 index 0000000000..9d58b81a54 --- /dev/null +++ b/crates/algo/examples/bench_multiband.rs @@ -0,0 +1,42 @@ +//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels +//! (3:2), each overlapping the next by half its width. +//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP). +//! Peak memory is the growth of the process's high-water mark during the blend (Linux only). +use std::time::Instant; + +use photocraft_algo::panorama::{self, RoiImage}; + +/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it. +fn peak_mib() -> Option { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; + Some(kb / 1024.0) +} + +fn main() { + let mut args = std::env::args().skip(1); + let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1); + let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0); + let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2); + let sw = sh * 3 / 2; + let step = sw / 2; + let (cw, ch) = (step * (n - 1) + sw, sh); + let rois: Vec = (0..n) + .map(|k| { + let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect(); + RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] } + }) + .collect(); + // Each canvas column belongs to the image whose centre is nearest, as a seam would split it. + let wts: Vec> = (0..n) + .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect()) + .collect(); + let levels = panorama::blend_levels(cw, ch).min(7); + let before = peak_mib(); + let t0 = Instant::now(); + let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels); + let ms = t0.elapsed().as_secs_f64() * 1000.0; + let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b)); + println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend"); + std::hint::black_box((px, cov)); +} diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index abe17db395..75fa9a4bfb 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -1170,10 +1170,28 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize }) } +/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built +/// and accumulated in batches under it, so its peak memory doesn't grow with the image count +/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side +/// uses a many-core machine better than one band's row-parallel work, and one at a time made the +/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop. +#[cfg(not(target_arch = "wasm32"))] +const BAND_BUDGET: usize = 5 << 30; +/// Without threads the bands of a batch are built one after another anyway, so each is added as +/// soon as it is built (the browser's address space is 4 GB). +#[cfg(target_arch = "wasm32")] +const BAND_BUDGET: usize = 0; + /// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image /// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour /// channels (`ch` per pixel) and the total coverage. pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) { + multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET) +} + +/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one +/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it. +fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize, budget: usize) -> (Vec, Vec) { let ch = imgs.first().map_or(1, |i| i.ch); let levels = levels.max(1); let align = 1i32 << (levels - 1); @@ -1187,13 +1205,22 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le let mut wsum: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect(); let margin = align * 4; // Each image on its ROI grown by a margin and aligned to the coarsest level's grid. - let bands = par_map(imgs.len(), |k| { - let im = &imgs[k]; + let grid = |im: &RoiImage| { let gx0 = ((im.x0 - margin).max(0) / align) * align; let gy0 = ((im.y0 - margin).max(0) / align) * align; let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32); let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32); - let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize); + (gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize) + }; + // Building one band holds its grid's colour and weight planes about three times over: the + // pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top. + let cost = |im: &RoiImage| { + let (_, _, gw, gh) = grid(im); + gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3) + }; + let band = |k: usize| -> PyramidBand { + let im = &imgs[k]; + let (gx0, gy0, gw, gh) = grid(im); // Each grid pixel takes the image pixel that lands on it (every image pixel lands on at // most one), so rows of the grid are independent and fill in parallel. let src_of = |gx: usize, gy: usize| -> Option { @@ -1225,68 +1252,85 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le } }); let bin: Vec = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 }); + drop(cov); push_pull(gw, gh, ch, &mut px, &bin); - // Laplacian of the colours, Gaussian of the weights. + drop(bin); + // Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather + // than being copied: with one image built at a time, a copy is a serial pass over it. let mut lap: Vec<(usize, usize, Vec)> = Vec::new(); let mut gau: Vec> = Vec::new(); let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt); for l in 0..levels { if l + 1 == levels { - lap.push((cw, chh, cur.clone())); - gau.push(cwt.clone()); + lap.push((cw, chh, cur)); + gau.push(cwt); break; } let (nw, nh, down) = reduce(cw, chh, ch, &cur); let up = expand(nw, nh, ch, &down, cw, chh); lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b))); - gau.push(cwt.clone()); let (_, _, wdown) = reduce(cw, chh, 1, &cwt); + gau.push(cwt); cw = nw; chh = nh; cur = down; cwt = wdown; } (gx0, gy0, lap, gau) - }); - // Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per - // level, bands one after another), but rows of a level are independent, so they run in - // parallel: every row walks the bands itself. - for l in 0..levels { - let pw = sizes[l].0; - par_rows(&mut wsum[l], pw, 1, |py, row| { - for b in &bands { - let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue }; - for x in 0..lw { - let px = ox + x; - if px >= pw { - break; - } - let k = g[x]; - if k <= 0.0 { - continue; - } - row[px] += k; - } - } - }); - par_rows(&mut acc[l], pw, ch, |py, row| { - for b in &bands { - let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue }; - for x in 0..lw { - let px = ox + x; - if px >= pw { - break; - } - let k = g[x]; - if k <= 0.0 { - continue; - } - for c in 0..ch { - row[px * ch + c] += k * band[x * ch + c]; + }; + // Build the bands a batch at a time and add each batch before building the next. Each pixel + // adds the bands in the same order as the serial loop (per level, bands one after another; + // batches follow each other in image order), but rows of a level are independent, so they + // run in parallel: every row walks the batch's bands itself. + let mut start = 0; + while start < imgs.len() { + let mut end = start + 1; + let mut bytes = cost(&imgs[start]); + while let Some(im) = imgs.get(end) + && bytes.saturating_add(cost(im)) <= budget + { + bytes = bytes.saturating_add(cost(im)); + end += 1; + } + let bands = par_map(end - start, |j| band(start + j)); + for l in 0..levels { + let pw = sizes[l].0; + par_rows(&mut wsum[l], pw, 1, |py, row| { + for b in &bands { + let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { + let px = ox + x; + if px >= pw { + break; + } + let k = g[x]; + if k <= 0.0 { + continue; + } + row[px] += k; } } - } - }); + }); + par_rows(&mut acc[l], pw, ch, |py, row| { + for b in &bands { + let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { + let px = ox + x; + if px >= pw { + break; + } + let k = g[x]; + if k <= 0.0 { + continue; + } + for c in 0..ch { + row[px * ch + c] += k * band[x * ch + c]; + } + } + } + }); + } + start = end; } for l in 0..levels { let pw = sizes[l].0; @@ -1910,6 +1954,28 @@ mod parallel_tests { assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len()); } + /// Batching the bands (#7758) changes when they are built, not the result. Budgets from none + /// (one image per batch) up to everything in steps of a fifth pass through every split of the + /// three images. + #[test] + fn multiband_batches_match_the_serial_code() { + let budgets: Vec = + std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect(); + for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() { + for ch in [1usize, 3] { + let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64); + for levels in [1usize, 3, 5] { + let want = ref_multiband(w, h, &imgs, &wts, levels); + for &budget in &budgets { + let got = multiband_batched(w, h, &imgs, &wts, levels, budget); + assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}"); + assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}"); + } + } + } + } + } + /// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so /// they hold on every platform). #[test] diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 72851b3679..c729afd0d5 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -402,10 +402,18 @@ pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seam .collect() }) .collect(); - // Shift ROIs to canvas-relative coordinates for the blend. - let local: Vec = rois.iter().map(|r| RoiImage { x0: r.x0 - canvas.x0, y0: r.y0 - canvas.y0, ..r.clone() }).collect(); + // Shift ROIs to canvas-relative coordinates for the blend, and back after: a shifted copy + // would hold every source's pixels twice (#7758). + for r in &mut rois { + r.x0 -= canvas.x0; + r.y0 -= canvas.y0; + } let levels = if seamless { panorama::blend_levels(cw, chh).min(7) } else { 1 }; - let (blend, cov) = panorama::multiband(cw, chh, &local, &weights, levels); + let (blend, cov) = panorama::multiband(cw, chh, &rois, &weights, levels); + for r in &mut rois { + r.x0 += canvas.x0; + r.y0 += canvas.y0; + } let cc = fmt.mode.color_channels(); let nch = fmt.channels(); let mut composite = Surface::new(fmt); From cbe6c4e13187a52316f4000275769cbbb9e94232 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:31:41 +0200 Subject: [PATCH 221/304] Guides: colours follow Crop, Canvas Size and rotations (#7724) (#7725) transform_doc_marks moved guide positions but not their colour entries, which are keyed by exact position: after a crop the coloured guide fell back to the preference colour and a guide that landed on its old place took the colour. Map the entries with the same expressions as the guides (swap orientation on quarter turns, clear on arbitrary angles). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/canvas_geom.rs | 9 +++++++++ crates/engine/src/canvas_geom/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 76142f9a75..99f7e4cc80 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -156,15 +156,24 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if m1.abs() < EPS && m2.abs() < EPS { g.horizontal.iter_mut().for_each(|y| *y = m3 * *y + ty); g.vertical.iter_mut().for_each(|x| *x = m0 * *x + tx); + // Colours are keyed by exact position, so they move by the same expressions. + for c in &mut g.colors { + c.position = if c.vertical { m0 * c.position + tx } else { m3 * c.position + ty }; + } } else if m0.abs() < EPS && m3.abs() < EPS { // A horizontal guide at y becomes a vertical one at x' = m2·y + tx, and vice versa. let v: Vec = g.horizontal.iter().map(|y| m2 * y + tx).collect(); let h: Vec = g.vertical.iter().map(|x| m1 * x + ty).collect(); g.horizontal = h; g.vertical = v; + for c in &mut g.colors { + c.position = if c.vertical { m1 * c.position + ty } else { m2 * c.position + tx }; + c.vertical = !c.vertical; + } } else { g.horizontal.clear(); g.vertical.clear(); + g.colors.clear(); } } diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index f27d9756fd..0dbf03d2ef 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -263,6 +263,28 @@ fn canvas_size_and_crop_move_vector_geometry() { assert!(mean < 1.0 / 255.0, "{mean}"); } +#[test] +fn guide_colours_follow_their_guides() { + const MAGENTA: [u8; 3] = [255, 0, 255]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "#ff00ff"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap(); + // Crop shifts both guides by 10: the colour goes with the guide at 20, not to the one now there. + s.execute("image.crop", json!({"x": 10, "y": 0, "width": W - 10, "height": H})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.vertical, vec![10.0, 20.0]); + assert_eq!((g.color(true, 10.0), g.color(true, 20.0)), (Some(MAGENTA), None)); + // A quarter turn makes it a horizontal guide at y = 10, still magenta. + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.horizontal, vec![10.0, 20.0]); + assert_eq!((g.color(false, 10.0), g.color(true, 10.0)), (Some(MAGENTA), None)); + // An arbitrary angle drops the guides, so their colours go too. + s.execute("image.rotation.arbitrary", json!({"angle": 33})).unwrap(); + assert!(doc(&s).guides.colors.is_empty()); +} + #[test] fn image_size_scales_vector_geometry() { let mut s = mixed(8, "rgb"); From fe64e5a2bde315793b36fc41879c8317c6d9142e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:31:49 +0200 Subject: [PATCH 222/304] Keep arbitrary canvas rotation interpolation in ATN export (#7630) Fixes #7504 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/atn_cmds.rs | 21 +++++++++++++++++++-- crates/engine/src/atn_cmds/tests.rs | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ffa29aec14..621b5bb4a8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2135,7 +2135,11 @@ fn rotate_from(d: &Descriptor) -> Mapped { } else if targets_layer(d) { return Mapped::Unsupported { harmless: false }; } else { - return step("image.rotation.arbitrary", json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }})); + let mut p = json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }}); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + p["interpolation"] = i.into(); + } + return step("image.rotation.arbitrary", p); }; none(if targets_layer(d) { layer } else { doc }) } @@ -2155,7 +2159,20 @@ fn rotate_to(id: &str, p: &Value) -> Option { if layer { d = d.with("null", target_ref("Lyr ")); } - Some(d.with("Angl", ang(angle))) + d = d.with("Angl", ang(angle)); + if id == "image.rotation.arbitrary" + && let Some(i) = p.get("interpolation") + { + // The command resamples with nearest, bilinear or bicubic; any other value can't be + // carried faithfully, so refuse rather than replay with a different interpolation. + let i = match i.as_str()? { + "nearestNeighbor" => "nearest", + i @ ("bicubic" | "bilinear" | "nearest") => i, + _ => return None, + }; + d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); + } + Some(d) } fn flip_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 1b91235428..b19dc25d8b 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1199,6 +1199,25 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); } +/// Arbitrary canvas rotation must keep its interpolation through a strict export: dropping it +/// silently replays the rotation with the default bicubic resampling. +#[test] +fn arbitrary_rotation_keeps_interpolation_through_atn() { + for params in [ + json!({"angle": 17, "direction": "ccw", "interpolation": "nearest"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bilinear"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bicubic"}), + json!({"angle": 17, "direction": "ccw"}), + ] { + let a = Action { name: "Rot".into(), steps: vec![("image.rotation.arbitrary".into(), params.clone())], ..Default::default() }; + let (bytes, skipped) = export("Rot", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, vec![("image.rotation.arbitrary".to_string(), params)]); + } +} + /// A filter recorded against a layer mask must not export as a plain filter that edits the /// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. #[test] From c78ec47f4bcc2d1a49086bcbc8a6d0d8bdce8ee3 Mon Sep 17 00:00:00 2001 From: zrsix1 <139825666+zrsix1@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:31:56 -0300 Subject: [PATCH 223/304] Type: a click inside the paragraph box off the glyphs moves the caret (#7606) While editing paragraph text, a click inside the box that missed the glyphs (left of a line's start, or below the last line) committed the edit. Like Photoshop, it now places the caret at the nearest position; a click outside the box still commits. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/type_tool.rs | 13 ++++++++++++- crates/ui-egui/src/type_tool_tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d07b8bb161..ef021438f3 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -237,6 +237,15 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { let _ = app.run("type.edit", json!({"layer": ed.layer, "box": [o.x, o.y, x1 - x0, y1 - y0], "coalesce": ed.session})); } +/// True when document point (`x`, `y`) lies inside the paragraph box of type layer `id` (false +/// for point text). +fn inside_box(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> bool { + let Some((bx, by, w, h)) = box_shape(app, id) else { return false }; + let Some((_, aff, _)) = layout(app, id) else { return false }; + let (tx, ty) = to_text(&aff, x, y); + tx >= bx && tx <= bx + w && ty >= by && ty <= by + h +} + /// Pointer down with the Type tool. Returns true when the press was consumed (no box drag). pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) -> bool { if let Some(ed) = app.ui.text_edit.clone() { @@ -247,7 +256,9 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } return true; } - if hit_layer(app, x, y) == Some(id) { + // Photoshop: anywhere inside the paragraph box places the caret, even off the glyphs + // (left of a line's start, below the last line); only a click outside it commits. + if hit_layer(app, x, y) == Some(id) || inside_box(app, id, x, y) { let off = hit_offset(app, id, x, y); if let Some(e) = app.ui.text_edit.as_mut() { e.caret = off; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 20e59a01d6..9b840236f0 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -964,6 +964,33 @@ fn temporary_type_transform_free_scale_center_scale_move_skew_and_rotation() { } } +/// Editing paragraph text, a click inside the box but off the glyphs moves the caret to the +/// nearest offset, as in Photoshop; a click outside the box still commits. +#[test] +fn click_inside_the_paragraph_box_off_the_glyphs_moves_the_caret() { + use crate::canvas::{ToolEvent, tool_event}; + // The box is 240 × 180; the two 32 pt lines only fill its top. + let (mut app, id) = transform_app(true, false, Affine::translate(400.0, 350.0)); + let (bx, by, _, h) = super::box_shape(&app, id).unwrap(); + let (l, aff, _) = layout(&mut app, id).unwrap(); + let click = |app: &mut PhotocraftApp, tx: f32, ty: f32| { + let p = aff.apply(Point::new(f64::from(tx), f64::from(ty))); + tool_event(app, ToolEvent::Down { x: p.x, y: p.y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: p.x, y: p.y }, Modifiers::NONE); + }; + let layers = app.session.active().unwrap().doc.layers.len(); + // Near the bottom of the box, just right of its left edge. + assert!(!photocraft_text::text_point_inside(&l, bx + 8.0, by + h - 8.0, 6.0), "the point is off the glyphs"); + click(&mut app, bx + 8.0, by + h - 8.0); + let ed = app.ui.text_edit.clone().expect("still editing"); + // "Hello é世界\n" is 10 characters: the start of the second line. + assert_eq!((ed.layer, ed.caret, ed.anchor), (id.0, 10, 10)); + // Outside the box the edit commits, without starting new text. + click(&mut app, bx - 40.0, by + h - 8.0); + assert!(app.ui.text_edit.is_none()); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers); +} + #[test] fn temporary_type_transform_noop_cancel_and_stale_capture() { use crate::canvas::{ToolEvent, tool_event}; From 4252fb7743cabbb2e41db9b5c65af7ef59fedde5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:32:07 +0200 Subject: [PATCH 224/304] Presets: Migrate Presets reads tool presets like the launch loader (#7375) (#7376) Migrate Presets deserialized the incoming tool presets all or nothing, so one damaged entry dropped every good one, and it skipped the checks the launch loader applies: blank or overlong names were imported and then lost on restart, and a large file pushed the library past MAX_PRESETS. Read the list with `presets::tools::load` and stop at MAX_PRESETS. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/migrate_cmds.rs | 52 ++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/migrate_cmds.rs b/crates/engine/src/migrate_cmds.rs index 18d2be8017..68fbffd25b 100644 --- a/crates/engine/src/migrate_cmds.rs +++ b/crates/engine/src/migrate_cmds.rs @@ -40,12 +40,14 @@ fn migrate(s: &mut Session, p: &Value) -> Result { let st = merge_groups(&mut ps.styles, groups(&v, "styles")); let sh = merge_groups(&mut ps.shapes, groups(&v, "shapes")); let pt = merge_groups(&mut ps.pattern_groups, groups(&v, "pattern_groups")); - // Tool presets are a flat list, matched by name. + // Tool presets are a flat list, matched by name. Read them like the launch loader does (entry by + // entry, checked names), and keep the library within its cap, so what we import survives a restart. let mut tp = 0usize; - if let Some(arr) = v.get("tool_presets").cloned() - && let Ok(incoming) = serde_json::from_value::>(arr) - { - for t in incoming { + if let Some(arr) = v.get("tool_presets").cloned() { + for t in crate::presets::tools::load(arr) { + if ps.tool_presets.len() >= crate::presets::tools::MAX_PRESETS { + break; + } if !ps.tool_presets.iter().any(|e| e.name == t.name) { ps.tool_presets.push(t); tp += 1; @@ -151,6 +153,46 @@ mod tests { let _ = std::fs::remove_file(file); } + #[test] + fn tool_presets_are_read_entry_by_entry_like_the_launch_loader() { + use crate::presets::tools::MAX_PRESETS; + let file = std::env::temp_dir().join(format!("pc-migrate-tools-{}.json", std::process::id())); + let migrate = |s: &mut Session, data: Value| { + std::fs::write(&file, serde_json::to_string(&data).unwrap()).unwrap(); + s.execute("edit.presets.migratePresets", json!({"path": file.to_string_lossy()})).unwrap() + }; + // A damaged entry no longer discards the good ones; blank and overlong names are skipped. + let mut s = Session::new(); + let before = s.presets.tool_presets.len(); + let r = migrate( + &mut s, + json!({"tool_presets": [ + {"name": "4x6 print", "tool": "crop", "options": {}}, + {"name": "old", "tool": null}, + {"name": " ", "tool": "crop"}, + {"name": "x".repeat(300), "tool": "crop"}, + {"name": " Padded ", "tool": "brush", "options": 5}, + ]}), + ); + assert_eq!(r["added"]["toolPresets"], 2); + assert_eq!(s.presets.tool_presets.len(), before + 2); + assert!(s.presets.tool_presets.iter().any(|t| t.name == "4x6 print")); + let padded = s.presets.tool_presets.iter().find(|t| t.name == "Padded").expect("trimmed name"); + assert_eq!(padded.options, json!({})); + // What was imported survives the next launch. + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(restored.presets.tool_presets, s.presets.tool_presets); + // The library stays within MAX_PRESETS. + let mut s = Session::new(); + let many: Vec = (0..MAX_PRESETS + 500).map(|i| json!({"name": format!("p{i}"), "tool": "brush"})).collect(); + let before = s.presets.tool_presets.len(); + let r = migrate(&mut s, json!({"tool_presets": many})); + assert_eq!(s.presets.tool_presets.len(), MAX_PRESETS); + assert_eq!(r["added"]["toolPresets"], MAX_PRESETS - before); + let _ = std::fs::remove_file(file); + } + #[test] fn bad_path_is_an_error() { let mut s = Session::new(); From 8c2cfdba6ae43a850b76fa56e44501058aeed8c6 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:32:14 +0300 Subject: [PATCH 225/304] fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612) (#6941) * fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612) * test(linux): cover the XDG state recovery folder (#6612) --- apps/photocraft/src/app_dirs.rs | 71 +++++++++++++++++++++++++++------ apps/photocraft/src/services.rs | 7 ++-- docs/control-protocol.md | 4 +- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 39d90f07bb..a75520f3c4 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -10,8 +10,8 @@ //! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows //! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`. //! -//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the -//! roaming profile ([`recovery_dir`]). +//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced +//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option { }) } -/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every -/// few minutes, so on Windows the platform convention puts them in the machine-local -/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and -/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before -/// a crashed document is recovered. The override and portable folders keep everything in one -/// place, and other platforms already keep these folders local. +/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few +/// minutes, so with the platform convention they stay out of what is synced with the settings: +/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming +/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy. +/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default +/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that +/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy +/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored. +/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put +/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the +/// system. +/// +/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed +/// document is recovered. The override and portable folders keep everything in one place. pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option { - if cfg!(windows) - && data.mode == Mode::Platform - && let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) - { - return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + if data.mode == Mode::Platform { + if cfg!(windows) { + if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) { + return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + } + } else if !cfg!(target_os = "macos") { + let state = env("XDG_STATE_HOME") + .map(PathBuf::from) + .filter(|s| s.is_absolute()) + .or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state"))); + if let Some(state) = state { + return Some(state.join("photocraft").join(RECOVERY)); + } + } } data.dir.as_ref().map(|d| d.join(RECOVERY)) } @@ -623,6 +640,34 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default + /// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows + /// and macOS don't read it, and an override or portable folder keeps them inside itself. + #[test] + fn autosaves_go_to_the_xdg_state_folder() { + let root = temp("recovery-xdg"); + let settings = root.join("home").join(".config").join("photocraft"); + let home = root.join("home"); + let state = root.join("state"); + let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None }; + let xdg = !cfg!(windows) && !cfg!(target_os = "macos"); + let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") }); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery"))); + let default = home.join(".local").join("state").join("photocraft").join("Recovery"); + assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone())); + let relative = PathBuf::from("relative/state"); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone())); + let empty = PathBuf::new(); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default)); + // No home at all: beside the settings, as before. + assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery"))); + for mode in [Mode::Override, Mode::Portable] { + let d = DataDir { dir: Some(settings.clone()), mode, warning: None }; + assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}"); + } + let _ = std::fs::remove_dir_all(&root); + } + /// An autosave bundle and its sidecar, as `format::Autosaver` writes them. fn autosave(dir: &Path, key: &str) { std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap(); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 20de0d6be8..80f0b2d3a1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -293,7 +293,7 @@ fn block_on(future: impl Future) -> T { /// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `/PhotoCraftData` in /// portable mode, else the platform convention. Everything the app persists lives under it -/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`]. +/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`]. pub fn config_dir() -> Option { crate::app_dirs::config_dir() } @@ -308,8 +308,9 @@ pub fn presets_dir() -> Option { config_dir().map(|d| d.join("Presets")) } -/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay -/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]). +/// Crash-recovery autosaves: under the settings directory, except where that is synced with the +/// settings: the Windows roaming profile and the Linux config folder (see +/// [`crate::app_dirs::resolve_recovery`]). fn recovery_dir() -> Option { crate::app_dirs::recovery_dir() } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e6d5c9591d..78f44b07d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -248,7 +248,9 @@ The desktop app stores them in `preferences.json` in the platform config directo `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft`; override with `PHOTOCRAFT_CONFIG_DIR`); autosaves go to its `Recovery` folder (on Windows to `%LOCALAPPDATA%\Photocraft\Recovery`, out of the roaming -profile; autosaves left by earlier versions move there at startup), and the window size and +profile, and on Linux to `$XDG_STATE_HOME/photocraft/Recovery`, by default +`~/.local/state/photocraft/Recovery`, out of the config folder; autosaves left by earlier +versions move there at startup), and the window size and position, the dock width and floating-panel geometry to `ui.ron` beside it (a file holding values that would crash startup is discarded). In portable mode (a `portable.txt` or `PhotoCraft.portable` file beside the executable, as in the Windows portable zip) that directory is `PhotoCraftData` next to the From e1c209eadb58ad5a84588513bd00089fbfe3ed50 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 20 Oct 2026 04:32:21 +1100 Subject: [PATCH 226/304] Hide the Hyphenate checkbox until layout hyphenates (#6710) (#6869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Hide the Hyphenate checkbox until layout actually hyphenates (#6710). The flag still round-trips to PSD and ⌥⇧⌘H still toggles it, but the Paragraph and Properties panels no longer advertise a no-op. * Assert Hyphenate stays hidden until layout hyphenates (#6710). The Paragraph properties and paragraph style options editor now have tests that fail if the no-op control is shown again. * Drop the duplicate #[test] on hyphenate_is_not_shown (#6710). Clippy -D warnings failed on duplicate-macro-attributes and unused mut on the harness that only queries the label. --- crates/ui-egui/src/type_panels_ui.rs | 23 ++++++++++++++++++++++- crates/ui-egui/src/type_tool.rs | 5 +---- crates/ui-egui/src/type_tool_tests.rs | 11 ++++++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index d97a649fba..86ef663209 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -443,7 +443,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s (tl!("Indent Right"), "end_indent_pt", "pt"), (tl!("Space Before"), "space_before_pt", "pt"), (tl!("Space After"), "space_after_pt", "pt"), - (tl!("Hyphenate"), "hyphenate", "bool"), + // Hyphenate is stored and round-trips to PSD, but layout never hyphenates (#6710). ]; for &(label, key, kind) in rows { let mut on = defined(¶_attrs, key); @@ -969,4 +969,25 @@ mod tests { assert!(h.state().ui.type_panels.spell.is_none(), "the × closed Check Spelling"); assert_eq!(h.state().session.active().unwrap().revision, revision, "and changed nothing"); } + + #[test] + fn hyphenate_is_not_shown_in_paragraph_style_options() { + // #6710: layout never hyphenates; the style editor must not advertise it. + use egui_kittest::kittest::Queryable; + let mut app = app_with_text("Hi"); + app.ui.type_panels.styles = true; + app.ui.type_panels.styles_tab = 1; + app.ui.type_panels.options = Some((true, 0)); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + windows(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + assert!(h.query_by_label("Hyphenate").is_none()); + } } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index ef021438f3..ee29a9e1d3 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -2398,10 +2398,7 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa apply(app, ui.ctx(), json!({"spaceAfter": v / u})); } }); - let mut hy = para.hyphenate; - if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() { - apply(app, ui.ctx(), json!({"hyphenate": hy})); - } + // Hyphenate is stored (⌥⇧⌘H / PSD AutoHyphenate) but layout never breaks words (#6710). ui.add_space(ROW_GAP); } } diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 9b840236f0..5cbca3e2b4 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1578,6 +1578,14 @@ mod multi_layer_formatting { h.run(); } + #[test] + fn hyphenate_is_not_shown_while_layout_does_not_hyphenate() { + // #6710: layout never hyphenates; the control must stay hidden until it does. + let (app, _) = fixture(); + let h = controls(app, true); + assert!(h.query_by_label("Hyphenate").is_none()); + } + #[test] fn properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary() { let (mut app, ids) = fixture(); @@ -1595,8 +1603,6 @@ mod multi_layer_formatting { h.run(); h.get_by_label("Center text").click(); h.run(); - h.get_by_label("Hyphenate").click(); - h.run(); number(&mut h, "Indent left margin", "9"); for (id, scale) in [(ids[0], 2.0), (ids[1], 4.0)] { let c = style(h.state(), id, 0); @@ -1605,7 +1611,6 @@ mod multi_layer_formatting { assert!(c.faux_bold); let p = text(h.state(), id).paragraph_runs()[0].style.clone(); assert_eq!(p.align, TextAlign::Center); - assert!(p.hyphenate); assert_eq!(p.start_indent_pt, 9.0); } assert_eq!(style(h.state(), ids[2], 0), untouched); From daa9abfe43803cf055e08b6d24a9919d8e10fbd0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:32:29 +0200 Subject: [PATCH 227/304] Disable Save for Web while a Free Transform is pending (#6737) (#6832) --- crates/ui-egui/src/menus.rs | 6 ++++++ crates/ui-egui/src/transform_tool.rs | 26 ++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5928d844b7..ea94379685 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -155,6 +155,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if let Some(r) = crate::prefs_ui::invoke(app, ctx, id, ¶ms) { return r; } + // Save for Web's dialog would export the committed document, not the open Free Transform (#6737). + if id == crate::transform_tool::SAVE_FOR_WEB + && let Some(r) = crate::transform_tool::intercept(app, id) + { + return r; + } // Save for Web, Print and the other File-menu dialogs added with slices. if let Some(r) = crate::file_ui::invoke(app, ctx, id, ¶ms) { return r; diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index bf0c4d8e87..7f4238ae82 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1250,6 +1250,9 @@ pub(crate) fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64) { } } +/// Save for Web (Legacy): refused under an open box, as it would export without the preview (#6737). +pub(crate) const SAVE_FOR_WEB: &str = "file.export.saveForWebLegacy"; + /// While transforming, Undo and Redo apply to the box: `Some(enabled)` for those commands (and /// Toggle Last State, which would change the document under the box), `None` for the rest. pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { @@ -1266,6 +1269,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { "edit.redo" => Some(steps.is_some_and(|s| !s.redo.is_empty())), // No Toggle Last State or history jump under the box, and one box at a time. "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), + // Save for Web exports the committed document and would ignore the pending box (#6737). + SAVE_FOR_WEB => Some(false), // Rotate and Flip turn the box (not while warping, where the mesh is the box). _ if box_preset(id).is_some() => Some(t.warp.is_none()), // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). @@ -1305,7 +1310,7 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { /// `None` for other commands or when not transforming. /// The commands the open box answers itself ([`is_enabled`], [`intercept`]). fn box_command(id: &str) -> bool { - matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some() + matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp" | SAVE_FOR_WEB) || box_preset(id).is_some() } pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { @@ -1316,7 +1321,7 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), - "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => { + "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" | SAVE_FOR_WEB => { Some(Err("Commit or cancel the transform first".into())) } _ => { @@ -3975,6 +3980,23 @@ mod tests { assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "another tool ends it"); } + /// #6737: Save for Web exports the committed document, so it is off while a box is pending. + #[test] + fn save_for_web_is_off_under_a_pending_transform() { + let ctx = egui::Context::default(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + assert!(crate::menus::is_enabled(&app, SAVE_FOR_WEB), "enabled with no box"); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap(); + assert!(!crate::menus::is_enabled(&app, SAVE_FOR_WEB), "off under the box"); + assert!(crate::menus::invoke(&mut app, &ctx, SAVE_FOR_WEB, json!({})).is_err(), "the dialog doesn't open"); + let path = std::env::temp_dir().join(format!("pc-web6737-{}.png", std::process::id())).to_string_lossy().into_owned(); + let r = crate::menus::invoke(&mut app, &ctx, SAVE_FOR_WEB, json!({"path": path, "format": "png24"})); + assert!(r.is_err() && !std::path::Path::new(&path).exists(), "nor does the export run"); + assert!(app.ui.transform.is_some(), "the box stays open"); + cancel(&mut app); + assert!(crate::menus::is_enabled(&app, SAVE_FOR_WEB), "back after the box closes"); + } + /// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step; /// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected /// pixels only. From a0128945056a58cf094a95b685d402f1c8f30d18 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:32:35 +0200 Subject: [PATCH 228/304] Layer Mask Properties: the Feather slider has fine steps near 0 px, as in Photoshop (#6545) (#6727) --- crates/ui-egui/src/adjust_editors.rs | 4 +- crates/ui-egui/src/filter_dialog.rs | 2 +- crates/ui-egui/src/mask_props_ui.rs | 8 +++- crates/ui-egui/src/mask_props_ui_tests.rs | 28 +++++++++++- crates/ui-egui/src/widgets.rs | 54 ++++++++++++++++++++++- 5 files changed, 87 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index fa32a3b392..0c4d5ae268 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -216,7 +216,7 @@ fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[SliderRow]) -> Edit { for &(key, text, min, max, default, unit, decimals) in rows { let mut x = num(v, key, default).clamp(min, max); // A double-click on the slider puts it back to its default (Photoshop). - let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None }; + let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None, ..Default::default() }; let r = widgets::slider_row_prec(ui, text, &mut x, min..=max, unit, None, gestures, decimals); if r.changed() { v[key] = json!(row_value(x, min, max, decimals)); @@ -1095,7 +1095,7 @@ fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { ]; // Photoshop: a double-click on a slider zeroes it (this tone only); the dialog's sliders step // with the wheel; Up/Down step the fields; Shift makes those steps 10. - let gestures = widgets::RowGestures { reset: Some(0.0), wheel_step: cx.dialog.then_some(1.0) }; + let gestures = widgets::RowGestures { reset: Some(0.0), wheel_step: cx.dialog.then_some(1.0), ..Default::default() }; let mut changed = false; for (i, (text, a, b)) in rows.iter().enumerate() { let r = Edit::of(&widgets::slider_row_with(ui, text, &mut vals[i], -100.0..=100.0, "", Some(&[*a, *b]), gestures)); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 14f24bc52e..effb52694d 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -811,7 +811,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.add_space(4.0); } else { // A double-click on the slider puts it back to the filter's default. - let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; + let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None, ..Default::default() }; crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); } }); diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index 295310bf97..26c3791d5a 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -66,8 +66,12 @@ fn sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: u64, document: u64 } g.0 }); - for (field, label, mut value, max, unit) in [("density", tl!("Density"), density * 100.0, 100.0, "%"), ("feather", tl!("Feather"), feather, 1000.0, "px")] { - let response = widgets::slider_row(ui, label, &mut value, 0.0..=max, unit, None); + // Feather's track is curved like Photoshop's, so a few px can be dragged to (#6545). + for (field, label, mut value, max, unit, curve) in + [("density", tl!("Density"), density * 100.0, 100.0, "%", None), ("feather", tl!("Feather"), feather, 1000.0, "px", Some(3.0))] + { + let gestures = widgets::RowGestures { curve, ..Default::default() }; + let response = widgets::slider_row_with(ui, label, &mut value, 0.0..=max, unit, None, gestures); response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(value), label)); if response.changed() { let mut params = json!({"document": document, "layer": layer, diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 1cde904682..660b94a155 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -94,8 +94,32 @@ fn mask_slider_drags_preview_live_and_undo_as_separate_gestures() { h.state_mut().run("edit.redo", json!({})).unwrap(); assert!((mask(&h).density - 0.5).abs() < 0.01); h.run_steps(3); - drag(&mut h, 1, &[0.0, 0.01, 0.02]); - assert!((mask(&h).feather - 20.0).abs() < 0.1); + // #6545: Feather's track is curved like Photoshop's, so a fifth of the way is 8 px, not 200. + drag(&mut h, 1, &[0.0, 0.1, 0.2]); + assert!((mask(&h).feather - 8.0).abs() < 0.3, "feather {}", mask(&h).feather); +} + +/// #6545: the curve is only the track's. A typed Feather and the arrow keys stay exact px, and +/// drawing the knob for them never writes the value back. +#[test] +fn typed_and_stepped_feather_stays_exact() { + let mut h = harness(false, crate::theme::ThemeKind::Pro); + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("5".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(mask(&h).feather, 5.0); + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().click(); + h.run_steps(1); + h.key_press(egui::Key::ArrowUp); + h.run_steps(3); + assert_eq!(mask(&h).feather, 6.0); + let before = h.state().session.active().unwrap().history.past_len(); + h.run_steps(5); + assert_eq!(mask(&h).feather, 6.0); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before, "idle frames write nothing"); } #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b393c0787d..19f47830d9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -926,6 +926,34 @@ pub struct RowGestures { /// Each wheel notch over the slider moves the value by this step (up = higher; ×10 with /// Shift). Photoshop does this in dialogs, not in the Properties panel. pub wheel_step: Option, + /// A curved track: the knob at fraction f of the track is `lo + (hi - lo) * f^k`, so small + /// values get fine steps (Photoshop's mask Feather, #6545). Typed, scrubbed and stepped + /// values stay linear; only the track maps through the curve. + pub curve: Option, +} + +/// The value at fraction `f` of a track over `lo..=hi`, through `curve` (see [`RowGestures`]). +pub(crate) fn curve_value(f: f32, lo: f32, hi: f32, curve: Option) -> f32 { + match curve { + Some(k) if k.is_finite() && k > 0.0 => lo + f.clamp(0.0, 1.0).powf(k) * (hi - lo), + _ => lo + f * (hi - lo), + } +} + +/// Where `v` sits on a track over `lo..=hi`, as a fraction: the inverse of [`curve_value`]. +pub(crate) fn curve_fraction(v: f32, lo: f32, hi: f32, curve: Option) -> f32 { + let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0); + match curve { + // An unset (NaN) value draws the knob at the start rather than nowhere. + Some(k) if k.is_finite() && k > 0.0 => { + if f.is_finite() { + f.powf(1.0 / k) + } else { + 0.0 + } + } + _ => f, + } } /// Wheel notches this frame while the pointer is over `resp` (Shift ×10). Wheel lines add up in @@ -1010,7 +1038,7 @@ fn slider_with(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive f32::EPSILON { *value = nv; resp.mark_changed(); @@ -1033,7 +1061,7 @@ fn slider_with(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive {back}"); + let v = i as f32 * 10.0; + let again = curve_value(curve_fraction(v, 0.0, 1000.0, Some(3.0)), 0.0, 1000.0, Some(3.0)); + assert!((again - v).abs() < 1e-3, "{v} -> {again}"); + } + assert_eq!(curve_fraction(-5.0, 0.0, 1000.0, Some(3.0)), 0.0); + assert_eq!(curve_fraction(f32::NAN, 0.0, 1000.0, Some(3.0)), 0.0); + } + #[test] fn arrow_keys_work_out_a_typed_sum_then_step_it() { let mut h = focused(100.0, 0.0..=1000.0); From 80cd36846e51e1cc44ebb04440490139141436af Mon Sep 17 00:00:00 2001 From: Joseph <56056764+00xJS@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:32:42 -0700 Subject: [PATCH 229/304] GPU canvas: redraw mip levels of 128 px or less whole; fixes AMD Vulkan corruption after partial updates (#6418) (#6438) * GPU canvas: redraw mip levels of 128 px or less whole; AMD Vulkan corrupted them after partial updates (#6418) On an RX 6900 XT with Vulkan (driver 26.8.1), a regenerate_mips pass that drew only the damaged part of a small mip level corrupted texels outside its scissor, at the level's top-left; DX12 on the same card was fine. A sweep of 15 canvas sizes x 8-bit/float x 6 dabs found 66 of 180 cases corrupt on Vulkan, every corrupted level at most 64 px; sampling the levels in a shader shows the same pixels, so it is the texture contents, not the readback. mip_damage (regenerate_mips's damage arithmetic) now redraws levels of at most WHOLE_MIP_SIDE (128 px) whole on every adapter: no new passes or textures, only a larger scissor on the smallest levels. With it the sweep is clean on both backends. Tests: small_mip_levels_are_redrawn_whole (the rule, no GPU) and partial_updates_match_a_full_rebuild_at_every_mip_level (fails on the Radeon over Vulkan without the change, passes with it, like canvas_mips_preserve_opaque_pixels_and_partial_updates). * contributors: add 00xJS (GitHub profile name) * Mip sweep test: hold the GPU lock like the other GPU tests (#6438 review) --------- Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com> --- contributors/people.toml | 3 ++ crates/ui-egui/src/gpu_canvas.rs | 33 ++++++++++++++-- crates/ui-egui/src/gpu_canvas_mip_tests.rs | 44 ++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 102c69e66c..0860ea17db 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -172,3 +172,6 @@ sync_github_name = false [people.Byxis] sync_github_name = true + +[people.00xJS] +sync_github_name = true diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 65388d5049..7ec4c192b2 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -36,6 +36,10 @@ pub use photocraft_gpu::{DeviceHealth, Fault}; /// Default tile side; documents up to this size (and the device limit) use a single texture. pub const DEFAULT_TILE: u32 = 8192; +/// Mip levels no larger than this on either side are redrawn whole on every edit rather than +/// just their damage (see [`mip_damage`], #6418). Every corrupted level measured was at most +/// 64 px; this leaves a factor of two. +const WHOLE_MIP_SIDE: u32 = 128; const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Canvas texture format of high-bit documents (see [`GpuCanvas::format_for`]). const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; @@ -1847,6 +1851,20 @@ fn mip_count(w: u32, h: u32) -> u32 { 32 - w.max(h).max(1).leading_zeros() } +/// The rect (x0, y0, x1, y1) of a `lw`×`lh` mip level to redraw when `prev` changed in the level +/// above: that damage halved and padded by one texel for odd-size rounding, or the whole level +/// when it is at most [`WHOLE_MIP_SIDE`] on either side. On AMD's Vulkan driver a pass that draws +/// only part of a level of 64 px or less corrupts texels outside its scissor, at the level's +/// top-left (#6418: RX 6900 XT, driver 26.8.1; DX12 on the same card is fine). Such a level costs +/// next to nothing to redraw, so this holds for every adapter; the levels after it are smaller +/// still, so the rest of the chain is redrawn whole too. +fn mip_damage([x0, y0, x1, y1]: [u32; 4], lw: u32, lh: u32) -> [u32; 4] { + if lw.max(lh) <= WHOLE_MIP_SIDE { + return [0, 0, lw, lh]; + } + [(x0 / 2).saturating_sub(1), (y0 / 2).saturating_sub(1), (x1.div_ceil(2) + 1).min(lw), (y1.div_ceil(2) + 1).min(lh)] +} + /// A buffer holding `contents`, like `DeviceExt::create_buffer_init` but without its panic when /// the device was lost (an invalid buffer has no mapped range): the buffer is then just invalid. fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { @@ -1944,9 +1962,7 @@ impl DocTextures { (x0, y0, x1, y1) = (x0 - tx, y0 - ty, x1 - tx, y1 - ty); for level in 1..t.levels.len() { let (lw, lh) = ((tw >> level).max(1), (th >> level).max(1)); - // Damage at this level, padded by one texel for odd-size rounding. - (x0, y0) = ((x0 / 2).saturating_sub(1), (y0 / 2).saturating_sub(1)); - (x1, y1) = ((x1.div_ceil(2) + 1).min(lw), (y1.div_ceil(2) + 1).min(lh)); + [x0, y0, x1, y1] = mip_damage([x0, y0, x1, y1], lw, lh); let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_mip"), layout: &res.mip_bgl, @@ -2567,6 +2583,17 @@ mod tests { assert!(separate_mip_targets(Dx12, intel, Some("yes"))); } + /// #6418: levels of at most WHOLE_MIP_SIDE are redrawn whole; larger ones keep the damage. + #[test] + fn small_mip_levels_are_redrawn_whole() { + // A 3 × 3 dab at (800, 500) of a 1791 × 1083 tile, at mip 1 (895 × 541): halved and padded. + assert_eq!(mip_damage([800, 500, 803, 503], 895, 541), [399, 249, 403, 253]); + // At most 128 px on either side: the whole level, wherever the damage is. + assert_eq!(mip_damage([100, 60, 102, 63], 111, 67), [0, 0, 111, 67]); + assert_eq!(mip_damage([0, 0, 1, 1], 128, 2), [0, 0, 128, 2]); + assert_eq!(mip_damage([0, 0, 1, 1], 129, 2), [0, 0, 2, 2]); + } + #[test] fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; diff --git a/crates/ui-egui/src/gpu_canvas_mip_tests.rs b/crates/ui-egui/src/gpu_canvas_mip_tests.rs index 82e911bae9..b9eac3e0b9 100644 --- a/crates/ui-egui/src/gpu_canvas_mip_tests.rs +++ b/crates/ui-egui/src/gpu_canvas_mip_tests.rs @@ -102,3 +102,47 @@ fn check_mips(gpu: &GpuCanvas) { } } } + +/// #6418: on AMD's Vulkan driver, a pass that drew only part of a small mip level corrupted texels +/// outside its scissor, at the level's top-left (RX 6900 XT, driver 26.8.1). Whether a texture is +/// hit depends on where the driver placed it, so this paints dabs on canvases of several sizes at +/// both depths and compares every level with a full rebuild of the same pixels. +#[test] +fn partial_updates_match_a_full_rebuild_at_every_mip_level() { + let _gpu = photocraft_testkit::gpu_lock(); + let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(wgpu_setup(), Default::default()))); + let Ok(rs) = rs else { + eprintln!("skipping mip rebuild comparison: no GPU adapter"); + return; + }; + let gpu = GpuCanvas::new(&rs); + for (depth, w, h) in [(SampleType::U8, 255, 255), (SampleType::U8, 1024, 768), (SampleType::F32, 512, 512), (SampleType::F32, 1791, 1083)] { + let mut full = Buffer::filled(Rect::from_xywh(0, 0, w, h), [0.0; 4]); + for (i, px) in full.px.iter_mut().enumerate() { + let (x, y) = (i as u32 % w, i as u32 / w); + *px = [x as f32 / w as f32, y as f32 / h as f32, ((x + y) % 7) as f32 / 7.0, 1.0]; + } + gpu.upload_buffer_full(1, &full, depth); + let dabs = [Rect::from_xywh((w / 2) as i32, (h / 2) as i32, w / 3, h / 3), Rect::from_xywh(w as i32 - 1, h as i32 - 1, 1, 1)]; + for (dab, color) in dabs.into_iter().zip([[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]) { + assert!(gpu.upload_buffer_rect(1, &Buffer::filled(dab, color))); + for y in dab.y0..dab.y1 { + for x in dab.x0..dab.x1 { + full.px[y as usize * w as usize + x as usize] = color; + } + } + gpu.upload_buffer_full(2, &full, depth); + for (level, (partial, rebuilt)) in read_mips(&gpu, 1).iter().zip(&read_mips(&gpu, 2)).enumerate() { + let lw = (w >> level).max(1) as usize; + for (i, (a, b)) in partial.iter().zip(rebuilt).enumerate() { + assert!( + a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.005), + "{depth:?} {w}x{h} dab {dab:?} mip {level} at ({}, {}): {a:?} != {b:?}", + i % lw, + i / lw + ); + } + } + } + } +} From 3cafc636564f799b17e3d9eb9d627cb8ea1fcfa0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 19:32:51 +0200 Subject: [PATCH 230/304] Free Transform: W and H take a typed length in percent view (#6284) (#6293) W and H showing percent rejected a typed length ("40px", "2 in", "5cm"), since the shared unit parser has no length to convert a percentage against. The fields now carry the box's original side as the length 100 % stands for, so a typed length becomes that share of it at the document's resolution, and "50%" typed into a length view is half the original side. The field keeps its chosen unit; the aspect link and the scale limits apply as before. Other percent fields (opacity, fill) still reject lengths. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/numeric_expression.rs | 59 +++++++++++++-- crates/ui-egui/src/transform_tool.rs | 94 +++++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 27 ++++++- 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/numeric_expression.rs b/crates/ui-egui/src/numeric_expression.rs index a3232dd96f..6c7f96393c 100644 --- a/crates/ui-egui/src/numeric_expression.rs +++ b/crates/ui-egui/src/numeric_expression.rs @@ -19,6 +19,10 @@ pub struct Units<'s> { pub dpi: f64, /// Points per inch (72 PostScript, 72.27 traditional). pub ppi: f64, + /// The length in pixels that 100 % stands for, in a field that is both (Free Transform's W + /// and H, #6284): a length then converts into its percent and a percentage into its length. + /// `None` (most fields) keeps lengths and percentages apart. + pub extent: Option, } /// Unit names a number may carry, longest first so `pica` wins over `pc`. @@ -34,8 +38,9 @@ const UNIT_NAMES: [(&str, Unit); 8] = [ ]; /// [`parse`] where a number may carry a unit (`2in`, `5 mm`, `50%`), converted into the field's -/// unit. A unit that can't convert (a length in a percent field, any unit in a unitless one) makes -/// the whole text invalid; without `units`, unit names are invalid as before. +/// unit. A unit that can't convert (a length in a percent field without an [`Units::extent`], any +/// unit in a unitless one) makes the whole text invalid; without `units`, unit names are invalid +/// as before. pub(crate) fn parse_with_units(text: &str, units: Option) -> Option { if text.len() > 1024 { return None; @@ -168,7 +173,13 @@ impl Parser<'_> { Some(field) if field == typed => Some(value), // Lengths convert through pixels; a percentage has no length to convert against. Some(field) if field != Unit::Percent && typed != Unit::Percent => Some(field.from_px(typed.to_px(value, dpi, 1.0, ppi), dpi, 1.0, ppi)), - _ => None, + // A field whose 100 % is a length: convert through that length's pixels. + Some(field) => { + let extent = units.extent.map(f64::abs).filter(|e| e.is_finite() && *e > 1e-9)?; + let v = field.from_px(typed.to_px(value, dpi, extent, ppi), dpi, extent, ppi); + v.is_finite().then_some(v) + } + None => None, } } } @@ -206,10 +217,10 @@ mod tests { #[test] fn units_convert_into_the_field_unit_and_bad_input_is_rejected() { use super::{Unit, Units, parse_with_units}; - let px = Units { unit: Some(Unit::Pixels), suffix: "px", dpi: 300.0, ppi: 72.0 }; - let pt = Units { unit: Some(Unit::Points), suffix: "pt", dpi: 144.0, ppi: 72.0 }; - let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 72.0, ppi: 72.0 }; - let deg = Units { unit: None, suffix: "°", dpi: 72.0, ppi: 72.0 }; + let px = Units { unit: Some(Unit::Pixels), suffix: "px", dpi: 300.0, ppi: 72.0, extent: None }; + let pt = Units { unit: Some(Unit::Points), suffix: "pt", dpi: 144.0, ppi: 72.0, extent: None }; + let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 72.0, ppi: 72.0, extent: None }; + let deg = Units { unit: None, suffix: "°", dpi: 72.0, ppi: 72.0, extent: None }; for (text, units, want) in [ ("1in", px, 300.0), ("2.54cm", px, 300.0), @@ -255,6 +266,40 @@ mod tests { } assert_eq!(parse("1in"), None, "without a field unit, unit names stay invalid"); } + #[test] + fn a_field_with_an_extent_converts_between_lengths_and_percent() { + use super::{Unit, Units, parse_with_units}; + // Free Transform's W at 300 ppi for a 600 px side (#6284): 100 % is 600 px, 2 in. + let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 300.0, ppi: 72.0, extent: Some(600.0) }; + let px = Units { unit: Some(Unit::Pixels), suffix: "px", ..pct }; + let inches = Units { unit: Some(Unit::Inches), suffix: "in", ..pct }; + for (text, units, want) in [ + ("300px", pct, 50.0), + ("1in", pct, 50.0), + ("2in*2", pct, 200.0), + ("2.54cm", pct, 50.0), + ("-1in", pct, -50.0), + ("50%", pct, 50.0), + ("50", pct, 50.0), + ("50%", px, 300.0), + ("50%+10", px, 310.0), + ("150%", inches, 3.0), + ("10%3", px, 1.0), + ] { + let got = parse_with_units(text, Some(units)); + assert!(got.is_some_and(|v| (v - want).abs() < 1e-9), "{text}: {got:?}, want {want}"); + } + // A degenerate or hostile extent can't convert, and never divides by zero. + for extent in [Some(0.0), Some(f64::NAN), Some(f64::INFINITY), None] { + assert_eq!(parse_with_units("1in", Some(Units { extent, ..pct })), None, "{extent:?}"); + assert_eq!(parse_with_units("50%", Some(Units { extent, ..px })), None, "{extent:?}"); + } + assert_eq!(parse_with_units("1e308in", Some(Units { extent: Some(1e-6), ..pct })), None, "an overflow is invalid"); + // Other percent fields (opacity, fill …) still reject a length. + let opacity = Units { extent: None, ..pct }; + assert_eq!(parse_with_units("40px", Some(opacity)), None); + assert_eq!(parse_with_units("2in", Some(opacity)), None); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 7f4238ae82..7ae223029d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -2880,14 +2880,17 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let (suffix, places) = (u.unit.suffix(), u.unit.decimals() as u32); // Lengths need more room than a percentage ("2116.67 mm"). let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 }; + // Whatever their unit, W and H take any other typed in (`500 px` or `2 in` in percent, `50%` + // in a length), converted at the document's resolution against the box's original side (#6284). + let units = crate::widgets::FieldUnits { unit: u.unit, dpi: u.dpi, ppi: u.ppi }; lbl(ui, "W:"); - let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places); + let rw = crate::widgets::value_field_extent(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places, units, side_w); let link = !app.ui.transform_unlinked; if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() { app.ui.transform_unlinked = link; } lbl(ui, "H:"); - let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places); + let rh = crate::widgets::value_field_extent(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places, units, side_h); for r in [&rw, &rh] { r.context_menu(|ui| { if let Some(name) = crate::rulers::unit_menu(ui, u.unit) { @@ -3514,6 +3517,93 @@ mod tests { assert_eq!(shown_size(&bar_harness(|_| {})), ["100", "100"]); } + /// Opens a document at `dpi` so the W/H fields convert typed lengths at its resolution. + fn at_dpi(app: &mut PhotocraftApp, dpi: f32) { + app.session.execute("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.session + .edit("resolution", |doc, _| { + doc.resolution_dpi = dpi; + Ok(()) + }) + .unwrap(); + } + + /// The box's width and height in px after a W/H edit. + fn box_size(t: &TransformSession) -> (f64, f64) { + (t.quad[1][0] - t.quad[0][0], t.quad[3][1] - t.quad[0][1]) + } + + #[test] + fn w_and_h_in_percent_take_a_typed_length() { + use photocraft_engine::prefs::Unit; + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + // The 100 × 50 px box with W/H showing percent (#6284): a typed length is the box's new side. + for (text, px) in + [("40px", 40.0), ("40 px", 40.0), ("2in", 600.0), ("2 in", 600.0), ("5cm", 5.0 / 2.54 * 300.0), ("25.4mm", 300.0), ("72pt", 300.0), ("1pica", 50.0)] + { + // Linked: the height follows the width. + let t = type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text); + let (w, h) = box_size(&t); + assert!(near(w, px) && near(h, px / 2.0), "{text} linked: {w} × {h}"); + // Unlinked: only the width changes. + let t = type_into_bar_field_with( + |app| { + at_dpi(app, 300.0); + app.ui.transform_unlinked = true; + }, + 2, + text, + ); + let (w, h) = box_size(&t); + assert!(near(w, px) && near(h, 50.0), "{text} unlinked: {w} × {h}"); + } + // H takes a length too; linked, the width follows. + let t = type_into_bar_field_with(|app| at_dpi(app, 300.0), 3, "1in"); + let (w, h) = box_size(&t); + assert!(near(w, 600.0) && near(h, 300.0), "H 1in: {w} × {h}"); + // A plain number is still the percentage. + let (w, _) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "40")); + assert!(near(w, 40.0), "40 is 40 % of 100 px: {w}"); + // A percentage typed into a length field is that share of the box's original side. + let px = |app: &mut PhotocraftApp| { + at_dpi(app, 300.0); + set_size_unit(app, Unit::Pixels.name()); + }; + let (w, h) = box_size(&type_into_bar_field_with(px, 2, "50%")); + assert!(near(w, 50.0) && near(h, 25.0), "50% in px: {w} × {h}"); + let inches = |app: &mut PhotocraftApp| { + at_dpi(app, 300.0); + set_size_unit(app, Unit::Inches.name()); + }; + let (w, h) = box_size(&type_into_bar_field_with(inches, 3, "300%")); + assert!(near(w, 300.0) && near(h, 150.0), "300% in inches: {w} × {h}"); + } + + #[test] + fn w_and_h_reject_or_clamp_hostile_typed_lengths() { + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + // Text that is no number leaves the box alone. + for text in ["px", "in", "NaN", "%"] { + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text)); + assert!(near(w, 100.0) && near(h, 50.0), "{text}: {w} × {h}"); + } + // Invalid text after a number keeps only what applied while typing (a plain number applies + // per keystroke), never a NaN or a box past the largest document. + for text in ["1in/0", "1e400px", "2 inches", "1e308in"] { + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text)); + assert!(w.is_finite() && h.is_finite() && w.abs() <= f64::from(POSITION_LIMIT_PX) + 0.01, "{text}: {w} × {h}"); + } + // A huge length stops at the field's limit: the box stays within the largest document. + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "1e9in")); + assert!(w.is_finite() && w <= f64::from(POSITION_LIMIT_PX) + 0.01 && h <= f64::from(POSITION_LIMIT_PX), "{w} × {h}"); + // A negative length flips, as a negative percentage does. + let (w, _) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "-40px")); + assert!(near(w, -40.0), "{w}"); + // Zero collapses the side without dividing by zero. + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "0px")); + assert!(w.is_finite() && h.is_finite(), "{w} × {h}"); + } + #[test] fn right_clicking_w_or_h_picks_their_unit_and_remembers_it() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 19f47830d9..0a91058387 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -350,6 +350,25 @@ pub fn value_field_units(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInc value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units), ..Default::default() }).0 } +/// A [`value_field_prec`] in `units` whose 100 % is a length of `extent` px (Free Transform's W +/// and H): a length typed while it shows percent becomes that share of `extent` (`40px`, `2in`), +/// and a percentage typed while it shows a length becomes that share of `extent` (`50%`), at +/// `units`' resolution (#6284). The field keeps its own unit. +#[allow(clippy::too_many_arguments)] +pub fn value_field_extent( + ui: &mut Ui, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + width: f32, + decimals: u32, + units: FieldUnits, + extent: f64, +) -> Response { + let opts = FieldOpts { decimals: Some(decimals), units: Some(units), extent: Some(extent), ..Default::default() }; + value_field_in(ui, value, range, suffix, width, 0.0, opts).0 +} + fn field_resolution_id() -> egui::Id { egui::Id::new("pc-field-resolution") } @@ -499,6 +518,8 @@ struct FieldOpts { decimals: Option, /// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]). units: Option, + /// The length (px) 100 % stands for, so lengths and percentages convert into each other. + extent: Option, wrap_drag: bool, } @@ -526,7 +547,7 @@ fn value_field_box( trailing: f32, opts: FieldOpts, ) -> (Response, Rect) { - let FieldOpts { decimals, units, wrap_drag } = opts; + let FieldOpts { decimals, units, extent, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); let scrub = take_label_scrub(ui, rect).map(|s| { @@ -544,8 +565,8 @@ fn value_field_box( // Typed text loses its spaces before parsing, so a " pt" suffix reads as "pt". let typed_suffix = suffix.trim(); let units = match units { - Some(u) => crate::numeric_expression::Units { unit: Some(u.unit), suffix: typed_suffix, dpi: u.dpi, ppi: u.ppi }, - None => crate::numeric_expression::Units { unit: unit_of_suffix(typed_suffix), suffix: typed_suffix, dpi, ppi }, + Some(u) => crate::numeric_expression::Units { unit: Some(u.unit), suffix: typed_suffix, dpi: u.dpi, ppi: u.ppi, extent }, + None => crate::numeric_expression::Units { unit: unit_of_suffix(typed_suffix), suffix: typed_suffix, dpi, ppi, extent }, }; surface(ui, rect, t.field, false); if !t.bevel { From 1c74da7baa4f5c9b0438889195300cbd0b1fded2 Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Mon, 19 Oct 2026 13:32:59 -0400 Subject: [PATCH 231/304] keep layer-based slices when duplicating a document (#6133) --- crates/engine/src/image_cmds.rs | 5 ++++ crates/engine/tests/duplicate_slices.rs | 35 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 crates/engine/tests/duplicate_slices.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 92bbdd7463..805fbde55b 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -115,6 +115,11 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { def.layer = new_id; } } + for slice in &mut doc.slices.list { + if let Some(&new_id) = slice.layer.and_then(|layer| ids.get(&layer)) { + slice.layer = Some(new_id); + } + } } /// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › diff --git a/crates/engine/tests/duplicate_slices.rs b/crates/engine/tests/duplicate_slices.rs new file mode 100644 index 0000000000..5f5d21c621 --- /dev/null +++ b/crates/engine/tests/duplicate_slices.rs @@ -0,0 +1,35 @@ +use photocraft_doc::SliceOrigin; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn sliced() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({"name": "logo"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.newLayerBasedSlice", json!({"url": "https://example.com", "alt": "logo"})).unwrap(); + s +} + +fn layer_slice(s: &mut Session) -> Option { + let list = s.execute("slice.list", json!({})).unwrap(); + list["slices"].as_array().unwrap().iter().find(|sl| sl["origin"] == "layer").cloned() +} + +#[test] +fn duplicating_a_document_keeps_its_layer_based_slices() { + for cmd in ["image.duplicate", "history.newDocument"] { + let mut s = sliced(); + s.execute(cmd, json!({})).unwrap(); + let st = s.active().unwrap(); + let logo = st.doc.walk().into_iter().find(|(_, _, l)| l.name == "logo").unwrap().2.id; + let slices: Vec<_> = st.doc.slices.list.iter().filter(|sl| sl.origin == SliceOrigin::Layer).collect(); + assert_eq!(slices.len(), 1, "{cmd}"); + assert_eq!(slices[0].layer, Some(logo), "{cmd}"); + assert_eq!((slices[0].url.as_str(), slices[0].alt.as_str()), ("https://example.com", "logo"), "{cmd}"); + let before = layer_slice(&mut s).unwrap()["rect"].clone(); + s.execute("layer.translate", json!({"dx": 5, "dy": 3})).unwrap(); + let after = layer_slice(&mut s).unwrap_or_else(|| panic!("{cmd} lost the slice after a move"))["rect"].clone(); + assert_ne!(before, after, "{cmd}: the slice no longer follows its layer"); + } +} From 8c3c3237f412af5bd1ba9b340ce9192c407b6230 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Mon, 19 Oct 2026 18:33:06 +0100 Subject: [PATCH 232/304] refactor(engine): drop the second damage pass after single-edit duplicates and pastes (#5880) Session::edit has reported a new layer's bounds as damage since #3847, so Duplicate Layer, Duplicate Layers and Paste no longer need to keep the document from before the edit and work the damage out again with note_insert. Paste Into still does: it is three edits. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 -- crates/engine/src/edit_cmds.rs | 4 +--- crates/engine/src/layer_multi_cmds.rs | 18 +++++++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd7f5d606e..9b4d4a7472 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -754,7 +754,6 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let nid = s.edit("Duplicate Layer", |doc, active| { let mut dup = layer_copy(doc, id)?; if let Some(name) = &name { @@ -767,7 +766,6 @@ fn build() -> Vec { *active = Some(nid); Ok(nid) })?; - crate::layer_multi_cmds::note_insert(s, &before); Ok(json!({ "layer": nid.0 })) } ), diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 7cdb595212..aa2ce627f7 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -394,7 +394,6 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); let surf = if moved.format() == target { moved } else { moved.convert(target) }; - let before = d.doc.clone(); let id = s.edit("Paste", |doc, active| { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; @@ -403,8 +402,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { doc.selection = None; Ok(id) })?; - // Only where the pasted pixels land needs recompositing, not the whole document (#2901). - crate::layer_multi_cmds::note_insert(s, &before); + // `edit` reports only where the pasted pixels land as damage, not the whole document (#2901). Ok(json!({"layer": id.0, "offset": [dx, dy]})) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1b71e966a4..418b0051d0 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -885,9 +885,9 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { } } -/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for -/// commands made of more than one edit, so the canvas recomposites only where the new layers -/// draw instead of the whole document. +/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]), +/// so the canvas recomposites only where its new layers draw instead of the whole document. +/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695). pub(crate) fn note_insert(s: &mut Session, before: &Document) { let Some(st) = s.active_mut() else { return }; st.last_damage = step_damage(before, &st.doc); @@ -1386,7 +1386,6 @@ pub fn delete_selected(s: &mut Session) -> Result { /// this path, #2777). pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let old_active = s.active().and_then(|d| d.active_layer); let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); @@ -1408,7 +1407,6 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul *active = a; Ok((copies, a)) })?; - note_insert(s, &before); let out = copies.iter().map(|l| l.0).collect::>(); reselect(s, copies, active); Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) @@ -1823,8 +1821,8 @@ mod tests { x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) } - /// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer - /// draws; undoing a duplicate used to recomposite the whole document. + /// Duplicate (one layer or several), paste and delete, and their undo and redo, recomposite + /// only where the layers draw; undoing a duplicate used to recomposite the whole document. #[test] fn duplicate_paste_and_delete_report_where_the_layer_draws() { let mut s = session(8); @@ -1847,6 +1845,12 @@ mod tests { s.execute("edit.copy", json!({})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); step(&mut s, "edit.paste", json!({})); + // Duplicating several selected layers reports where the copies draw, too. + let b = rect_layer(&mut s, Rect::new(30, 30, 40, 40)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + step(&mut s, "layer.duplicate", json!({})); + assert_eq!(damage(&s), Some(Rect::new(10, 10, 40, 40)), "both copies, nothing else"); step(&mut s, "layer.delete", json!({"layer": a.0})); } From caa1539030a95f5320053d5f0acaece8facfb864 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 19 Oct 2026 23:03:14 +0530 Subject: [PATCH 233/304] Tools: Sample All Layers is each tool's own, as in Photoshop (#5524) (#5570) One ToolOptions::sample_all_layers was shared by twelve tools, so ticking it for the Magic Wand or Paint Bucket also turned it on for Blur, Sharpen and Smudge. A dab of those on an annotation layer then pasted an opaque copy of the composite there, which sat above the picture and hid every later blur of it (the sharp round patch in #5524). ToolOptions::sample_all is now a SampleAllLayers with one flag per tool, all off by default: Magic Wand, Quick and Object Selection (and Object Finder), Paint Bucket, Magic Eraser, Spot Healing, Patch, Content-Aware Move, Remove, Blur, Sharpen and Smudge. Each options bar and command reads its own tool's flag, and Reset Tool resets only that tool's. The new key means older preferences drop the shared flag, so every tool starts with it off. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/eraser_ui.rs | 4 +- crates/ui-egui/src/object_finder.rs | 2 +- crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/preset_panels.rs | 34 ++++++++- crates/ui-egui/src/retouch_ui.rs | 72 +++++++++++++----- crates/ui-egui/src/state.rs | 76 ++++++++++++++++++- crates/ui-egui/src/tool_reset.rs | 72 ++++++++++++++---- .../ui-egui/tests/paint_bucket_all_layers.rs | 4 +- 9 files changed, 225 insertions(+), 47 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 474864746b..053716a572 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5547,7 +5547,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::MagicWand => { let o = app.ui.tool_options.clone(); let mode = selection_mode(app, mods); - let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "mode": mode})); + let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.magic_wand, "mode": mode})); return; } Tool::MagicEraser => { @@ -5557,7 +5557,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::PaintBucket => { let o = app.ui.tool_options.clone(); let contents = if o.bucket_fill_pattern { "pattern" } else { "foreground" }; - let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)})); + let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.paint_bucket, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)})); return; } Tool::RedEye => { diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index 59fc4d4926..01a2e74a00 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -27,7 +27,7 @@ pub fn click(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { "tolerance": o.tolerance, "antiAlias": o.anti_alias, "contiguous": o.contiguous, - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.magic_eraser, "opacity": o.magic_eraser_opacity, }); let r = app.run("paint.magicEraser", p); @@ -92,7 +92,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo widgets::value_field(ui, &mut o.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut o.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut o.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + widgets::checkbox(ui, &mut o.sample_all.magic_eraser, tl!("Sample All Layers")); widgets::vline(ui, 22.0); opt(ui, tl!("Opacity:")); widgets::value_field(ui, &mut o.magic_eraser_opacity, 1.0..=100.0, "%", 62.0); diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index e219b0ddff..01fe50e502 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -78,7 +78,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo return Update::default(); }; let Some(st) = app.session.active() else { return Update::default() }; - let all_layers = app.ui.tool_options.sample_all_layers; + let all_layers = app.ui.tool_options.sample_all.object_selection; let target = sampled_target(&st.doc, st.active_layer, all_layers); let (doc, revision, layer) = (st.doc.id, st.revision, st.active_layer); let px = [at[0].floor() as i32, at[1].floor() as i32]; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 75ade83b8d..9e0a701cee 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1241,7 +1241,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("Sample All Layers")); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all.magic_wand, tl!("Sample All Layers")); widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { let r = app.run("select.subject", json!({})); @@ -1263,7 +1263,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("All Layers")); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all.paint_bucket, tl!("All Layers")); } Tool::Gradient => { // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels). diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 2741f9170c..b52f1400d7 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1017,7 +1017,14 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { let mut cur = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); if let Some(c) = cur.as_object_mut() { for (k, v) in o { - c.insert(k.clone(), v.clone()); + // An object option (`sample_all`, one per tool) takes only the entries the + // preset stored, so the other tools' keep theirs (#5524). + match (c.get_mut(k), v) { + (Some(Value::Object(have)), Value::Object(set)) => have.extend(set.clone()), + _ => { + c.insert(k.clone(), v.clone()); + } + } } } if let Ok(opts) = serde_json::from_value(cur) { @@ -1034,8 +1041,14 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { /// preset later leaves the other tools' settings alone. fn new_tool_preset_params(app: &PhotocraftApp, tool: Tool) -> Value { let all = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); - let own: serde_json::Map = + let mut own: serde_json::Map = crate::tool_reset::option_keys(tool).iter().filter_map(|k| Some(((*k).to_string(), all.get(*k)?.clone()))).collect(); + // Sample All Layers is each tool's own (#5524): store `tool`'s alone. + if let Some(k) = crate::tool_reset::sample_all_key(tool) + && let Some(v) = all.get("sample_all").and_then(|m| m.get(&k)) + { + own.insert("sample_all".into(), json!({ k: v })); + } json!({"name": tool.label(), "tool": tool_id(tool), "options": {"toolOptions": own}}) } @@ -1360,6 +1373,23 @@ mod tests { assert_eq!(app.ui.tool_options.move_auto_select, !ToolOptions::default().move_auto_select); } + /// #5524 with #7356: a Blur preset keeps Blur's Sample All Layers and leaves the Magic Wand's + /// alone, both when it is made and when it is chosen. + #[test] + fn new_tool_preset_stores_only_its_tools_sample_all_layers() { + let (mut app, _) = app(); + app.ui.tool = Tool::Blur; + app.ui.tool_options.sample_all.blur = true; + let p = new_tool_preset_params(&app, Tool::Blur); + assert_eq!(p["options"]["toolOptions"]["sample_all"], json!({"blur": true})); + let name = app.run("tool.presets.new", p).unwrap()["name"].as_str().unwrap().to_string(); + app.ui.tool_options.sample_all.blur = false; + app.ui.tool_options.sample_all.magic_wand = true; + select_tool_preset(&mut app, &name); + assert!(app.ui.tool_options.sample_all.blur); + assert!(app.ui.tool_options.sample_all.magic_wand, "the Magic Wand's Sample All Layers is not Blur's"); + } + #[test] fn custom_shape_tool_places_the_picked_shape() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index d79a5385e3..20efb9bc0b 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -50,9 +50,9 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; Some(match tool { - Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})), - Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})), - Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})), + Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), + Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), + Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), Tool::Dodge | Tool::Burn => ( if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), @@ -84,7 +84,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m let o = app.ui.tool_options.clone(); let pts = json!(points); let (cmd, mut p): (&str, Value) = match tool { - Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})), + Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all.spot_healing})), Tool::Remove => { if !o.remove_after_each_stroke { remove_pending_add(app, points); @@ -134,7 +134,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; let xy: Vec<[f64; 2]> = points.iter().map(|q| [q[0], q[1]]).collect(); - let (sample_all, enhance) = (o.sample_all_layers, o.enhance_edge); + let (sample_all, enhance) = (o.sample_all.quick_selection, o.enhance_edge); let r = app.run("select.quick", json!({"points": xy, "size": size, "mode": mode, "enhanceEdge": enhance, "sampleAllLayers": sample_all})); report_smart_result(app, r, tl!("Quick Selection found nothing there")); return true; @@ -163,7 +163,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m /// `paint.remove` params from the options bar (without the strokes). fn remove_params(o: &crate::state::ToolOptions) -> Value { - json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer}) + json!({"sampleAllLayers": o.sample_all.remove, "newLayer": o.remove_new_layer}) } /// The selection a queued Remove stroke was drawn against. A later selection must not @@ -307,7 +307,7 @@ pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { "contentAware": true, "structure": o.patch_structure.round().clamp(1.0, 7.0), "color": o.patch_color.round().clamp(0.0, 10.0), - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.patch, "target": crate::canvas::paint_target(app), }); if let Err(e) = app.run("paint.patch", p) { @@ -353,7 +353,7 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value "mode": o.cam_mode, "structure": o.cam_structure.round().clamp(1.0, 7.0), "color": o.cam_color.round().clamp(0.0, 10.0), - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.content_aware_move, "target": crate::canvas::paint_target(app), }) } @@ -367,7 +367,7 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let (x, y) = (start[0].min(end[0]), start[1].min(end[1])); let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); - let sample_all = app.ui.tool_options.sample_all_layers; + let sample_all = app.ui.tool_options.sample_all.object_selection; if (w < 2.0 || h < 2.0) && app.ui.tool_options.object_finder { // Object Finder (#4087): the object under the click, as hovering highlighted it. let mode = match (mods.shift, mods.alt) { @@ -472,12 +472,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } crate::widgets::vline(ui, 22.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.spot_healing, tl!("Sample All Layers")); } Tool::Remove => { // Photoshop's options bar: Sample All Layers, Create a new layer, Remove after each // stroke; with strokes waiting, ✓ applies them and ⊘ drops them. - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.remove, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.remove_new_layer, tl!("Create a new layer")); crate::widgets::checkbox(ui, &mut o.remove_after_each_stroke, tl!("Remove after each stroke")); if !app.remove_pending.is_empty() { @@ -506,7 +506,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.patch_structure, 1.0..=7.0, "", 40.0); opt(ui, tl!("Color:")); crate::widgets::value_field(ui, &mut o.patch_color, 0.0..=10.0, "", 40.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.patch, tl!("Sample All Layers")); } else { for (k, l) in [("source", tl!("Source")), ("destination", tl!("Destination"))] { let mut on = o.patch_mode == k; @@ -526,7 +526,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.cam_structure, 1.0..=7.0, "", 40.0); opt(ui, tl!("Color:")); crate::widgets::value_field(ui, &mut o.cam_color, 0.0..=10.0, "", 40.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.content_aware_move, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.cam_transform_on_drop, tl!("Transform On Drop")); crate::widgets::vline(ui, 22.0); if crate::cam_transform::active(app) { @@ -578,7 +578,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::Blur | Tool::Sharpen | Tool::Smudge => { pct(ui, tl!("Strength:"), &mut o.strength); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + if let Some(all) = o.sample_all.slot(tool) { + crate::widgets::checkbox(ui, all, tl!("Sample All Layers")); + } if tool == Tool::Sharpen { crate::widgets::checkbox(ui, &mut o.protect_detail, tl!("Protect Detail")); } @@ -622,7 +624,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::HistoryBrush => opt(ui, "Paints from the document's opening state"), Tool::QuickSelection => { - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.quick_selection, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.enhance_edge, tl!("Enhance Edge")); opt(ui, &crate::i18n::fmt(tl!("{key} to subtract"), &[("key", &crate::shortcuts::pretty("Alt"))])); crate::widgets::vline(ui, 22.0); @@ -633,7 +635,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::ObjectSelection => { crate::widgets::checkbox(ui, &mut o.object_finder, tl!("Object Finder")); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.object_selection, tl!("Sample All Layers")); opt(ui, tl!("Drag a rectangle around the object")); crate::widgets::vline(ui, 22.0); if crate::widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { @@ -820,7 +822,7 @@ mod tests { let mut app = app(); stripes(&mut app, 4, "pixels"); app.run("layer.new.layer", json!({})).unwrap(); - app.ui.tool_options.sample_all_layers = all; + *app.ui.tool_options.sample_all.slot(tool).unwrap() = all; drag(&mut app, tool); let a = active(&app).surface().unwrap().rgba(30, 30)[3]; assert_eq!(a > 0.0, all, "{tool:?} sampleAllLayers={all}: alpha {a}"); @@ -828,6 +830,42 @@ mod tests { } } + #[test] + fn sample_all_layers_is_each_tools_own() { + // #5524: Sample All Layers turned on for another tool (the Magic Wand, say) left Blur and + // Smudge copying the composite onto an annotation layer, freezing a sharp patch above the + // picture. Photoshop keeps the option per tool, so those strokes leave the empty layer empty. + for tool in [Tool::Blur, Tool::Sharpen, Tool::Smudge] { + let mut app = app(); + stripes(&mut app, 4, "pixels"); + app.run("layer.new.layer", json!({})).unwrap(); + let mut others = crate::state::SampleAllLayers::default(); + for t in [ + Tool::MagicWand, + Tool::QuickSelection, + Tool::ObjectSelection, + Tool::PaintBucket, + Tool::MagicEraser, + Tool::SpotHealing, + Tool::Patch, + Tool::ContentAwareMove, + Tool::Remove, + Tool::Blur, + Tool::Sharpen, + Tool::Smudge, + ] { + if let Some(v) = others.slot(t).filter(|_| t != tool) { + *v = true; + } + } + app.ui.tool_options.sample_all = others; + assert!(!app.ui.tool_options.sample_all.get(tool)); + drag(&mut app, tool); + let a = active(&app).surface().unwrap().rgba(30, 30)[3]; + assert_eq!(a, 0.0, "{tool:?} sampled all layers through another tool's option"); + } + } + #[test] fn object_selection_click_falls_back_to_quick_selection() { // A click cannot be a rectangle: it selects at the cursor instead of looking dead. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d464cb3cc8..974d3fde35 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -511,7 +511,9 @@ pub struct ToolOptions { pub tolerance: f32, pub contiguous: bool, pub anti_alias: bool, - pub sample_all_layers: bool, + /// Sample All Layers, kept per tool as in Photoshop (#5524). Stored under a new key, so the + /// single shared `sample_all_layers` of older preferences is ignored on load. + pub sample_all: SampleAllLayers, /// Object Selection › Object Finder: hovering highlights the object a click selects (#4087). pub object_finder: bool, /// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer). @@ -653,8 +655,8 @@ pub struct ToolOptions { /// Crop gear menu (#1919): Show Cropped Area and the crop shield. See `crop_shield`. #[serde(default)] pub crop_shield: crate::crop_shield::CropShield, - /// Magic Eraser opacity % (tolerance, anti-alias, contiguous and sample-all are shared with the - /// Magic Wand and Paint Bucket). + /// Magic Eraser opacity % (tolerance, anti-alias and contiguous are shared with the Magic Wand + /// and Paint Bucket). pub magic_eraser_opacity: f32, /// Background Eraser: sampling (continuous | once | backgroundSwatch), limits (discontiguous | /// contiguous | findEdges), tolerance % and Protect Foreground Color. @@ -761,13 +763,60 @@ fn default_eyedropper_sample() -> String { "all".into() } +/// Each tool's Sample All Layers (the Paint Bucket calls it All Layers). Photoshop keeps one per +/// tool, all off by default: turning it on for the Magic Wand must not leave Blur or Smudge +/// copying the composite onto the active layer (#5524). The Mixer Brush keeps its own with the +/// brush (`MixerSettings`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SampleAllLayers { + pub magic_wand: bool, + pub quick_selection: bool, + pub object_selection: bool, + pub paint_bucket: bool, + pub magic_eraser: bool, + pub spot_healing: bool, + pub patch: bool, + pub content_aware_move: bool, + pub remove: bool, + pub blur: bool, + pub sharpen: bool, + pub smudge: bool, +} + +impl SampleAllLayers { + /// `tool`'s setting; false for a tool without the option. + pub fn get(mut self, tool: Tool) -> bool { + self.slot(tool).is_some_and(|v| *v) + } + + /// `tool`'s setting, or `None` for a tool without the option. + pub fn slot(&mut self, tool: Tool) -> Option<&mut bool> { + Some(match tool { + Tool::MagicWand => &mut self.magic_wand, + Tool::QuickSelection => &mut self.quick_selection, + Tool::ObjectSelection => &mut self.object_selection, + Tool::PaintBucket => &mut self.paint_bucket, + Tool::MagicEraser => &mut self.magic_eraser, + Tool::SpotHealing => &mut self.spot_healing, + Tool::Patch => &mut self.patch, + Tool::ContentAwareMove => &mut self.content_aware_move, + Tool::Remove => &mut self.remove, + Tool::Blur => &mut self.blur, + Tool::Sharpen => &mut self.sharpen, + Tool::Smudge => &mut self.smudge, + _ => return None, + }) + } +} + impl Default for ToolOptions { fn default() -> Self { Self { tolerance: 32.0, contiguous: true, anti_alias: true, - sample_all_layers: false, + sample_all: SampleAllLayers::default(), object_finder: true, remove_new_layer: false, remove_after_each_stroke: true, @@ -1444,6 +1493,25 @@ impl UiState { mod tests { use super::*; + #[test] + fn sample_all_layers_is_per_tool_and_old_prefs_load() { + // The single shared flag of older preferences is dropped: every tool starts off (#5524). + let o: ToolOptions = serde_json::from_str(r#"{"sample_all_layers": true, "feather": 3.0}"#).unwrap(); + assert_eq!((o.sample_all, o.feather), (SampleAllLayers::default(), 3.0)); + let mut o = ToolOptions::default(); + o.sample_all.magic_wand = true; + let back: ToolOptions = serde_json::from_value(serde_json::to_value(&o).unwrap()).unwrap(); + assert!(back.sample_all.magic_wand && !back.sample_all.get(Tool::Blur)); + assert!(!back.sample_all.get(Tool::Brush), "a tool without the option reads false"); + let mut s = SampleAllLayers::default(); + assert!(s.slot(Tool::Brush).is_none()); + for t in [Tool::MagicWand, Tool::PaintBucket, Tool::Blur, Tool::Sharpen, Tool::Smudge, Tool::Remove] { + *s.slot(t).unwrap() = true; + assert!(s.get(t), "{t:?}"); + } + assert!(!s.get(Tool::QuickSelection)); + } + #[test] fn tool_names_parse() { assert_eq!(Tool::from_name("brush"), Some(Tool::Brush)); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 9fa9169f87..3c154d3a45 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -8,9 +8,10 @@ //! `ui.dialog.cancel` answer it); `{"confirmed": true}` skips it, for agents that already asked. //! //! What a tool's options are: [`ToolOptions`] is one struct, not one per tool, and a few fields -//! are shared by the tools that show them (Tolerance, Anti-alias, Contiguous and Sample All Layers -//! by the Magic Wand, Paint Bucket and Magic Eraser; Feather by the marquees and lassos; Strength -//! by Blur, Sharpen and Smudge). Resetting one of those tools resets the shared field too. +//! are shared by the tools that show them (Tolerance, Anti-alias and Contiguous by the Magic Wand, +//! Paint Bucket and Magic Eraser; Feather by the marquees and lassos; Strength by Blur, Sharpen and +//! Smudge). Resetting one of those tools resets the shared field too. Sample All Layers is kept per +//! tool, as in Photoshop, so resetting a tool resets only its own. //! [`reset_options`] lists the fields of each tool. The selection tools also go back to New //! Selection, and the painting tools (the ones with a brush in the options bar) get the default //! brush, keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have @@ -128,6 +129,20 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { if let Ok(n) = serde_json::from_value(Value::Object(cur)) { *o = n; } + // Sample All Layers is each tool's own (#5524): only `tool`'s goes back. + let d = ToolOptions::default(); + if let Some(all) = o.sample_all.slot(tool) { + *all = d.sample_all.get(tool); + } +} + +/// The serialized name of `tool`'s own Sample All Layers inside `sample_all`, if it has one: a +/// tool preset stores that one alone, not the other tools' (#5524, #7356). +pub fn sample_all_key(tool: Tool) -> Option { + let mut probe = crate::state::SampleAllLayers::default(); + *probe.slot(tool)? = true; + let Ok(Value::Object(m)) = serde_json::to_value(probe) else { return None }; + m.into_iter().find(|(_, v)| v.as_bool() == Some(true)).map(|(k, _)| k) } /// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and @@ -147,9 +162,9 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::SingleRowMarquee | Tool::SingleColumnMarquee => reset!(feather), Tool::Lasso | Tool::PolygonLasso => reset!(feather, anti_alias), Tool::MagneticLasso => reset!(feather, anti_alias, magnetic_width, magnetic_contrast, magnetic_frequency, magnetic_pressure), - Tool::MagicWand => reset!(tolerance, anti_alias, contiguous, sample_all_layers), - Tool::QuickSelection => reset!(sample_all_layers, enhance_edge), - Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge, object_finder), + Tool::MagicWand => reset!(tolerance, anti_alias, contiguous), + Tool::QuickSelection => reset!(enhance_edge), + Tool::ObjectSelection => reset!(enhance_edge, object_finder), Tool::Crop => reset!( crop_ratio, crop_delete, @@ -173,20 +188,20 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Remove => reset!(remove_new_layer, remove_after_each_stroke), Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg), Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), - Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), + Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), - Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous, sample_all_layers), + Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous), Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), Tool::Zoom => reset!(zoom_scrubby), - Tool::SpotHealing => reset!(spot_type, sample_all_layers), + Tool::SpotHealing => reset!(spot_type), Tool::Healing => reset!(clone_aligned, clone_sample), - Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color, sample_all_layers), - Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, cam_transform_on_drop, sample_all_layers), + Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color), + Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, cam_transform_on_drop), Tool::RedEye => reset!(red_eye_pupil_size, red_eye_darken), Tool::CloneStamp => reset!(clone_aligned, clone_sample), Tool::PatternStamp => reset!(pattern_stamp_aligned, pattern_stamp_impressionist, pattern_stamp_scale, pattern_stamp_angle), - Tool::Blur | Tool::Sharpen => reset!(strength, sample_all_layers, protect_detail), - Tool::Smudge => reset!(strength, sample_all_layers, finger_painting), + Tool::Blur | Tool::Sharpen => reset!(strength, protect_detail), + Tool::Smudge => reset!(strength, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), Tool::Pen => reset!(vector_mode, pen_auto_add_delete), @@ -226,13 +241,14 @@ mod tests { } /// Every option belongs to a tool, so Reset Tool and tool presets (#7356) miss none, and every - /// listed name is a real serialized field. + /// listed name is a real serialized field. `sample_all` holds one Sample All Layers per tool, + /// which `reset_options` and `sample_all_key` handle tool by tool (#5524). #[test] fn every_option_belongs_to_a_tool() { let all = serde_json::to_value(ToolOptions::default()).unwrap(); let all = all.as_object().unwrap(); let listed: std::collections::BTreeSet<&str> = Tool::ALL.iter().flat_map(|t| option_keys(*t).iter().copied()).collect(); - for k in all.keys() { + for k in all.keys().filter(|k| *k != "sample_all") { assert!(listed.contains(k.as_str()), "{k} is no tool's option"); } for k in listed { @@ -264,4 +280,30 @@ mod tests { assert_eq!(app.ui.tool_options, ToolOptions::default()); assert!(app.ui.dialogs.is_empty()); } + + /// Sample All Layers is per tool (#5524): resetting Blur turns off only Blur's. + #[test] + fn reset_tool_resets_only_its_own_sample_all_layers() { + let mut o = ToolOptions::default(); + o.sample_all.blur = true; + o.sample_all.magic_wand = true; + reset_options(&mut o, Tool::Blur); + assert!(!o.sample_all.blur); + assert!(o.sample_all.magic_wand, "the Magic Wand's Sample All Layers is not Blur's"); + reset_options(&mut o, Tool::Remove); + assert!(o.sample_all.magic_wand); + reset_options(&mut o, Tool::MagicWand); + assert_eq!(o.sample_all, Default::default()); + } + + /// Every tool with a Sample All Layers names its own key, and a tool without one names none. + #[test] + fn sample_all_key_names_the_tools_own() { + assert_eq!(sample_all_key(Tool::MagicWand).as_deref(), Some("magic_wand")); + assert_eq!(sample_all_key(Tool::Blur).as_deref(), Some("blur")); + assert_eq!(sample_all_key(Tool::Brush), None); + let keys: Vec = Tool::ALL.iter().filter_map(|t| sample_all_key(*t)).collect(); + let distinct: std::collections::BTreeSet<&String> = keys.iter().collect(); + assert_eq!((keys.len(), distinct.len()), (12, 12), "one key per tool with the option"); + } } diff --git a/crates/ui-egui/tests/paint_bucket_all_layers.rs b/crates/ui-egui/tests/paint_bucket_all_layers.rs index 27e1428942..9e71d918d0 100644 --- a/crates/ui-egui/tests/paint_bucket_all_layers.rs +++ b/crates/ui-egui/tests/paint_bucket_all_layers.rs @@ -52,7 +52,7 @@ fn setup(h: &mut Harness) -> LayerId { o.tolerance = 0.0; o.anti_alias = false; o.contiguous = true; - o.sample_all_layers = false; + o.sample_all.paint_bucket = false; o.bucket_fill_pattern = false; o.fill_opacity = 100.0; background @@ -98,7 +98,7 @@ fn all_layers_checkbox_limits_bucket_fill_to_the_visible_color() { h.get_by_label("All Layers").click(); h.run_steps(3); } - assert_eq!(h.state().ui.tool_options.sample_all_layers, all_layers, "checkbox changed the option"); + assert_eq!(h.state().ui.tool_options.sample_all.paint_bucket, all_layers, "checkbox changed the option"); let pos = screen(&h, 300.0, 150.0); h.event(egui::Event::PointerMoved(pos)); From f11e51b8993daebc02f81574dd0f9c3cef105713 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:34:06 -0600 Subject: [PATCH 234/304] Re-run CI Co-Authored-By: Claude Opus 5.5 From 990483a95e803cabfb33e3c854d0effae9798046 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:44:51 +0300 Subject: [PATCH 235/304] Actions: Apply Layer Comp is recorded by the comp's name, so it plays in any document (#7181) (#7790) * fix(engine): record Apply Layer Comp by the comp's name, not its document-local id (#7181) * test(engine): a recorded Apply Layer Comp applies the comp of that name in another document (#7181) --------- Co-authored-by: Brandon Thomas --- crates/engine/src/comps_cmds.rs | 17 +++++++++ crates/engine/src/comps_cmds/tests.rs | 53 +++++++++++++++++++++++++++ crates/engine/src/jobs.rs | 8 +++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index fe5708387c..43a8ec12f0 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -208,6 +208,23 @@ fn apply_cmd(s: &mut Session, p: &Value) -> Result { apply(s, id, "Apply Layer Comp") } +/// How an action records Apply Layer Comp: by the comp's name, as Photoshop does. Comp ids are +/// local to a document, so a recorded id would apply whichever comp of another document has it +/// (#7181); by name the step finds that document's comp of the name, or stops with an error. +/// The comp is the one just applied, so `{}` and `null` (the last applied) record its name too. +pub(crate) fn recorded_apply(s: &Session, mut p: Value) -> Value { + let Some(d) = s.active() else { return p }; + let Some(comp) = d.doc.last_applied_comp.and_then(|id| d.doc.comp(id)) else { return p }; + // `null` params run as `{}` (the last applied comp), so they are recorded by name too. + if p.is_null() { + p = json!({}); + } + if let Value::Object(m) = &mut p { + m.insert("comp".into(), json!(comp.name)); + } + p +} + fn step(s: &mut Session, delta: i64) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let n = d.doc.layer_comps.len() as i64; diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 5458523bcf..d4857ad025 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -347,3 +347,56 @@ fn clearing_warnings_of_an_unknown_comp_is_an_error() { assert_eq!(doc(&s).layer_comps, before); assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); } + +/// Apply Layer Comp is recorded by the comp's name, not its document-local id: played in another +/// document it applies that document's comp of the name, or stops when there is none (#7181). +#[test] +fn a_recorded_apply_layer_comp_finds_the_comp_by_name() { + let (mut s, _, _) = session(8); + s.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + let wanted = doc(&s).layer_comps[0].id; + s.execute("actions.record", json!({"name": "ApplyWanted"})).unwrap(); + s.execute("layerComp.apply", json!({"comp": wanted})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + // `{}` and `null` apply the last applied comp; they are recorded by name too. + s.execute("actions.record", json!({"name": "ApplyLast"})).unwrap(); + s.execute("layerComp.apply", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.record", json!({"name": "ApplyNull"})).unwrap(); + s.execute("layerComp.apply", Value::Null).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + for action in ["ApplyWanted", "ApplyLast", "ApplyNull"] { + let steps = s.execute("actions.get", json!({"action": action})).unwrap()["steps"].clone(); + assert_eq!(steps, json!([["layerComp.apply", {"comp": "Wanted"}]]), "{action}"); + } + + // A second document whose comp with the recorded id is another one. + let (mut other, _, ob) = session(8); + other.actions = s.actions.clone(); + let show = |s: &mut Session, visible: bool| { + s.edit("t", |d, _| { + d.layer_mut(ob).unwrap().visible = visible; + Ok(()) + }) + .unwrap(); + }; + show(&mut other, false); + other.execute("layerComp.new", json!({"name": "Different"})).unwrap(); + show(&mut other, true); + other.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + assert_eq!(doc(&other).layer_comps[0].id, wanted, "the recorded id names `Different` here"); + show(&mut other, false); + other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + let applied = doc(&other).last_applied_comp.and_then(|id| doc(&other).comp(id)).map(|c| c.name.clone()); + assert_eq!(applied.as_deref(), Some("Wanted")); + assert!(doc(&other).layer(ob).unwrap().visible, "Wanted shows the layer"); + + // A document without a comp of that name stops the action instead of applying another one. + other.execute("layerComp.delete", json!({"comp": "Wanted"})).unwrap(); + show(&mut other, false); + let before = (doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible); + let played = other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("no layer comp named \"Wanted\""), "{played}"); + assert_eq!((doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible), before, "the document is unchanged"); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5098b958b4..583465b46e 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -816,8 +816,12 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = - if id == "layer.select" && self.actions.recording.is_some() { crate::layer_multi_cmds::recorded_selection(self, params) } else { params }; + let params = match id { + _ if self.actions.recording.is_none() => params, + "layer.select" => crate::layer_multi_cmds::recorded_selection(self, params), + "layerComp.apply" => crate::comps_cmds::recorded_apply(self, params), + _ => params, + }; self.journal.push((id.to_string(), params)); } } From 2ef8a9d3f5aa74ddc7612cb58720a84dbcafc2ea Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:45:07 +0200 Subject: [PATCH 236/304] Reject exporting a partial Play Action call to .atn (#7602) Fixes #7186 Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 4 ++++ crates/engine/src/atn_cmds/tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 621b5bb4a8..3e4c34b81a 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1984,6 +1984,10 @@ fn play_from(d: &Descriptor) -> Mapped { fn play_to(_: &str, p: &Value) -> Option { let name = jstr(p, "action")?; + // The Play Action descriptor written here carries no starting step, so a call from a later step is not a whole call. + if p.get("from").is_some_and(|v| !v.is_null() && v.as_f64() != Some(0.0)) { + return None; + } let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; if let Some(set) = jstr(p, "set") { items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index b19dc25d8b..37744831cc 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1239,6 +1239,23 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { } } +/// A Play Action that starts the called action at a later step must not export as a call of the +/// whole action: that would replay the steps the recording deliberately skipped. +#[test] +fn partial_play_action_is_not_exported_as_whole_action_call() { + let partial = Action { name: "Parent".into(), steps: vec![("actions.play".into(), json!({"action": "Child", "from": 1}))], ..Default::default() }; + assert!(export("Set", &[&partial], &[], false).is_err()); + let (_, skipped) = export("Set", &[&partial], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Parent".into(), step: 0, id: "actions.play".into() }]); + for params in [json!({"action": "Child"}), json!({"action": "Child", "from": 0})] { + let whole = Action { name: "Parent".into(), steps: vec![("actions.play".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Set", &[&whole], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("actions.play".to_string(), json!({"action": "Child"}))]); + } +} + /// #7646: an action's function key keeps Photoshop's Shift and Command options through /// `.atn` load and save, as `Shift+F5`, `Cmd+F5` and `Cmd+Shift+F5` shortcuts. #[test] From 3f509f06ed585815310ba18ed8dd4efb0cfd7fc7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 18:01:48 +0000 Subject: [PATCH 237/304] Integration: regenerate docs/scorecard.md --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index b320028936..c4b53a20f8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 168 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -179,7 +179,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 25 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 25 of 169** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. From 0b6480cdc5d67582c4d78edc320a6bed9e037438 Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 14:13:55 -0400 Subject: [PATCH 238/304] Address review: adapter override process, wasm build, saved-name warning - PHOTOCRAFT_GPU_ADAPTER now counts as a per-process launch option, so a second launch with it set doesn't forward to the running window and drop it. - Gate RenderState::available_adapters to native targets (it doesn't exist on wasm, which broke the web build). - Share the adapter name matcher (engine prefs) between startup and Preferences, so a saved fragment like "nvidia" isn't flagged as missing when it matches a listed adapter. Co-Authored-By: Claude Sonnet 5.5 --- apps/photocraft/src/gpu_startup.rs | 4 +--- apps/photocraft/src/main.rs | 6 +++++- crates/engine/src/prefs.rs | 9 +++++++++ crates/engine/src/prefs/tests.rs | 10 ++++++++++ crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/prefs_ui.rs | 7 +++++-- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index a709e99676..411708c8f8 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -279,9 +279,7 @@ pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option bool { - let name = name.to_lowercase(); - let mut words = wanted.split_whitespace().peekable(); - words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) + photocraft_engine::prefs::gpu_adapter_matches(name, wanted) } /// The user asked for an adapter and none of `adapters` that can present is it. diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index befff83026..1c06e23898 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -496,7 +496,7 @@ fn main() -> eframe::Result { unreadable_paths, safe_gpu, new_instance, - launch_options, + mut launch_options, present_mode_arg, frame_latency_arg, stroke_trace_arg, @@ -516,6 +516,10 @@ fn main() -> eframe::Result { } } + // An adapter named in the environment is chosen at startup, which a forwarded launch never reaches. + if std::env::var("PHOTOCRAFT_GPU_ADAPTER").is_ok_and(|v| !v.trim().is_empty()) { + launch_options.push("PHOTOCRAFT_GPU_ADAPTER"); + } // One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs // become tabs in its window. Only with the per-user settings folder (a portable copy's may be // shared between users), and not for automation, `--new-instance` or per-process options. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index eed66a3247..375fb9ceac 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1067,6 +1067,15 @@ pub fn is_hidden(path: &str) -> bool { /// still load, save and round-trip on every platform. pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer", "performance.gpuAdapter"]; +/// Whether a graphics adapter called `name` is the one `performance.gpuAdapter` (`wanted`) asks +/// for: every whitespace-separated word of `wanted` appears in the name, ignoring case ("nvidia", +/// "arc a370m"). An empty `wanted` matches nothing. Startup and Preferences share this. +pub fn gpu_adapter_matches(name: &str, wanted: &str) -> bool { + let name = name.to_lowercase(); + let mut words = wanted.split_whitespace().peekable(); + words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) +} + /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> { Some(match path { diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index bbb9df4ff2..c5970adb19 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -509,6 +509,16 @@ fn gpu_adapter_round_trips_and_defaults_to_automatic() { assert_eq!(u.prefs().performance.gpu_adapter, ""); } +#[test] +fn gpu_adapter_names_match_by_word_ignoring_case() { + let arc = "Intel(R) Arc(tm) A370M Graphics (DG2)"; + assert!(gpu_adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); + assert!(gpu_adapter_matches(arc, " ARC A370M ")); + assert!(!gpu_adapter_matches(arc, "arc a380")); + assert!(!gpu_adapter_matches(arc, "")); + assert!(!gpu_adapter_matches(arc, " ")); +} + #[test] fn gpu_backend_round_trips_and_validates() { let mut s = session(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5634dfa72b..52c6408154 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1013,6 +1013,8 @@ impl PhotocraftApp { let tile = self.session.prefs().performance.cache_tile_size; // Escaped driver/setup panics must leave the session and CPU canvas alive. self.perf.gpu_info.set_adapter(&rs.adapter.get_info()); + // The browser has no adapter list (egui-wgpu only has it natively). + #[cfg(not(target_arch = "wasm32"))] self.perf.gpu_info.set_available(&rs.available_adapters); let gpu = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)))) { Ok(gpu) => gpu, diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 1b59083261..9a57a41277 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1344,8 +1344,11 @@ fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, adapters: Option<&Va fn graphics_adapter_row(ui: &mut egui::Ui, adapters: Option<&Value>, obj: &mut Map) { let mut current = obj.get("gpuAdapter").and_then(Value::as_str).unwrap_or("").to_string(); let mut names: Vec = adapters.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); - let missing = !current.is_empty() && !names.contains(¤t); - if missing { + // A saved name fragment ("nvidia") isn't an adapter's full name but still selects one; only a + // value no listed adapter matches is marked. + let kept = !current.is_empty() && !names.contains(¤t); + let missing = kept && !names.iter().any(|n| prefs::gpu_adapter_matches(n, ¤t)); + if kept { names.push(current.clone()); } let automatic = tl!("Automatic"); From cec0e843e66dbd53e362f2c977b07064465bbd9b Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Mon, 19 Oct 2026 14:17:32 -0400 Subject: [PATCH 239/304] refactor(algo): route the splitmix64 copies through one function (#7603) * route the splitmix64 copies through one function * document the splitmix64 generators --------- Co-authored-by: Brandon Thomas --- crates/algo/src/features.rs | 7 ++----- crates/algo/src/inpaint.rs | 8 +++---- crates/algo/src/photo_util.rs | 39 ++++++++++++++++++++++++++++++----- crates/algo/src/render2.rs | 8 +++---- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index ec74891074..80de7b993e 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15; struct Rng(u64); impl Rng { + /// The next value of the SplitMix64 sequence. fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } fn unit(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index ee472b2296..2dd40198d8 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20; #[derive(Clone)] pub(crate) struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed`. pub(crate) fn new(seed: u64) -> Self { Rng(seed ^ 0x9E37_79B9_7F4A_7C15) } + /// The next value of the SplitMix64 sequence. pub(crate) fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]). pub(crate) fn skip(&mut self, n: u64) { diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index b29612769b..7316088079 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -159,15 +159,21 @@ pub(crate) fn par_map(n: usize, f: impl Fn(usize) -> T + Sync + Send) - (0..n).map(f).collect() } +/// Advances `state` one SplitMix64 step and returns the mixed output. +pub(crate) fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + /// Deterministic 64-bit generator (SplitMix64). pub(crate) struct Rng(pub u64); impl Rng { + /// The next value of the SplitMix64 sequence. pub fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + splitmix64(&mut self.0) } pub fn below(&mut self, n: usize) -> usize { (self.next() % n.max(1) as u64) as usize @@ -209,6 +215,29 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] { mod tests { use super::*; + /// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit. + #[test] + fn splitmix64_output_is_unchanged() { + /// The SplitMix64 step as each generator inlined it before. + fn inlined(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + let mut zero = 0; + assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]); + for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] { + let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed)); + for _ in 0..10_000 { + let want = inlined(&mut copy); + assert_eq!(splitmix64(&mut shared), want); + assert_eq!(rng.next(), want); + } + } + } + /// The shared sRGB functions match the removed copies bit for bit. #[test] fn srgb_transfer_output_is_unchanged() { diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs index ed6567ca5c..19a4b8218d 100644 --- a/crates/algo/src/render2.rs +++ b/crates/algo/src/render2.rs @@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O pub struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed` and `salt`. pub fn new(seed: u32, salt: u64) -> Rng { Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D) } + /// The next value of the SplitMix64 sequence. pub fn next_u64(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Uniform in [0, 1). pub fn f(&mut self) -> f32 { From 7200f64bf1a795bd0aaf70afe2302f4df4183646 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 20 Oct 2026 05:17:49 +1100 Subject: [PATCH 240/304] Explain the Memory Usage budget and GPU caps (#6546) (#6892) * Explain the per-document memory limit and automatic GPU budget caps. * Add catalog entries for the Memory Usage explanation (#6546). The new preference strings need a row in every complete-menu language for the i18n tests. --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/cs.tsv | 6 ++++ crates/ui-egui/src/i18n/de.tsv | 6 ++++ crates/ui-egui/src/i18n/el.tsv | 6 ++++ crates/ui-egui/src/i18n/es.tsv | 6 ++++ crates/ui-egui/src/i18n/fr.tsv | 6 ++++ crates/ui-egui/src/i18n/hu.tsv | 6 ++++ crates/ui-egui/src/i18n/id.tsv | 6 ++++ crates/ui-egui/src/i18n/it.tsv | 6 ++++ crates/ui-egui/src/i18n/ja.tsv | 6 ++++ crates/ui-egui/src/i18n/ko.tsv | 6 ++++ crates/ui-egui/src/i18n/nl.tsv | 6 ++++ crates/ui-egui/src/i18n/pl.tsv | 6 ++++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++++ crates/ui-egui/src/i18n/ru.tsv | 6 ++++ crates/ui-egui/src/i18n/sr.tsv | 6 ++++ crates/ui-egui/src/i18n/tr.tsv | 6 ++++ crates/ui-egui/src/i18n/uk.tsv | 6 ++++ crates/ui-egui/src/i18n/vi.tsv | 6 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++++ crates/ui-egui/src/prefs_ui.rs | 45 ++++++++++++++++++++++++++++- 21 files changed, 164 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9cf6344d24..7894c00941 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3839,6 +3839,12 @@ navigator Zoom Zvětšení Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + Memory Usage Využití paměti + Per-document RAM limit (MB) Limit RAM na dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Nainstalovaná RAM: {ram} MB; tento limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Omezuje pixely plus Historii každého otevřeného dokumentu. Nejstarší stavy zpět se při překročení limitu zahodí. + Applies immediately when saved. Memory is not reserved in advance. Platí hned po uložení. Paměť se předem nerezervuje. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Při GPU paměti Automaticky omezuje i GPU rozpočet. Strop je čtvrtina RAM a, je-li známo, tři osminy paměti karty, minimum 512 MB. Vyšší limit GPU rozpočet nezvýší. Use Použít In Front Vpředu Behind Vzadu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 627c9eced5..6468ef3bf7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3231,6 +3231,12 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + Memory Usage Speichernutzung + Per-document RAM limit (MB) RAM-Limit pro Dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installierter RAM: {ram} MB; dieses Limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begrenzt Pixel plus Verlauf jedes offenen Dokuments. Die ältesten Widerrufsschritte werden verworfen, wenn das Limit überschritten wird. + Applies immediately when saved. Memory is not reserved in advance. Gilt sofort nach dem Speichern. Speicher wird nicht im Voraus reserviert. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Bei GPU-Speicher = Automatisch begrenzt dies auch das GPU-Ebenenbudget. Das Budget ist auf ein Viertel des RAM und, wenn bekannt, drei Achtel des Grafikspeichers begrenzt, mindestens 512 MB. Ein höheres Limit erhöht das GPU-Budget nicht. Use Verwenden In Front Davor Behind Dahinter diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4cd75535d8..0a0f1fda29 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3931,6 +3931,12 @@ curves Smooth Εξομάλυνση Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + Memory Usage Χρήση μνήμης + Per-document RAM limit (MB) Όριο RAM ανά έγγραφο (MB) + Installed RAM: {ram} MB; this limit: {percent}% Εγκατεστημένη RAM: {ram} MB· αυτό το όριο: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Περιορίζει τα pixel συν το Ιστορικό κάθε ανοιχτού εγγράφου. Οι παλαιότερες καταστάσεις αναίρεσης απορρίπτονται όταν ξεπεραστεί το όριο. + Applies immediately when saved. Memory is not reserved in advance. Ισχύει αμέσως με την αποθήκευση. Η μνήμη δεν δεσμεύεται εκ των προτέρων. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Με Μνήμη GPU σε Αυτόματο, περιορίζει και τον προϋπολογισμό GPU. Ανώτατο όριο το 1/4 της RAM και, αν είναι γνωστό, τα 3/8 της μνήμης κάρτας, ελάχιστο 512 MB. Μεγαλύτερο όριο δεν αυξάνει τον προϋπολογισμό GPU. Use Χρήση In Front Μπροστά Behind Πίσω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a48e224832..ba1183354c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4193,6 +4193,12 @@ navigator Zoom Zoom Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + Memory Usage Uso de memoria + Per-document RAM limit (MB) Límite de RAM por documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este límite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita los píxeles más el Historial de cada documento abierto. Se descartan los estados de deshacer más antiguos al superar el límite. + Applies immediately when saved. Memory is not reserved in advance. Se aplica al guardar. La memoria no se reserva de antemano. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Con Memoria GPU en Automático, también limita el presupuesto GPU. Está topeado a un cuarto de la RAM y, si se conoce, tres octavos de la memoria de la tarjeta, mínimo 512 MB. Subir el límite no aumenta el presupuesto GPU. Use Usar In Front Delante Behind Detrás diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index caff033e38..12bafb51d3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3239,6 +3239,12 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + Memory Usage Utilisation de la mémoire + Per-document RAM limit (MB) Limite RAM par document (Mo) + Installed RAM: {ram} MB; this limit: {percent}% RAM installée : {ram} Mo ; cette limite : {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limite les pixels plus l’Historique de chaque document ouvert. Les plus anciens états d’annulation sont supprimés au-delà de la limite. + Applies immediately when saved. Memory is not reserved in advance. S’applique dès l’enregistrement. La mémoire n’est pas réservée à l’avance. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Si Mémoire GPU est Automatique, cela limite aussi le budget GPU. Il est plafonné à un quart de la RAM et, si connu, aux trois huitièmes de la mémoire de la carte, minimum 512 Mo. Dépasser ces plafonds n’augmente pas le budget GPU. Use Utiliser In Front Devant Behind Derrière diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c2b1e3e293..c670182e6b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4150,6 +4150,12 @@ cameraRaw Yellow Sárga Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + Memory Usage Memóriahasználat + Per-document RAM limit (MB) Dokumentumonkénti RAM-korlát (MB) + Installed RAM: {ram} MB; this limit: {percent}% Telepített RAM: {ram} MB; ez a korlát: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Minden nyitott dokumentum pixeljeit és előzményeit korlátozza. A legrégebbi visszavonási állapotok törlődnek a korlát túllépésekor. + Applies immediately when saved. Memory is not reserved in advance. Mentéskor azonnal érvényes. A memória nincs előre lefoglalva. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Automatikus GPU-memóriánál a GPU-rétegkeretet is korlátozza. Felső határ a RAM negyede és, ha ismert, a kártyamemória háromnyolcada, minimum 512 MB. Magasabb korlát a GPU-keretet nem növeli. Use Használat In Front Elöl Behind Mögött diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9836f2262a..5142781b01 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3226,6 +3226,12 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + Memory Usage Penggunaan memori + Per-document RAM limit (MB) Batas RAM per dokumen (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM terpasang: {ram} MB; batas ini: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Membatasi piksel plus Riwayat setiap dokumen terbuka. Status urungkan tertua dibuang jika batas terlampaui. + Applies immediately when saved. Memory is not reserved in advance. Berlaku segera setelah disimpan. Memori tidak dipesan di muka. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Jika Memori GPU Otomatis, ini juga membatasi anggaran GPU. Plafon seperempat RAM dan, jika diketahui, tiga per delapan memori kartu, minimum 512 MB. Menaikkan batas tidak menambah anggaran GPU. Use Gunakan In Front Di Depan Behind Di Belakang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 09e20955ca..9ccb67dd8c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4348,6 +4348,12 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + Memory Usage Utilizzo memoria + Per-document RAM limit (MB) Limite RAM per documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM installata: {ram} MB; questo limite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita i pixel più la Cronologia di ogni documento aperto. Gli stati Annulla più vecchi vengono scartati oltre il limite. + Applies immediately when saved. Memory is not reserved in advance. Si applica al salvataggio. La memoria non è riservata in anticipo. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Con Memoria GPU Automatica limita anche il budget GPU. È limitato a un quarto della RAM e, se nota, a tre ottavi della memoria della scheda, minimo 512 MB. Alzare il limite non aumenta il budget GPU. Use Usa In Front Davanti Behind Dietro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f43978bdfe..372cd06906 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3908,6 +3908,12 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + Memory Usage メモリ使用量 + Per-document RAM limit (MB) ドキュメントあたりの RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 搭載 RAM: {ram} MB、この上限: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 開いている各ドキュメントのピクセルと履歴を制限します。上限を超えると最も古い取り消し状態が破棄されます。 + Applies immediately when saved. Memory is not reserved in advance. 保存するとすぐに適用されます。メモリは事前に予約されません。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU メモリが自動のとき、GPU レイヤー予算も制限します。上限は搭載 RAM の 1/4、判明していればグラフィックメモリの 3/8、最低 512 MB です。これ以上に上げても GPU 予算は増えません。 Use 使用 In Front 前面 Behind 背面 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 43dcbd7184..60f50975ab 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3412,6 +3412,12 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + Memory Usage 메모리 사용량 + Per-document RAM limit (MB) 문서당 RAM 한도(MB) + Installed RAM: {ram} MB; this limit: {percent}% 설치된 RAM: {ram} MB; 이 한도: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 열린 각 문서의 픽셀과 기록을 제한합니다. 한도를 넘으면 가장 오래된 실행 취소 상태가 버려집니다. + Applies immediately when saved. Memory is not reserved in advance. 저장하면 바로 적용됩니다. 메모리를 미리 예약하지 않습니다. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU 메모리가 자동이면 GPU 레이어 예산도 제한합니다. 상한은 설치 RAM의 1/4, 알려진 경우 그래픽 메모리의 3/8, 최소 512MB입니다. 이보다 올려도 GPU 예산은 늘지 않습니다. Use 사용 In Front 앞 Behind 뒤 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2bbe405810..18c37120e4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3991,6 +3991,12 @@ curves Smooth Vloeiend maken Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + Memory Usage Geheugengebruik + Per-document RAM limit (MB) RAM-limiet per document (MB) + Installed RAM: {ram} MB; this limit: {percent}% Geïnstalleerd RAM: {ram} MB; deze limiet: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Beperkt pixels plus Geschiedenis van elk geopend document. Oudste herstelstappen verdwijnen als de limiet wordt overschreden. + Applies immediately when saved. Memory is not reserved in advance. Geldt meteen na opslaan. Geheugen wordt niet vooraf gereserveerd. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Bij GPU-geheugen Automatisch beperkt dit ook het GPU-laagbudget. Maximum is een kwart RAM en, indien bekend, drie achtste van het videogeheugen, minstens 512 MB. Hoger zetten vergroot het GPU-budget niet. Use Gebruiken In Front Voor Behind Achter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e086a07d93..ca1f466d8f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3830,6 +3830,12 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + Memory Usage Użycie pamięci + Per-document RAM limit (MB) Limit RAM na dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Zainstalowany RAM: {ram} MB; ten limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ogranicza piksele plus Historię każdego otwartego dokumentu. Najstarsze stany cofania są usuwane po przekroczeniu limitu. + Applies immediately when saved. Memory is not reserved in advance. Obowiązuje zaraz po zapisaniu. Pamięć nie jest rezerwowana z góry. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Przy pamięci GPU = Automatycznie ogranicza też budżet GPU. Limit to 1/4 RAM i, jeśli znane, 3/8 pamięci karty, minimum 512 MB. Wyższy limit nie zwiększa budżetu GPU. Use Użyj In Front Z przodu Behind Z tyłu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cb16fa0326..6f98a43622 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4021,6 +4021,12 @@ layerLabel No Color Sem cor Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + Memory Usage Uso de memória + Per-document RAM limit (MB) Limite de RAM por documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este limite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita os pixels mais o Histórico de cada documento aberto. Os estados de desfazer mais antigos são descartados ao exceder o limite. + Applies immediately when saved. Memory is not reserved in advance. Aplica-se ao salvar. A memória não é reservada antecipadamente. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Com Memória GPU Automática, também limita o orçamento GPU. O teto é um quarto da RAM e, se conhecido, três oitavos da memória da placa, mínimo 512 MB. Aumentar o limite não aumenta o orçamento GPU. Use Usar In Front Na frente Behind Atrás diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 50b0081146..1fd816bf90 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3204,6 +3204,12 @@ curves Smooth Сгладить Increase Brush Size Увеличить размер кисти Decrease Brush Hardness Уменьшить жёсткость кисти Increase Brush Hardness Увеличить жёсткость кисти + Memory Usage Использование памяти + Per-document RAM limit (MB) Лимит ОЗУ на документ (МБ) + Installed RAM: {ram} MB; this limit: {percent}% Установлено ОЗУ: {ram} МБ; этот лимит: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ограничивает пиксели и историю каждого открытого документа. Старые состояния отмены удаляются при превышении лимита. + Applies immediately when saved. Memory is not reserved in advance. Применяется сразу после сохранения. Память заранее не резервируется. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. При GPU-памяти «Авто» это также ограничивает бюджет GPU. Потолок — четверть ОЗУ и, если известно, три восьмых памяти карты, минимум 512 МБ. Больший лимит GPU-бюджет не увеличивает. Command Command Use Использовать In Front Спереди diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 389469a2fe..a9ff4435bf 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4304,6 +4304,12 @@ shape Appearance Izgled Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + Memory Usage Upotreba memorije + Per-document RAM limit (MB) RAM limit po dokumentu (MB) + Installed RAM: {ram} MB; this limit: {percent}% Instalisan RAM: {ram} MB; ovaj limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ograničava piksele plus Istoriju svakog otvorenog dokumenta. Najstarija stanja opoziva se odbacuju kad se prekorači limit. + Applies immediately when saved. Memory is not reserved in advance. Važi odmah po čuvanju. Memorija se unapred ne rezerviše. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Kad je GPU memorija Automatski, ograničava i GPU budžet. Plafon je četvrtina RAM-a i, ako je poznato, tri osmine memorije kartice, najmanje 512 MB. Veći limit ne povećava GPU budžet. Use Koristi In Front Ispred Behind Iza diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c27af05e43..fb4b426c2d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3961,6 +3961,12 @@ cameraData Lens Lens Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + Memory Usage Bellek kullanımı + Per-document RAM limit (MB) Belge başına RAM sınırı (MB) + Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Her açık belgenin piksellerini ve Geçmişini sınırlar. Sınır aşılınca en eski geri alma durumları atılır. + Applies immediately when saved. Memory is not reserved in advance. Kaydedilince hemen uygulanır. Bellek önceden ayrılmaz. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU Belleği Otomatikken GPU katman bütçesini de sınırlar. Tavan, RAM’in dörtte biri ve biliniyorsa kart belleğinin sekizde üçü, en az 512 MB. Sınırı yükseltmek GPU bütçesini artırmaz. Use Kullan In Front Önde Behind Arkada diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9f68595f57..d8510183de 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3939,6 +3939,12 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + Memory Usage Використання пам’яті + Per-document RAM limit (MB) Ліміт RAM на документ (МБ) + Installed RAM: {ram} MB; this limit: {percent}% Встановлено RAM: {ram} МБ; цей ліміт: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Обмежує пікселі плюс Історію кожного відкритого документа. Найстаріші стани скасування відкидаються після перевищення ліміту. + Applies immediately when saved. Memory is not reserved in advance. Застосовується одразу після збереження. Пам’ять заздалегідь не резервується. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Якщо пам’ять GPU — Авто, це також обмежує бюджет GPU. Стеля — чверть RAM і, якщо відомо, три восьмих пам’яті карти, мінімум 512 МБ. Більший ліміт бюджет GPU не збільшує. Use Використати In Front Спереду Behind Ззаду diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e6f6312760..a1106bbbc2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3718,6 +3718,12 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + Memory Usage Sử dụng bộ nhớ + Per-document RAM limit (MB) Giới hạn RAM mỗi tài liệu (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM đã cài: {ram} MB; giới hạn này: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Giới hạn pixel cộng Lịch sử của mỗi tài liệu đang mở. Các trạng thái hoàn tác cũ nhất bị bỏ khi vượt giới hạn. + Applies immediately when saved. Memory is not reserved in advance. Áp dụng ngay khi lưu. Bộ nhớ không được dành trước. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Khi Bộ nhớ GPU là Tự động, cũng giới hạn ngân sách GPU. Trần là 1/4 RAM và, nếu biết, 3/8 bộ nhớ card, tối thiểu 512 MB. Tăng giới hạn không làm tăng ngân sách GPU. Use Dùng In Front Phía trước Behind Phía sau diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 418d631a0f..c10a287def 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2854,6 +2854,12 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + Memory Usage 内存使用 + Per-document RAM limit (MB) 每个文档的 RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 已安装 RAM:{ram} MB;此上限:{percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 限制每个打开文档的像素加历史记录。超出上限时会丢弃最旧的撤销状态。 + Applies immediately when saved. Memory is not reserved in advance. 保存后立即生效。不会预先占用内存。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. 当 GPU 内存为自动时,这也会限制 GPU 图层预算。上限为安装 RAM 的四分之一,以及(若已知)显存的八分之三,最低 512 MB。超过这些上限不会增加 GPU 预算。 Use 使用 In Front 前面 Behind 后面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cffc27969b..82015e5754 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2839,6 +2839,12 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + Memory Usage 記憶體使用量 + Per-document RAM limit (MB) 每個文件的 RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 已安裝 RAM:{ram} MB;此上限:{percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 限制每個開啟文件的像素加歷史記錄。超出上限時會捨棄最舊的復原狀態。 + Applies immediately when saved. Memory is not reserved in advance. 儲存後立即套用。不會預先保留記憶體。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. 當 GPU 記憶體為自動時,這也會限制 GPU 圖層預算。上限為安裝 RAM 的四分之一,以及(若已知)顯示卡記憶體的八分之三,最低 512 MB。超過這些上限不會增加 GPU 預算。 Use 使用 In Front 前面 Behind 後面 diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 7857b0f8f7..a3f9f26c7d 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1566,7 +1566,7 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" - || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) + || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode" | "memoryUsageMb")) // The browser presents frames itself: Low-Latency Drawing does nothing there. || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") || (section == "export" && !export_field_visible(obj, k)) @@ -1598,10 +1598,37 @@ fn section_fields( if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) { rendering_mode_row(ui, obj); } + if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", &query)) { + memory_usage_row(ui, obj); + } keys.retain(|k| field_visible(section, obj, k, &query)); egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang)); } +/// Explain the per-document RAM limit separately from the graphics-memory preference. +fn memory_usage_row(ui: &mut egui::Ui, obj: &mut Map) { + let t = Tokens::get(ui.ctx()); + ui.group(|ui| { + ui.label(RichText::new(tl!("Memory Usage")).strong()); + let mut mb = obj.get("memoryUsageMb").and_then(Value::as_u64).unwrap_or(8192); + ui.horizontal(|ui| { + ui.label(tl!("Per-document RAM limit (MB)")); + if ui.add(egui::DragValue::new(&mut mb).range(256..=1_048_576).suffix(" MB")).changed() { + obj.insert("memoryUsageMb".into(), json!(mb)); + } + }); + if let Some(ram) = crate::gpu_canvas::physical_memory().filter(|n| *n > 0) { + let installed = ram / (1 << 20); + let percent = mb as f64 * 100.0 / (ram as f64 / (1 << 20) as f64); + ui.label(crate::i18n::fmt(tl!("Installed RAM: {ram} MB; this limit: {percent}%"), &[("ram", &installed.to_string()), ("percent", &format!("{percent:.1}"))])); + } + ui.label(RichText::new(tl!("Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded.")).color(t.text_dim)); + ui.label(RichText::new(tl!("Applies immediately when saved. Memory is not reserved in advance.")).color(t.text_dim)); + ui.label(RichText::new(tl!("With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget.")).color(t.text_dim)); + }); + ui.add_space(8.0); +} + /// One grid row per setting in `keys` (already filtered by `field_visible`). fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, keys: Vec, lang: crate::i18n::Lang) { let t = Tokens::get(ui.ctx()); @@ -2486,6 +2513,22 @@ mod shortcut_set_tests; #[cfg(test)] mod tests { + #[test] + fn memory_usage_has_a_dedicated_explanation_and_keeps_its_value() { + use egui_kittest::{Harness, kittest::Queryable}; + let values = json!({"memoryUsageMb":50000}).as_object().unwrap().clone(); + assert!(!field_visible("performance", &values, "memoryUsageMb", "")); + let mut h = Harness::builder() + .with_size(vec2(680.0, 350.0)) + .build_ui_state(|ui, values| section_fields(ui, "performance", values, &["memoryUsageMb".into()], crate::i18n::Lang::EN, None, ""), values); + h.run_steps(2); + h.get_by_label("Memory Usage"); + h.get_by_label("Per-document RAM limit (MB)"); + h.get_by_label("Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded."); + h.get_by_label("Applies immediately when saved. Memory is not reserved in advance."); + assert_eq!(h.state()["memoryUsageMb"], 50000); + } + #[test] fn colour_preferences_take_a_typed_hex_value() { // #668: the canvas colour (and every colour preference) showed its hex but couldn't take one. From dfe94d6ab96bc03e2b57e6b57b7bb348bf44cba8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:18:18 +0200 Subject: [PATCH 241/304] Actions: export Box Blur, Surface Blur, Flatten Image and Black & White under ids Photoshop runs (#6330) (#6331) Save Actions wrote these four steps under the four-char codes BoxB, SrfB, Fltn and BlnW, which Photoshop does not run: executeAction fails with "the command is not currently available" (measured in Photoshop on a document with a filled pixel layer; Fltn resolves to the flatness property, not the flatten event). The names boxblur, surfaceBlur and blackAndWhite and the code FltI run. - Box Blur, Surface Blur and Black & White now export by name (boxblur, surfaceBlur, blackAndWhite); Flatten Image exports as FltI. The descriptors are unchanged. - Import accepts the names, FltI and flattenImage, and still accepts the old codes (a LEGACY_CODES table consulted only for four-char codes), so actions PhotoCraft saved before keep loading. - Tests: each step exports the new id and imports back to the same command and params under the new id, the name and the legacy code; a set mixing Photoshop-shaped and legacy steps loads and re-saves with the new ids; near-miss ids (case, padding, empty, NULs) stay Photoshop steps; broken descriptors don't panic. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 21 +++++++-- crates/engine/src/atn_cmds/tests.rs | 73 ++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 3e4c34b81a..0cfbc31156 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2691,8 +2691,8 @@ const EVENTS: &[Event] = &[ ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]), ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]), - ev!("BoxB", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), - ev!("SrfB", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!( "SmrB", "smartBlur", @@ -2768,7 +2768,7 @@ const EVENTS: &[Event] = &[ adjustment_event!("Exps", "Exps", "exposure", "Exposure"), adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), Event { - code: "BlnW", + code: "", name: "blackAndWhite", label: "Black & White", ids: &["image.adjustments.blackWhite"], @@ -2960,7 +2960,7 @@ const EVENTS: &[Event] = &[ from: move_from, to: move_to, }, - ev!("Fltn", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), + ev!("FltI", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), Event { code: "MrgV", name: "mergeVisible", @@ -3046,9 +3046,20 @@ const EVENTS: &[Event] = &[ Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; +/// Four-char codes PhotoCraft once exported for events Photoshop only runs by name (or by +/// another code), with the name they stand for. Measured in Photoshop: `executeAction` with +/// these codes fails ("the command is not currently available"), and `Fltn` is the +/// `flatness` property, not the flatten event. Import keeps accepting them so actions saved +/// before still load; export never writes them. +const LEGACY_CODES: &[(&str, &str)] = &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite")]; + fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) + let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); + find(&id).or_else(|| match item { + AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), + AtnEvent::Name(_) => None, + }) } /// The event `id` exports as: the first event listing it whose converter accepts the params diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 37744831cc..f22a153232 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -144,7 +144,7 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(file.name.to_string_lossy(), "Test Set"); assert_eq!(file.actions[0].function_key, 3); let events: Vec = file.actions[0].items.iter().map(|i| i.event.id()).collect(); - assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "Fltn"]); + assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "FltI"]); assert!(file.actions[0].items[0].with_dialog); assert!(!file.actions[0].items[8].enabled); // The kept Photoshop step is written back byte for byte. @@ -1037,6 +1037,77 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn` and +/// `BlnW` ("the command is not currently available"), while the names `boxblur`, +/// `surfaceBlur`, `flattenImage`, `blackAndWhite` and the code `FltI` run. +#[test] +fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() { + let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"}); + let cases = [ + ("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"), + ("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"), + ("layer.flattenImage", json!({}), "FltI", "Fltn"), + ("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"), + ]; + for (id, params, written, legacy) in &cases { + let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + assert_eq!(exported.event.id(), *written, "{id} exports under an id Photoshop runs"); + assert_eq!(matches!(exported.event, AtnEvent::Name(_)), written.len() != 4, "{id}: a name is written as a name"); + let d = exported.descriptor.clone(); + // The written id, the name for Flatten Image, and the old code all import to the command. + let name = if *id == "layer.flattenImage" { "flattenImage" } else { written }; + for event in [*written, name, *legacy] { + let e = event_for(&item(event, "", None).event).unwrap_or_else(|| panic!("{event} does not import")); + assert!(e.ids.contains(id), "{event} imports as {:?}, not {id}", e.ids); + match (e.from)(d.as_ref().unwrap()) { + Mapped::Steps(steps) => { + assert_eq!(steps.len(), 1, "{event}: {steps:?}"); + assert_eq!(steps[0].0, *id, "{event}"); + let mut back = steps[0].1.clone(); + normalise(&mut back); + assert_eq!(back, *params, "{event}"); + } + Mapped::Unsupported { .. } => panic!("{event} does not import back"), + } + } + } + + // A whole set of Photoshop-shaped steps (names, FltI) and old PhotoCraft-shaped ones + // (legacy codes) loads to the same commands. + let blur = |event: &str| item(event, "Box Blur", Some(desc("null").with("Rds ", px(3.0)))); + let surface = |event: &str| item(event, "Surface Blur", Some(desc("null").with("Rds ", px(5.0)).with("Thsh", long(15.0)))); + let photoshop = vec![blur("boxblur"), surface("surfaceBlur"), item("FltI", "Flatten Image", None), item("flattenImage", "Flatten Image", None)]; + let legacy = vec![blur("BoxB"), surface("SrfB"), item("Fltn", "Flatten Image", None)]; + let data = set_of("S", vec![("Photoshop", photoshop), ("Legacy", legacy)]); + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let ids = |i: usize| s.actions.list[i].steps.iter().map(|(id, _)| id.clone()).collect::>(); + assert_eq!(ids(0), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage", "layer.flattenImage"]); + assert_eq!(ids(1), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage"]); + assert_eq!(s.actions.list[1].steps[1].1, json!({"radius": 5, "threshold": 15})); + // Saving them writes the ids Photoshop runs, never the old codes. + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["boxblur", "surfaceBlur", "FltI", "FltI", "boxblur", "surfaceBlur", "FltI"]); + + // Unknown ids, near misses and the legacy codes written as names stay Photoshop steps, + // never a panic or a wrong command. + for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx"] { + let e = event_for(&item(event, "", None).event); + assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids)); + } + // A legacy code with a broken descriptor is unsupported, not a crash. + let e = event_for(&item("SrfB", "", None).event).unwrap(); + assert!(matches!((e.from)(&desc("null").with("Rds ", D::Double(f64::NAN))), Mapped::Unsupported { .. } | Mapped::Steps(_))); + // A wrong-typed key is skipped like any other unreadable field: the step keeps the defaults. + match (e.from)(&desc("null").with("Rds ", text("x"))) { + Mapped::Steps(steps) => assert!(steps.iter().all(|(_, p)| p.get("radius").is_none()), "{steps:?}"), + Mapped::Unsupported { .. } => {} + } +} + /// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in /// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. #[test] From 209a384ef0ebdc18fd45ae14989cc78afff949e2 Mon Sep 17 00:00:00 2001 From: Taylor Burke Date: Mon, 19 Oct 2026 13:18:33 -0500 Subject: [PATCH 242/304] Home screen: fit the card and stack the buttons in a narrow window (#5774) * Home screen: fit the card and stack the buttons in a narrow window The Home screen's card was a fixed 460 points wide, centred on the canvas, with two 190-point buttons side by side. A tiling window manager can give PhotoCraft a canvas narrower than that (Hyprland on Omarchy gave a 728-point window, below the 760-point minimum), so the New document and Open buttons were clipped at the edges. The card is now as wide as the canvas when the canvas is narrower than 460 points. The buttons are sized from their labels (so longer translations still fit) and are stacked when two of them don't fit side by side; the card grows by one row when they stack. Adds start_screen_buttons_stay_inside_a_narrow_canvas, which fails before this change (New document starts at x = -1 in a 390-point canvas). Co-Authored-By: Claude Sonnet 5.5 * contributors: add TaylorBurke Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 ++ crates/ui-egui/src/canvas.rs | 74 ++++++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 0860ea17db..34c50d4d9f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -163,6 +163,9 @@ sync_github_name = true real_name = "Sidney Markowitz" sync_github_name = true +[people.TaylorBurke] +sync_github_name = true + [people.1Git2Clone] sync_github_name = true diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 053716a572..02fb1b33c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2824,6 +2824,13 @@ fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) { } } +/// The Home screen's card is this wide when the canvas has room, and as wide as the canvas when it doesn't. +const HOME_CARD_WIDTH: f32 = 460.0; +/// Least width of each of the Home screen's New document and Open buttons. +const HOME_BUTTON_WIDTH: f32 = 190.0; +/// Space between the Home screen's two buttons. +const HOME_BUTTON_GAP: f32 = 12.0; + fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let area = ui.available_rect_before_wrap(); @@ -2834,9 +2841,16 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let recent: Vec = if cfg!(target_arch = "wasm32") || !show_recent { Vec::new() } else { app.ui.recent_files.iter().take(HOME_RECENT).cloned().collect() }; let recent_h = if recent.is_empty() { 0.0 } else { 34.0 + recent.len() as f32 * HOME_RECENT_ROW }; - let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 330.0 + recent_h)); + // A tiling window manager can give the canvas less room than the card wants: shrink the card + // to fit, and stack the buttons when they don't fit side by side. let new_label = crate::shortcuts::command_label(app, "New document…", "file.new"); let open_label = crate::shortcuts::command_label(app, "Open…", "file.open"); + let card_width = HOME_CARD_WIDTH.min(area.width()); + let button_width = HOME_BUTTON_WIDTH.max(crate::widgets::button_width(ui, &[&new_label, &open_label])); + let side_by_side = 2.0 * button_width + HOME_BUTTON_GAP <= card_width; + // Stacked buttons take one more row. + let stacked_h = if side_by_side { 0.0 } else { 44.0 }; + let card = Rect::from_center_size(area.center(), egui::vec2(card_width, 330.0 + recent_h + stacked_h)); ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { ui.vertical_centered(|ui| { ui.horizontal(|ui| { @@ -2852,17 +2866,26 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.label(egui::RichText::new(tl!("Create a new document or open an existing file.")).color(t.text_dim).size(14.0)); ui.add_space(22.0); - ui.horizontal(|ui| { - ui.add_space(((card.width() - 2.0 * 190.0 - 12.0) / 2.0).max(0.0)); - ui.spacing_mut().item_spacing.x = 12.0; - if crate::widgets::primary_button(ui, &new_label, 190.0).clicked() { - let fields = app.new_document_fields(); - app.ui.open_dialog(crate::state::DialogKind::NewDocument, fields); - } - if crate::widgets::secondary_button(ui, &open_label, 190.0).clicked() { - let _ = app.open_dialog_file(); - } - }); + let (mut new_clicked, mut open_clicked) = (false, false); + if side_by_side { + ui.horizontal(|ui| { + ui.add_space(((card.width() - 2.0 * button_width - HOME_BUTTON_GAP) / 2.0).max(0.0)); + ui.spacing_mut().item_spacing.x = HOME_BUTTON_GAP; + new_clicked = crate::widgets::primary_button(ui, &new_label, button_width).clicked(); + open_clicked = crate::widgets::secondary_button(ui, &open_label, button_width).clicked(); + }); + } else { + new_clicked = crate::widgets::primary_button(ui, &new_label, button_width).clicked(); + ui.add_space(HOME_BUTTON_GAP); + open_clicked = crate::widgets::secondary_button(ui, &open_label, button_width).clicked(); + } + if new_clicked { + let fields = app.new_document_fields(); + app.ui.open_dialog(crate::state::DialogKind::NewDocument, fields); + } + if open_clicked { + let _ = app.open_dialog_file(); + } ui.add_space(22.0); ui.horizontal(|ui| { let msg = start_screen_drop_hint(app); @@ -7273,6 +7296,33 @@ mod tests { assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}"); } + /// A tiling window manager can hand PhotoCraft a canvas narrower than the Home card (460 + /// points): the two buttons must stay inside the window instead of being clipped at its edges. + #[test] + fn start_screen_buttons_stay_inside_a_narrow_canvas() { + use egui_kittest::kittest::Queryable; + for width in [390.0, 420.0, 520.0] { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui)); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(3); + for label in ["New document", "Join us on Discord"] { + for node in h.query_all_by_label_contains(label) { + let r = node.rect(); + assert!(r.left() >= 0.0 && r.right() <= width, "`{label}` is clipped in a {width}-point-wide canvas: {r:?}"); + } + } + } + } + /// #4382: Interface › Show home recent files off hides the Home screen's list, not the recents. #[test] fn home_recent_list_hides_when_the_preference_is_off() { From 98d3ebc1f5d88010962ae1e5df62585cbe08ad8d Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:19:05 +0200 Subject: [PATCH 243/304] perf(codecs): Avoid redundant PNG filter passes (#7690) * perf(codecs): Avoid redundant PNG filter passes Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression. * docs(perf): measure PNG filtering on real images * perf(codecs): stop PNG candidates at the winning cost * Drop the local benchmark report Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/png.rs | 138 +++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 19 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index ede70d296d..f894b3a30d 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -541,8 +541,15 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o } }; let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); - let mut best = (cost(row), 0u8); + let mut best = cost(row); + let start = out.len(); + out.push(0); + out.extend_from_slice(row); for kind in 1u8..=4 { + if best == 0 { + break; + } + let mut c = 0u64; for i in 0..n { let x = row[i]; scratch[i] = match kind { @@ -551,27 +558,17 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; + c += u64::from((scratch[i] as i8).unsigned_abs()); + if c >= best { + break; + } } - let c = cost(&scratch[..n]); - if c < best.0 { - best = (c, kind); + if c < best { + best = c; + out[start] = kind; + out[start + 1..].copy_from_slice(&scratch[..n]); } } - out.push(best.1); - if best.1 == 0 { - out.extend_from_slice(row); - return; - } - for i in 0..n { - let x = row[i]; - scratch[i] = match best.1 { - 1 => x.wrapping_sub(left(i)), - 2 => x.wrapping_sub(up(i)), - 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), - _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), - }; - } - out.extend_from_slice(&scratch[..n]); } const ADLER_MOD: u32 = 65_521; @@ -779,6 +776,109 @@ mod indexed_tests { mod parallel_tests { use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_filter_selection() { + use std::{hint::black_box, time::Instant}; + for pattern in ["zero", "identical", "varied"] { + let row: Vec = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect(); + let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect(); + let mut scratch = vec![0; row.len()]; + let mut out = Vec::with_capacity(row.len() + 1); + for trial in 0..6 { + type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec); + let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + for _ in 0..4000 { + out.clear(); + black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out); + black_box(&out); + } + println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0); + } + } + } + } + + // The main-branch implementation before this refactor. + fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec) { + let n = row.len(); + let up = |i: usize| prev.map_or(0, |p| p[i]); + let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 }; + let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 }; + let paeth = |a: u8, b: u8, c: u8| { + let p = i16::from(a) + i16::from(b) - i16::from(c); + let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + }; + let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); + let mut best = (cost(row), 0u8); + for kind in 1u8..=4 { + for i in 0..n { + let x = row[i]; + scratch[i] = match kind { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + let c = cost(&scratch[..n]); + if c < best.0 { + best = (c, kind); + } + } + out.push(best.1); + if best.1 == 0 { + out.extend_from_slice(row); + return; + } + for i in 0..n { + let x = row[i]; + scratch[i] = match best.1 { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + out.extend_from_slice(&scratch[..n]); + } + + #[test] + fn adaptive_filter_preserves_minimum_cost_and_ties() { + for bpp in [1, 2, 3, 4, 6, 8] { + for seed in 0..32usize { + let row: Vec = (0..137).map(|i| (i * seed + i / 5) as u8).collect(); + let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect(); + for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] { + let mut expected = vec![99]; + let mut actual = expected.clone(); + let mut scratch = vec![0; row.len()]; + previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected); + filter_row(&row, prev, bpp, &mut scratch, &mut actual); + assert_eq!(actual, expected, "bpp={bpp}, seed={seed}"); + } + } + } + let mut out = Vec::new(); + filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); + assert_eq!(out, vec![0; 17]); + out.clear(); + filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out); + assert_eq!(out, [vec![1, 1], vec![0; 15]].concat()); + } + #[test] fn adler_combine_matches_one_pass() { let a: Vec = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect(); From 2cc107d05f67f3f263cbeef880ec1eb8eddbda52 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:19:15 +0200 Subject: [PATCH 244/304] Crop: a ui.pointer click on the frame leaves it alone (#7740) (#7741) `to_press` and `control_point` read a point in the press-time view through `remap`, which rounds it through f32 and the view even when the view hasn't moved. A `ui.pointer` down + up at the same point then came back slightly apart: the frame moved by a hair and a box Undo step was pushed that undid nothing visible. Skip the remap while the view is still the press view. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/crop_mode.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index 676c97dbec..a1264126bf 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -212,10 +212,11 @@ fn remap(p: [f64; 2], from: PressView, to: PressView, flip: bool) -> [f64; 2] { } /// A pointer point `p` the canvas mapped through the view as it is now, read in the press-time -/// view `press` (unchanged without one). +/// view `press` (unchanged without one, or while the view hasn't moved: the `f32` round trip +/// would nudge a click). pub fn to_press(app: &PhotocraftApp, press: Option, p: [f64; 2]) -> [f64; 2] { match (press, press_view(app)) { - (Some(press), Some(now)) => remap(p, now, press, app.ui.view.flip_horizontal), + (Some(press), Some(now)) if press != now => remap(p, now, press, app.ui.view.flip_horizontal), _ => p, } } @@ -239,7 +240,7 @@ pub fn control_point(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { _ => None, }; match (press, press_view(app)) { - (Some(press), Some(now)) if app.ui.tool == Tool::Crop => remap(p, press, now, app.ui.view.flip_horizontal), + (Some(press), Some(now)) if app.ui.tool == Tool::Crop && press != now => remap(p, press, now, app.ui.view.flip_horizontal), _ => p, } } @@ -452,6 +453,30 @@ mod tests { assert!(((c1[0] - c0[0]).hypot(c1[1] - c0[1]) - 30.0).abs() < 1e-3); } + /// A `ui.pointer` click (down and up at the same point, no move) changes nothing, upright or + /// turned: reading the point in the press view used to round it through `f32` and the view, + /// nudging the frame by a hair and pushing a box Undo step that undid nothing visible. + #[test] + fn a_ui_pointer_click_changes_nothing() { + let ctx = egui::Context::default(); + for deg in [0.0, -30.0, 41.5] { + let mut app = app(); + with_frame(&mut app); + app.ui.crop_angle = deg; + sync_frame(&mut app); + let (rect, angle) = (app.ui.crop_rect, app.ui.crop_angle); + // Inside the frame (moves the image) and outside it (turns the image). + for p in [[70.3, 50.7], [55.17, 41.9], [83.01, 59.33], [150.3, 50.7], [10.9, 90.1]] { + let ev = json!([{"kind": "down", "x": p[0], "y": p[1]}, {"kind": "up", "x": p[0], "y": p[1]}]); + let (req, _rx) = crate::control::ControlRequest::new("ui.pointer", json!({"events": ev})); + let crate::control::Outcome::Done(v) = crate::control::handle(&mut app, &ctx, &req) else { panic!("ui.pointer didn't finish") }; + assert_eq!(v["ok"], true, "{v}"); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), (rect, angle), "click at {p:?}, frame at {deg}°"); + assert_eq!(crate::crop_ui::is_enabled(&app, "edit.undo"), None, "click at {p:?}, frame at {deg}°: a box Undo step"); + } + } + } + #[test] fn the_readout_is_the_images_turn_clockwise_positive() { let mut app = app(); From 5a649f8e4ea4638df4e590138cd032834d718da1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:19:22 +0200 Subject: [PATCH 245/304] Apply background jobs to the layers they started on (#7620) Fixes #7366 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/jobs.rs | 55 +++++++++- crates/ui-egui/tests/remove_channel_target.rs | 100 ++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/remove_channel_target.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 583465b46e..0ee0573170 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -219,12 +219,36 @@ struct Running { started_ms: f64, coalesce: Option, color_restrict: Option, + /// The document's layer selection when the job started: applying targets it, not whatever + /// got selected meanwhile (a Type click does that without editing the document, #7366). + layers: Option, rx: std::sync::mpsc::Receiver>, apply: Option, #[cfg(not(target_arch = "wasm32"))] handle: Option>, } +/// A document's active layer, selected layers and selection anchor. +#[derive(Clone, PartialEq)] +struct LayerSelection { + active: Option, + selected: Vec, + anchor: Option, +} + +impl LayerSelection { + fn of(st: &crate::DocState) -> Self { + Self { active: st.active_layer, selected: st.selected_layers.clone(), anchor: st.layer_anchor } + } + + fn set(self, st: &mut crate::DocState) { + st.active_layer = self.active; + st.selected_layers = self.selected; + st.layer_anchor = self.anchor; + crate::channel_cmds::fix_view(st); + } +} + impl Running { fn info(&self) -> JobInfo { JobInfo { @@ -333,10 +357,10 @@ pub fn run( if s.jobs.pending.is_some() { return Err(EngineError::Other(format!("`{label}` started a second background job in one command"))); } - let (doc, snapshot) = match (lock_document, s.active()) { - (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc))), + let (doc, snapshot, layers) = match (lock_document, s.active()) { + (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc)), Some(LayerSelection::of(st))), (true, None) => return Err(EngineError::NoDocument), - (false, _) => (None, None), + (false, _) => (None, None, None), }; let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel::>(); @@ -382,6 +406,7 @@ pub fn run( started_ms: now_ms(), coalesce: s.coalesce_request.clone(), color_restrict: s.color_restrict, + layers, rx, apply: Some(erased), #[cfg(not(target_arch = "wasm32"))] @@ -843,7 +868,7 @@ impl Session { } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, .. } = job; + let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, .. } = job; let prev_active = self.active; let target = match doc { Some(d) => { @@ -858,6 +883,19 @@ impl Session { } None => None, }; + // Selecting a layer doesn't change the document, so the snapshot check above lets it + // through. Apply against the layers the job started on: the colour restriction and any + // `active`-based edit land on them (#7366). The user's selection is put back below. + let user_layers = match (target, layers) { + (Some(i), Some(started)) => self.docs.get_mut(i).and_then(|st| { + let now = LayerSelection::of(st); + (now != *started).then(|| { + started.clone().set(st); + now + }) + }), + _ => None, + }; self.coalesce_request = coalesce.clone(); self.color_restrict = *color_restrict; // Apply-time hooks can dispatch commands too. Keep the same single-step recording @@ -880,6 +918,15 @@ impl Session { if r.is_ok() { self.after_command(command, params.clone(), *journal, Some(nested_from)); } + // Give the user back the layers they selected while the job ran, unless the job chose a + // selection itself (a new layer, say) or one of their layers is gone. + if let (Some(started), Some(user), Some(st)) = (layers, user_layers, target.and_then(|i| self.docs.get_mut(i))) { + let kept = LayerSelection::of(st) == *started; + let exist = user.active.iter().chain(&user.selected).all(|id| st.doc.layer(*id).is_some()); + if (r.is_err() || kept) && exist { + user.set(st); + } + } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. if target.is_some() && self.active == target { diff --git a/crates/ui-egui/tests/remove_channel_target.rs b/crates/ui-egui/tests/remove_channel_target.rs new file mode 100644 index 0000000000..5d18009baa --- /dev/null +++ b/crates/ui-egui/tests/remove_channel_target.rs @@ -0,0 +1,100 @@ +//! #7366: a background Remove on a Red-only target keeps G/B/alpha intact when a Type click +//! selects another layer while the job is pending. + +use photocraft_doc::LayerId; +use photocraft_engine::Session; +use photocraft_engine::jobs::Started; +use photocraft_ui_egui::{PhotocraftApp, Services, control, type_tool}; + +const REMOVE: &str = r#"{"points":[[12,10],[16,12]],"size":6}"#; + +fn run(s: &mut Session, id: &str, p: &str) { + s.execute(id, p.parse().unwrap()).unwrap(); +} + +fn pixels(s: &Session, doc: usize, id: LayerId) -> Vec { + let d = &s.documents()[doc].doc; + d.layer(id).unwrap().surface().unwrap().read_region(d.bounds()) +} + +#[derive(Clone, Copy)] +enum Input { + None, + TypeTool, + ControlPointer, +} + +fn probe(input: Input) { + let mut a = PhotocraftApp::new(Session::new(), Services::default()); + run(&mut a.session, "file.new", r#"{"name":"A","width":32,"height":24,"background":"transparent"}"#); + run(&mut a.session, "type.create", r#"{"text":"X","x":2,"y":18,"size":14}"#); + let text = a.session.active().unwrap().active_layer.unwrap(); + run(&mut a.session, "layer.new.layer", r#"{"name":"Target"}"#); + let target = a.session.active().unwrap().active_layer.unwrap(); + a.session + .edit("fixture", |d, l| { + let q = d.layer_mut(l.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..24 { + for x in 0..32 { + q.write_pixel(x, y, &[((x * 7 + y * 13) % 31) as f32 / 30., (x % 5) as f32 / 4., (y % 7) as f32 / 6., 1.]); + } + } + Ok(()) + }) + .unwrap(); + run(&mut a.session, "file.new", r##"{"name":"B","width":32,"height":24,"background":"#6a219c"}"##); + a.session.set_active(0); + a.sync_views(); + run(&mut a.session, "channel.target", r#"{"channel":"red"}"#); + let b = a.session.documents()[1].doc.clone(); + let text_before = a.session.documents()[0].doc.layer(text).unwrap().clone(); + + // The inline Red-only Remove is the oracle. + let mut inline = Session::new(); + inline.add_document(a.session.documents()[0].doc.as_ref().clone(), None); + inline.select_layer(target).unwrap(); + run(&mut inline, "channel.target", r#"{"channel":"red"}"#); + run(&mut inline, "paint.remove", REMOVE); + let expected = pixels(&inline, 0, target); + + let job = match a.session.start("paint.remove", REMOVE.parse().unwrap()).unwrap() { + Started::Job(id) => id, + v => panic!("must start a background job: {v:?}"), + }; + match input { + Input::None => {} + Input::TypeTool => assert!(type_tool::pointer_down(&mut a, 4., 14., Default::default()), "the click must hit the text"), + Input::ControlPointer => { + let (req, _rx) = control::ControlRequest::new("ui.pointer", r#"{"tool":"type","events":[{"kind":"down","x":4,"y":14}]}"#.parse().unwrap()); + let _ = control::handle(&mut a, &Default::default(), &req); + } + } + let selected = if matches!(input, Input::None) { target } else { text }; + assert_eq!(a.session.active().unwrap().active_layer, Some(selected), "the input selects the text layer"); + + a.session.set_active(1); + a.session.wait_job(job).unwrap(); + assert_eq!(a.session.documents()[1].doc, b, "B is unchanged"); + let st = &a.session.documents()[0]; + assert_eq!(st.doc.layer(text).unwrap(), &text_before, "the text layer is unchanged"); + assert_eq!(st.active_layer, Some(selected), "the user's selection survives the job"); + let actual = pixels(&a.session, 0, target); + let wrong = expected.as_chunks::<4>().0.iter().zip(actual.as_chunks::<4>().0).filter(|(e, r)| e[1..] != r[1..]).count(); + assert_eq!(wrong, 0, "pixels with G/B/alpha changed outside the Red target"); + assert_eq!(actual, expected); +} + +#[test] +fn no_input_keeps_red_restriction() { + probe(Input::None); +} + +#[test] +fn type_tool_click_keeps_red_restriction() { + probe(Input::TypeTool); +} + +#[test] +fn control_pointer_click_keeps_red_restriction() { + probe(Input::ControlPointer); +} From 73b79953e6026998da1522bffcda3cf749453ce4 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:19:28 -0400 Subject: [PATCH 246/304] =?UTF-8?q?Print:=20Save=20as=20PDF=20gets=20a=20B?= =?UTF-8?q?rowse=E2=80=A6=20button=20(#5328)=20(#7586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save as PDF was only a free text field, so saving a PDF meant typing a full path by hand. Browse… opens the standard Save dialog (pick_save), suggesting the document's name as a PDF, and puts the chosen path in the field. file_ui::body now gets the dialog id so the answer can be written back into the Print dialog on a later frame. Refs storytold/photocraft#5328 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/file_ui.rs | 61 +++++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2e951e2ece..e27411b7e0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -336,7 +336,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::tool_reset::owns(&fields) => crate::tool_reset::body(ui), DialogKind::Command if crate::crop_presets::owns(&fields) => crate::crop_presets::body(app, ui, &mut fields), DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields), - DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields), + DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields, d.id), DialogKind::Command if crate::color_picker_ui::owns(&fields) => { outcome = crate::color_picker_ui::body(ui, &mut fields); crate::color_picker_ui::take_add_swatch(app, &mut fields); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index a84bfe7779..14a512c288 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -168,11 +168,12 @@ pub fn ok_label(f: &Map) -> Option<&'static str> { } } -pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +/// The body of Save for Web or Print (dialog `id`). +pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64) { if f.contains_key("__web") { web_body(app, ui, f); } else { - print_body(app, ui, f); + print_body(app, ui, f, id); } } @@ -865,7 +866,22 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { } } -fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +/// Save as PDF › Browse…: where to write the PDF, into Print dialog `id` once the file dialog +/// answers. Without it the only way to save a PDF was to type a full path (#5328). +fn browse_pdf(app: &mut PhotocraftApp, id: u64, suggested: &str) { + let r = app.pick_save(suggested, move |app, path| { + let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; + d.fields.insert("output".into(), json!(path)); + Ok(Value::Null) + }); + if let Err(e) = r + && e != crate::file_dialog::CANCELLED + { + app.ui.status = e; + } +} + +fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; // PDF exports and previews retain editable paper on Windows too. Only a real native @@ -885,6 +901,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { @@ -1470,6 +1495,28 @@ mod tests { assert_eq!(f["copies"], 1, "what wasn't remembered keeps its default"); } + /// #5328: Save as PDF has Browse…, which fills the field from a Save dialog (suggesting the + /// document's name as a PDF) rather than needing a typed path. + #[test] + fn save_as_pdf_browse_fills_the_output_from_a_save_dialog() { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + use egui_kittest::{Harness, kittest::Queryable}; + let out = std::env::temp_dir().join(format!("pc-printui-browse-{}", std::process::id())).to_string_lossy().into_owned(); + let (show, asked) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(out.clone()))]); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }) + }); + h.state_mut().run("file.new", json!({"width": 120, "height": 80, "name": "poster"})).unwrap(); + let id = open_print(h.state_mut()); + h.run_steps(3); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Save { suggested }] if suggested.ends_with("poster.pdf")), "{:?}", asked.borrow()); + // A name chosen without an extension gets the suggested one. + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["output"], json!(format!("{out}.pdf"))); + } + #[test] fn print_dialog_does_not_inherit_scripted_send_override() { for send in [false, true] { From b67502e20fd02a21f5a0bf9002459c63a15b89a3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:19:35 +0200 Subject: [PATCH 247/304] Reveal All: one History step named "Reveal All" (#7145) (#7147) image.revealAll ran image.crop, whose step is labelled "Crop". It now runs it inside analysis_cmds::compound with the label "Reveal All". Co-authored-by: Claude Opus 5.5 --- crates/engine/src/extra_cmds.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 08ed6cfa0f..072a3d4417 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -872,7 +872,9 @@ pub fn specs() -> Vec { if all == d.doc.bounds() { return Ok(json!({"changed": false})); } - s.execute("image.crop", json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}))?; + // One History step named as in Photoshop, not the crop's "Crop". + let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}); + crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?; Ok(json!({"changed": true})) }), spec!( @@ -1760,8 +1762,12 @@ mod tests { s.execute("layer.translate", json!({"dx": 0, "dy": 0})).ok(); paint_square(&mut s, Rect::new(-5, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]); assert_eq!(reveal_all_bounds(doc(&s)), Rect::new(-5, 0, 40, 30)); + let steps = s.active().unwrap().history.past_len(); s.execute("image.revealAll", json!({})).unwrap(); assert_eq!((doc(&s).size.width, doc(&s).size.height), (45, 30)); + let history = &s.active().unwrap().history; + assert_eq!(history.past_len(), steps + 1, "one step"); + assert_eq!(history.entries().last().map(String::as_str), Some("Reveal All"), "named as in Photoshop, not Crop"); assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false); } From bf638a7a228ccc9527ed346c969cad0dfd5c2754 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:19:42 +0200 Subject: [PATCH 248/304] Crop: Enter during a drag ends the drag (#7055) (#7056) Pressing Enter while the frame is still being dragged commits the crop; commit_crop now ends the drag, so the release no longer restores a frame in the old document's coordinates as a pending crop. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 ++ crates/ui-egui/src/crop_ui.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 02fb1b33c4..b20f827987 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -6148,6 +6148,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { crate::crop_ui::cancel_stale(app); // Committing ends the box's own Undo/Redo: from here Edit › Undo reverts the document. crate::crop_ui::clear_history(app); + // ↵ during a drag ends it: the release must not bring back the old document's frame. + app.crop.drag = None; let Some(r) = app.ui.crop_rect.take() else { return }; // The crop's default mode leaves the view upright (`crop_mode`). crate::crop_mode::upright(app); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..00700ae5c0 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1724,6 +1724,22 @@ mod tests { } } + #[test] + fn enter_during_a_drag_ends_the_drag() { + // ↵ while the frame is still being dragged commits it; the release after must not bring + // back a frame in the old document's coordinates as a pending crop. + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 40.0, pressure: 1.0 }, NONE); + crate::canvas::commit_crop(&mut app); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, NONE); + ensure_frame(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); + assert!(!pending(&app)); + } + #[test] fn degenerate_and_bad_input_never_panics() { let mut app = app(SampleType::U8); From e3224000cac1a30a19e239d67dcad110ccb7c8b2 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Mon, 19 Oct 2026 19:19:48 +0100 Subject: [PATCH 249/304] fix(psd): make packbits::decode_into crate-private and overflow-safe (#6950) decode_into computed out.len() + expected unchecked. With a non-empty buffer and an expected length near usize::MAX it panicked in debug builds and, in release, wrapped and returned Ok having decoded nothing. Compute the target with checked_add and report LimitExceeded, and test each packet as len > target - out.len() so the bound checks cannot overflow either. The only callers are decode and decode_rle in this module, and the crate README documents packbits::{encode, decode} as the public API, so decode_into becomes pub(crate). Co-authored-by: Claude Opus 5.5 --- crates/psd/src/compression.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index aab785dff4..68782464ad 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -111,9 +111,10 @@ pub mod packbits { /// /// Errors if the input is exhausted early or a packet would overflow the /// row. Trailing input after the row is complete is ignored except for - /// `-128` no-op bytes (which are always permitted). - pub fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { - let target = out.len() + expected; + /// `-128` no-op bytes (which are always permitted). Errors, rather than + /// overflowing, when `out.len() + expected` does not fit in `usize`. + pub(crate) fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { + let target = out.len().checked_add(expected).ok_or(PsdError::LimitExceeded("PackBits output length overflows usize"))?; let mut i = 0; while out.len() < target { let Some(&h) = src.get(i) else { @@ -126,7 +127,7 @@ pub mod packbits { let Some(lit) = src.get(i..i + len) else { return Err(PsdError::Decompress("PackBits literal truncated".into())); }; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits literal overflows row".into())); } out.extend_from_slice(lit); @@ -138,7 +139,7 @@ pub mod packbits { return Err(PsdError::Decompress("PackBits run truncated".into())); }; i += 1; - if out.len() + len > target { + if len > target - out.len() { return Err(PsdError::Decompress("PackBits run overflows row".into())); } out.extend(std::iter::repeat_n(b, len)); @@ -687,6 +688,20 @@ mod tests { assert!(packbits::decode(&[3, 1, 2, 3, 4], 3).is_err()); // literal overflows } + /// `out.len() + expected` past `usize::MAX` used to panic in debug builds and, in release, + /// wrap and return `Ok` having decoded nothing. + #[test] + fn packbits_decode_into_output_length_overflow_is_an_error() { + let mut out = vec![7u8]; + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX, &mut out); + assert!(matches!(r, Err(PsdError::LimitExceeded(_))), "{r:?}"); + assert_eq!(out, [7]); + // The largest `expected` that fits still decodes up to the input, then reports it short. + let r = packbits::decode_into(&[0x00, 0xaa], usize::MAX - 1, &mut out); + assert!(matches!(r, Err(PsdError::Decompress(_))), "{r:?}"); + assert_eq!(out, [7, 0xaa]); + } + #[test] fn packbits_zero_expected_ignores_input() { assert_eq!(packbits::decode(&[0xff, 1], 0).unwrap(), Vec::::new()); From 2cdc8cb73df08e82866fb4d28727675940a151ae Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:19:55 +0200 Subject: [PATCH 250/304] Content-Aware Fill: preview at full resolution so it matches the result (#6530) (#6838) --- crates/ui-egui/src/filter_dialog.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index effb52694d..a908df5eba 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -203,6 +203,9 @@ fn is_pixel_param_of(command: &str, key: &str) -> bool { /// Sharpen, Sharpen More, Sharpen Edges, Find Edges, Emboss and Despeckle use fixed one-pixel /// kernels with no size parameter, so a proxy changes their look: edges average away, making /// the sharpening and edge detection invisible. These must preview at full resolution (#3929). +/// +/// Content-Aware Fill searches for matching patches, and the matches found on a reduced copy +/// differ from those found at full size, so its preview would not show what OK writes (#6530). const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.distort.ripple", "filter.stylize.diffuse", @@ -225,6 +228,7 @@ const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.stylize.findEdges", "filter.stylize.emboss", "filter.noise.despeckle", + "edit.contentAwareFill", ]; /// Whether `command` with `params` previews at full resolution: [`FULL_RESOLUTION_PREVIEW`], every @@ -2094,6 +2098,15 @@ mod tests { assert_eq!(preview_factor("image.mode.bitmap", &json!({"method": "threshold"}), 4), 4); } + /// #6530: the Content-Aware Fill preview runs at full resolution, for every Sampling choice, + /// because its patch search finds different matches on a reduced copy. + #[test] + fn content_aware_fill_previews_at_full_resolution() { + for params in [json!({}), json!({"sampling": "auto"}), json!({"sampling": "rectangular"})] { + assert_eq!(preview_factor("edit.contentAwareFill", ¶ms, 4), 1, "{params}"); + } + } + /// #2063: proxy previews scaled what isn't a length (Tree's canvas fraction, Lens Correction's /// perspective), clamped negative lengths to +0.1 (Offset left/up), left Shadows/Highlights' /// radii in document pixels and rounded Mosaic's 10 px cells to 3 proxy px (12 px). From 77b845e0986b7baabbfc8d3f714a9ffa3cdc0bb3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:02 +0200 Subject: [PATCH 251/304] Actions: loading a set renames each Play target once (#6806) (#6807) Load Actions applied each rename as its own pass over every action in the set, so chained renames ("A" -> "A 2", "A 2" -> "A 2 2") retargeted a step twice, and with replace:false it also rewrote steps of actions already in the set. Now one original->new map is applied once to the newly loaded actions' Play and Conditional (then/else) steps and to function keys. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/atn_cmds.rs | 30 ++++++++++++++++---------- crates/engine/src/atn_cmds/tests.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 0cfbc31156..9e8eb6cb7c 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3290,31 +3290,39 @@ fn load(s: &mut Session, p: &Value) -> Result { } let mut taken: Vec = s.actions.list.iter().map(|a| a.name.clone()).collect(); let mut names = Vec::with_capacity(imp.actions.len()); - let mut renamed = Vec::new(); + // The file's name of each action that had to be renamed, and its new name. + let mut renamed: std::collections::HashMap = std::collections::HashMap::new(); let mut steps = 0usize; + let mut added = Vec::with_capacity(imp.actions.len()); for mut a in imp.actions { let name = unique(&a.name, &taken); if name != a.name { - renamed.push((a.name.clone(), name.clone())); + renamed.entry(a.name.clone()).or_insert_with(|| name.clone()); } a.name = name.clone(); taken.push(name.clone()); steps = steps.saturating_add(a.steps.len()); names.push(name); - s.actions.list.push(a); + added.push(a); } - // A Play step calls its action by name: follow renames within the set. - for (from, to) in &renamed { - for a in s.actions.list.iter_mut().filter(|a| a.set.as_deref() == Some(imp.set.as_str())) { - for (id, params) in &mut a.steps { - if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(from) { - params["action"] = json!(to); + // Play and Conditional steps call actions by name: follow the renames in the actions just + // loaded, each target looked up once by the name the file gave it. + for a in &mut added { + for (id, params) in &mut a.steps { + let keys: &[&str] = match id.as_str() { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + }; + for key in keys { + if let Some(to) = params.get(*key).and_then(Value::as_str).and_then(|n| renamed.get(n)) { + params[*key] = json!(to); } } } } - let keys: Vec<(String, String)> = - imp.function_keys.into_iter().map(|(n, k)| (renamed.iter().find(|(f, _)| f == &n).map_or(n, |(_, t)| t.clone()), k)).collect(); + s.actions.list.extend(added); + let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect(); if !keys.is_empty() { s.edit_prefs(|prefs| { for (name, key) in &keys { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index f22a153232..26865d4060 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -130,6 +130,39 @@ fn load_maps_events_keeps_flags_and_plays() { assert_eq!(s.actions.list[2].set.as_deref(), Some("Other")); } +/// Renames that chain ("A" becomes "A 2" while the file's own "A 2" becomes "A 2 2") rewrite +/// each Play and Conditional target once, to the renamed copy of the action it named. +#[test] +fn load_renames_each_play_target_once() { + let mut s = Session::new(); + let mut mine = Action::new("A", vec![]); + mine.set = Some("Mine".into()); + s.actions.list.push(mine); + let a = Action::new("A", vec![("actions.conditional".into(), json!({"if": "always", "then": "A", "else": "A 2"}))]); + let a2 = Action::new("A 2", vec![("actions.play".into(), json!({"action": "A"}))]); + let keys = [("A".to_string(), "F4".to_string())]; + let (bytes, _) = export("File", &[&a, &a2], &keys, false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + assert_eq!(r["actions"], json!(["A 2", "A 2 2"])); + let get = |n: &str| s.actions.list.iter().find(|a| a.name == n && a.set.as_deref() == Some("File")).unwrap(); + assert_eq!(get("A 2 2").steps[0].1["action"], "A 2", "plays the file's A, not its A 2"); + let cond = &get("A 2").steps[0].1; + assert_eq!((&cond["then"], &cond["else"]), (&json!("A 2"), &json!("A 2 2")), "{cond}"); + assert_eq!(r["functionKeys"], json!([{"action": "A 2", "key": "F4"}])); + + // Adding to a set of the same name leaves the Play steps already in it alone. + let mut s = Session::new(); + for (name, steps) in [("A", vec![]), ("B", vec![("actions.play".to_string(), json!({"action": "A"}))])] { + let mut a = Action::new(name, steps); + a.set = Some("File".into()); + s.actions.list.push(a); + } + let (bytes, _) = export("File", &[&Action::new("A", vec![])], &[], false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes), "replace": false})).unwrap(); + assert_eq!(r["actions"], json!(["A 2"])); + assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had"); +} + #[test] fn save_round_trips_through_photoshop_events() { let mut s = Session::new(); From 5e1d90d86e62dd5d969f7f454cbc8d25628692a2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:09 +0200 Subject: [PATCH 252/304] Preferences: a wrongly typed value resets only that field (#6611) (#6641) One value of the wrong type in prefs.json (say "recentFileCount": "ten") made the whole file fail to deserialize, so every preference fell back to its default for the session, and since the file was never rewritten it happened again on every launch. load_prefs_json now reads leniently when the file doesn't deserialize as a whole: it starts from the defaults and lays the file's values over them one at a time, descending into section objects and map entries, keeping every value that still reads and leaving out only the ones that don't. Colour settings get the same treatment instead of resetting as a whole. It returns the left-out values (keys and original value); text that isn't a JSON object still fails as before. On launch the app shows a notice naming the reset paths and saves once: the three-way merge base gets the unreadable values, so the merge replaces (or removes) exactly those in the stored file and keeps everything else, including keys this version doesn't know. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/prefs.rs | 109 +++++++++++++++++++++++++++++-- crates/engine/src/prefs/tests.rs | 47 +++++++++++++ crates/ui-egui/src/prefs_ui.rs | 78 ++++++++++++++++++++-- 3 files changed, 225 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index b551032e9d..77acd2e346 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1609,6 +1609,100 @@ impl PrefsStore { } } +/// A saved preference [`Session::load_prefs_json`] couldn't read (a wrong type, an unknown +/// choice) and left at its default. +#[derive(Clone, Debug, PartialEq)] +pub struct InvalidPref { + /// JSON keys from the top of the preferences file (`["fileHandling", "recentFileCount"]`), + /// kept apart because keys such as command ids contain dots. + pub keys: Vec, + /// The value the file held there. + pub value: Value, +} + +impl InvalidPref { + /// The dotted path, as `prefs.get` and `prefs.set` take it. + pub fn path(&self) -> String { + self.keys.join(".") + } +} + +/// How deep [`read_lenient`] looks for the readable parts of an unreadable value. +const LENIENT_DEPTH: usize = 32; + +/// Read `v` as a `T`. When that fails, start from `T::default()` and lay the file's values over +/// it one at a time, keeping each one the whole still reads with and descending into objects +/// that don't, so one bad value resets only itself. The values left out are added to +/// `invalid`, their keys after `prefix`. +fn read_lenient(v: Value, prefix: &[&str], invalid: &mut Vec) -> T +where + T: Default + Serialize + for<'de> Deserialize<'de>, +{ + if let Ok(t) = T::deserialize(&v) { + return t; + } + let prefix: Vec = prefix.iter().map(|k| (*k).to_string()).collect(); + let mut acc = serde_json::to_value(T::default()).unwrap_or(Value::Null); + let fits = |root: &Value| T::deserialize(root).is_ok(); + match v { + Value::Object(file) if acc.is_object() && fits(&acc) => { + lay_over(&mut acc, &mut Vec::new(), file, &fits, &prefix, invalid); + T::deserialize(&acc).unwrap_or_default() + } + value => { + invalid.push(InvalidPref { keys: prefix, value }); + T::default() + } + } +} + +/// [`read_lenient`]'s walk: lay `file` over the object at `at` inside `root`, key by key, so that +/// `root` always reads (`fits`). +fn lay_over( + root: &mut Value, + at: &mut Vec, + file: Map, + fits: &dyn Fn(&Value) -> bool, + prefix: &[String], + invalid: &mut Vec, +) { + for (k, fv) in file { + let Some(obj) = object_at(root, at) else { return }; + let old = obj.insert(k.clone(), fv.clone()); + if fits(root) { + continue; + } + // Back to the default. An object keeps its parts that read, and so does a new map entry + // (a shortcut set, a workspace) when it reads from an empty object. + let Some(obj) = object_at(root, at) else { return }; + let mut descend = fv.is_object() && at.len() < LENIENT_DEPTH && matches!(old, None | Some(Value::Object(_))); + match old { + Some(o) => obj.insert(k.clone(), o), + None if descend => obj.insert(k.clone(), Value::Object(Map::new())), + None => obj.remove(&k), + }; + at.push(k); + if descend && !fits(root) { + descend = false; + if let Some((key, parent)) = at.split_last() + && let Some(obj) = object_at(root, parent) + { + obj.remove(key); + } + } + match fv { + Value::Object(m) if descend => lay_over(root, at, m, fits, prefix, invalid), + value => invalid.push(InvalidPref { keys: prefix.iter().chain(at.iter()).cloned().collect(), value }), + } + at.pop(); + } +} + +/// The JSON object at `path` inside `root`. +fn object_at<'a>(root: &'a mut Value, path: &[String]) -> Option<&'a mut Map> { + path.iter().try_fold(root, |cur, k| cur.get_mut(k.as_str()))?.as_object_mut() +} + impl Session { /// The current preferences. pub fn prefs(&self) -> &Preferences { @@ -1657,8 +1751,14 @@ impl Session { /// Restore preferences saved by [`Session::prefs_to_json`]. Missing keys keep their /// defaults and unknown keys are ignored, so files from older and newer versions load. - pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result<(), String> { + /// A value of the wrong type (a hand-edited `"recentFileCount": "ten"`) keeps its default + /// and everything else still loads (#6611); the returned list names those values. Only + /// text that isn't a JSON object fails as a whole. + pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result, String> { let mut v: Value = serde_json::from_str(s).map_err(|e| format!("preferences: {e}"))?; + if !v.is_object() { + return Err("preferences: expected a JSON object".into()); + } // Saved preferences before appearance modes had one concrete theme. Preserve its // appearance instead of silently switching established users to Auto. if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut) @@ -1679,9 +1779,10 @@ impl Session { } let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); - let prefs: Preferences = serde_json::from_value(v).map_err(|e| format!("preferences: {e}"))?; + let mut invalid = Vec::new(); + let prefs: Preferences = read_lenient(v, &[], &mut invalid); if let Some(c) = color { - self.color.settings = serde_json::from_value(c).unwrap_or_default(); + self.color.settings = read_lenient(c, &["colorSettings"], &mut invalid); photocraft_compose::psblend::set_text_gamma(self.color.settings.blend_text_gamma); } self.prefs.edit(|p| *p = prefs); @@ -1689,7 +1790,7 @@ impl Session { self.load_presets_json(v); } self.apply_prefs(); - Ok(()) + Ok(invalid) } /// Full JSON view used by `prefs.get`: preferences plus `colorSettings`. diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 5314d1f197..55e41949b9 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -915,3 +915,50 @@ fn a_stranded_backup_is_named_in_the_error() { assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_wrongly_typed_value_resets_only_that_field() { + // #6611: one bad value used to reset every preference. + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": {"beepWhenDone": true}, "fileHandling": {"recentFileCount": "ten", "autosaveMinutes": 5}, + "performance": {"historyStates": 20}, "shortcuts": {"edit.fill": "Cmd+Shift+F", "file.open": 7}}"#, + ) + .unwrap(); + assert!(s.prefs().general.beep_when_done); + assert_eq!(s.prefs().file_handling.autosave_minutes, 5, "a sibling in the same section survives"); + assert_eq!(s.prefs().file_handling.recent_file_count, 20, "the bad value takes the default"); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.prefs().shortcuts.get("edit.fill").map(String::as_str), Some("Cmd+Shift+F")); + assert!(!s.prefs().shortcuts.contains_key("file.open")); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["fileHandling.recentFileCount", "shortcuts.file.open"]); + assert_eq!(invalid[0].value, json!("ten")); + assert_eq!(invalid[1].keys, ["shortcuts", "file.open"]); +} + +#[test] +fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": "loud", "performance": {"historyStates": 20}, + "colorSettings": {"workingRgb": "display-p3", "askOnMismatch": "yes"}}"#, + ) + .unwrap(); + assert_eq!(s.prefs().general, General::default()); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.color.settings.working_rgb, "display-p3", "the rest of the colour settings load"); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["general", "colorSettings.askOnMismatch"]); + // A valid file reports nothing; one that isn't an object still fails as a whole. + assert!(s.load_prefs_json(&s.prefs_to_json()).unwrap().is_empty()); + for bad in ["[1, 2]", "42", "\"prefs\"", "null", "{", ""] { + assert!(s.load_prefs_json(bad).is_err(), "{bad}"); + } + // Deep nesting and odd values don't panic. + let deep = format!("{{\"general\": {}1{}}}", "{\"a\":".repeat(100), "}".repeat(100)); + assert!(s.load_prefs_json(&deep).is_ok()); + assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index a3f9f26c7d..d808ae32f4 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -233,11 +233,15 @@ pub fn load(app: &mut PhotocraftApp) { return; } app.prefs_rt.loaded = true; - if let Some(text) = app.services.load_prefs.as_mut().and_then(|f| f()) - && let Err(e) = app.session.load_prefs_json(&text) - { - let template = "Preferences were reset: {error}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + let mut invalid = Vec::new(); + if let Some(text) = app.services.load_prefs.as_mut().and_then(|f| f()) { + match app.session.load_prefs_json(&text) { + Ok(found) => invalid = found, + Err(e) => { + let template = "Preferences were reset: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + } + } } crate::dock::restore(app); crate::brush_picker::restore(app); @@ -248,6 +252,37 @@ pub fn load(app: &mut PhotocraftApp) { app.sync_recent(); app.prefs_rt.saved_rev = app.session.prefs.rev(); app.prefs_rt.saved_value = Some(app.session.prefs_value()); + if !invalid.is_empty() { + repair_invalid(app, &invalid); + } +} + +/// Some saved values couldn't be read and took their defaults (#6611): say which, and write +/// the defaults over them on the next save so the next launch reads a clean file. The save +/// base holds the unreadable values, so the three-way merge sees them as changed here and +/// replaces (or removes) exactly those, keeping everything else in the stored file. +fn repair_invalid(app: &mut PhotocraftApp, invalid: &[prefs::InvalidPref]) { + if let Some(base) = app.prefs_rt.saved_value.as_mut() { + for bad in invalid { + let Some((key, parent)) = bad.keys.split_last() else { + continue; + }; + if let Some(obj) = parent.iter().try_fold(&mut *base, |cur, k| cur.get_mut(k.as_str())).and_then(Value::as_object_mut) { + obj.insert(key.clone(), bad.value.clone()); + } + } + } + app.session.prefs.edit(|_| ()); + let paths = invalid.iter().map(prefs::InvalidPref::path).collect::>().join(", "); + let (title, line) = ( + "Some preferences were reset", + "The preferences file held values PhotoCraft can't read for {paths}. Those settings are back to their defaults; the rest of your preferences loaded.", + ); + // Notices keep their English source and are translated when drawn. + let id = crate::notices::post(app, title, vec![line.into()], false, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|n| n.id == id) { + notice.args.push(("paths".into(), paths)); + } } /// Discover recovery entries after construction, then decode one at a time without blocking @@ -3096,6 +3131,39 @@ mod tests { serde_json::from_str(store.lock().unwrap().as_deref().unwrap_or("null")).unwrap() } + #[test] + fn a_wrongly_typed_value_resets_only_itself_and_is_repaired_on_disk() { + // #6611: one bad value used to reset every preference, on every launch. + let saved = json!({ + "fileHandling": {"recentFileCount": "ten", "autosaveMinutes": 5}, + "performance": {"historyStates": 12}, + "shortcuts": {"edit.fill": "Cmd+Shift+F", "file.open": 7}, + "futureSection": {"kept": true}, + }); + let (mut app, store) = app_with_saved(Some(saved.to_string())); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + let p = app.session.prefs(); + assert_eq!((p.file_handling.recent_file_count, p.file_handling.autosave_minutes, p.performance.history_states), (20, 5, 12)); + assert!(!app.ui.status.contains("reset"), "{}", app.ui.status); + let notice = app.ui.notices.iter().find(|n| n.title == "Some preferences were reset").expect("a notice names the bad values"); + let text = notice.text(¬ice.lines[0]); + assert!(text.contains("fileHandling.recentFileCount, shortcuts.file.open"), "{text}"); + // The file now holds the default in its place and keeps everything else. + let v = stored(&store); + assert_eq!(v["fileHandling"]["recentFileCount"], 20); + assert_eq!(v["fileHandling"]["autosaveMinutes"], 5); + assert_eq!(v["performance"]["historyStates"], 12); + assert_eq!(v["shortcuts"]["edit.fill"], "Cmd+Shift+F"); + assert!(v["shortcuts"].get("file.open").is_none()); + assert_eq!(v["futureSection"]["kept"], true, "keys this version doesn't know survive"); + // So the next launch loads cleanly. + let (mut next, _) = app_with_saved(store.lock().unwrap().clone()); + tick(&mut next, &ctx); + assert!(next.ui.notices.iter().all(|n| n.title != "Some preferences were reset")); + assert_eq!(next.session.prefs().file_handling.autosave_minutes, 5); + } + #[test] fn merge_keeps_changes_made_elsewhere() { let base = json!({"a": {"x": 1, "y": 1}, "list": [1], "gone": 1, "kept": 1}); From c136d2dd3db255f194098530be284c186561c979 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:16 +0200 Subject: [PATCH 253/304] Guides: Ctrl/Cmd drags a guide with every tool that moves guides, and the cursor shows it (#6288) (#6294) With Ctrl (Cmd on the Mac) held over a guide, Photoshop's tools that turn Ctrl into the Move tool show the guide cursor and drag the guide. Three gaps remained: - The Frame and Artboard tools showed the guide cursor under Ctrl but took the press themselves (a new frame or board), so the guide never moved. Their pointer handlers checked the selected tool instead of the effective one. - The Gradient tool's live mode took a Ctrl press sent with the event (automation, `ui.pointer` with `command: true`) instead of handing it to the Move tool. - With Window > Modifier Keys' latched Ctrl, the press took the guide but the pointer kept the tool's own cursor: `command_guide_at`, the decision the cursor and the guide context menu use, knew only the selection tools. `command_guide_at` now covers every `hold_keys::cmd_moves` tool, and not during a stroke or type editing. Frame, Artboard and live Gradient step aside for a Ctrl press when they have no drag of their own under way. Tools that keep Ctrl for themselves (Pen, Hand, Zoom, Crop, type, shapes, slices) and Lock Guides are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/artboard_tool.rs | 4 +- crates/ui-egui/src/canvas.rs | 15 +- crates/ui-egui/src/canvas_tool_menu.rs | 3 +- crates/ui-egui/src/frame_tool.rs | 4 +- crates/ui-egui/src/gradient_ui.rs | 5 + crates/ui-egui/src/guide_cursor_tests.rs | 204 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/rulers.rs | 12 +- 8 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/guide_cursor_tests.rs diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 0b12c36127..7f8ee838c6 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -182,7 +182,9 @@ fn follow(d: &mut ArtboardDrag, p: [f64; 2], mods: egui::Modifiers) { /// Pointer input for the Artboard tool. Returns true when the event was its own. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { - if app.ui.tool != Tool::Artboard { + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, + // so it drags a guide or the layer (#6288). A drag already under way keeps its events. + if app.ui.tool != Tool::Artboard || (app.ui.artboard_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Artboard) { return false; } let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b20f827987..3ebecc75ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5363,7 +5363,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if crate::gradient_ui::pointer(app, ev, mods) { return; } - // ⌘ with a selection tool over a guide takes the guide (#2690); the guide drag below moves it. + // ⌘ over a guide takes the guide (#2690, #6288); the guide drag below moves it. if let ToolEvent::Down { x, y, .. } = raw && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) { @@ -5758,13 +5758,16 @@ pub(crate) fn command_moves_layer(app: &PhotocraftApp, tool: Tool, p: [f64; 2], selection_tool && mods.command && !mods.shift && !floating && app.ui.polygon.is_empty() && !inside_selection(app, p) } -/// The guide a ⌘ press with selection tool `tool` at `p` drags (vertical?, index), as the Move -/// tool's press would (#2690): ⌘ is the Move tool, and a guide under the pointer comes before the -/// layer or the selected pixels. Not while a polygon or lasso outline is being drawn. +/// The guide a ⌘ press with `tool` at `p` drags (vertical?, index), as the Move tool's press +/// would (#2690): ⌘ is the Move tool, and a guide under the pointer comes before the layer or the +/// selected pixels. The selection tools, and every tool on which ⌘ is the Move tool +/// (`hold_keys::cmd_moves`, #6288); the canvas shows the guide cursor where this finds a guide. +/// Not while a polygon or lasso outline, a stroke, Free Transform or type editing is under way. pub(crate) fn command_guide_at(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option<(bool, usize)> { let selection_tool = matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand); - let idle = app.ui.polygon.is_empty() && !crate::lasso_ui::active(app) && app.ui.transform.is_none(); - (selection_tool && mods.command && idle).then(|| crate::rulers::guide_at(app, p[0], p[1])).flatten() + let moves = selection_tool || crate::hold_keys::cmd_moves(tool); + let idle = app.ui.polygon.is_empty() && !crate::lasso_ui::active(app) && app.ui.transform.is_none() && app.ui.text_edit.is_none() && app.drag.is_none(); + (moves && mods.command && idle).then(|| crate::rulers::guide_at(app, p[0], p[1])).flatten() } /// Photoshop's cursor over a selection: what a press (or the drag under way) would do. diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index f7f9892d2f..43418d5ff9 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -566,7 +566,8 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: } /// Right-click at document point `doc` on a guide that `tool` could drag (the Move tool, or ⌘ with -/// a selection tool): select the guide and open the guide menu at screen point `pos`. +/// a tool on which ⌘ moves guides, `canvas::command_guide_at`): select the guide and open the guide +/// menu at screen point `pos`. pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui::Modifiers, pos: [f32; 2], doc: [f64; 2]) -> bool { if !pos.iter().all(|v| v.is_finite()) || !doc.iter().all(|v| v.is_finite()) { return false; diff --git a/crates/ui-egui/src/frame_tool.rs b/crates/ui-egui/src/frame_tool.rs index 4a1210b874..b2102b1cfb 100644 --- a/crates/ui-egui/src/frame_tool.rs +++ b/crates/ui-egui/src/frame_tool.rs @@ -64,7 +64,9 @@ fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { /// Pointer input for the Frame tool. Returns true when the event was its own. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { - if app.ui.tool != Tool::Frame { + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, + // so it drags a guide or the layer (#6288). A drag already under way keeps its events. + if app.ui.tool != Tool::Frame || (app.ui.frame_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Frame) { return false; } let (square, centred) = (mods.shift, mods.alt); diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index be35f66028..dd91c1a74d 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -219,6 +219,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> app.gradient.drag = None; return false; } + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held drags a guide or the layer + // (#6288). A drag already under way keeps its events. + if app.gradient.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Gradient { + return false; + } let zoom = app.point_zoom().max(0.01); match ev { ToolEvent::Down { x, y, .. } => { diff --git a/crates/ui-egui/src/guide_cursor_tests.rs b/crates/ui-egui/src/guide_cursor_tests.rs new file mode 100644 index 0000000000..fd0e5c0595 --- /dev/null +++ b/crates/ui-egui/src/guide_cursor_tests.rs @@ -0,0 +1,204 @@ +//! #6288: ⌘ (Ctrl off the Mac) over a guide is the Move tool's guide drag with every tool on +//! which ⌘ is the Move tool, and the pointer shows it (the guide's double arrow), as in +//! Photoshop. Driven through real egui input, so the held key goes through `hold_keys`. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } + .to_screen(x, y) +} + +fn hover(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) -> egui::CursorIcon { + h.event(egui::Event::ModifiersChanged(m)); + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: f32, to: f32, m: Modifiers) { + let p = screen(h, from, 150.0); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }); + h.run_steps(1); + for k in 1..=4 { + let q = screen(h, from + (to - from) * k as f32 / 4.0, 150.0); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + let q = screen(h, to, 150.0); + h.event(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: m }); + h.run_steps(2); +} + +fn diagonal(h: &mut Harness<'static, PhotocraftApp>, a: [f32; 2], b: [f32; 2], m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + let p = screen(h, a[0], a[1]); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }); + h.run_steps(1); + for k in 1..=4 { + let t = k as f32 / 4.0; + let q = screen(h, a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + let q = screen(h, b[0], b[1]); + h.event(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: m }); + h.run_steps(2); +} + +/// The cursor over the guide with ⌘ held, and where a ⌘-drag from 200 to 240 leaves it. +fn command_drag(tool: Tool, locked: bool) -> (egui::CursorIcon, Vec) { + let mut h = harness(tool); + h.state_mut().ui.extras.lock_guides = locked; + let c = hover(&mut h, 200.0, 150.0, Modifiers::COMMAND); + drag(&mut h, 200.0, 240.0, Modifiers::COMMAND); + (c, h.state().session.active().unwrap().doc.guides.vertical.clone()) +} + +const GUIDE: egui::CursorIcon = egui::CursorIcon::ResizeHorizontal; + +#[test] +fn command_over_a_guide_shows_the_guide_cursor_and_drags_it_with_painting_tools() { + for tool in [ + Tool::Brush, + Tool::Eraser, + Tool::Gradient, + Tool::PaintBucket, + Tool::CloneStamp, + Tool::Eyedropper, + Tool::Dodge, + Tool::SpotHealing, + Tool::Smudge, + Tool::QuickSelection, + Tool::RectMarquee, + Tool::Lasso, + ] { + assert_eq!(command_drag(tool, false), (GUIDE, vec![240.0]), "{tool:?}"); + } +} + +/// The Frame and Artboard tools took a ⌘ press as their own (a new frame or board) although ⌘ +/// is the Move tool on them, so the guide cursor promised a drag that never moved the guide. +#[test] +fn command_drags_a_guide_with_the_frame_and_artboard_tools() { + for tool in [Tool::Frame, Tool::Artboard] { + assert_eq!(command_drag(tool, false), (GUIDE, vec![240.0]), "{tool:?}"); + } + // Off a guide, ⌘ is the Move tool too: no frame is drawn. + let mut h = harness(Tool::Frame); + let layers = h.state().session.active().unwrap().doc.layers.len(); + diagonal(&mut h, [100.0, 100.0], [160.0, 140.0], Modifiers::COMMAND); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers, "⌘-drag with the Frame tool draws no frame"); + assert!(h.state().ui.frame_tool.drag.is_none()); + // Without ⌘ the Frame tool still draws its frame. + diagonal(&mut h, [20.0, 20.0], [120.0, 90.0], Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1, "a plain drag draws a frame"); +} + +#[test] +fn locked_guides_show_no_guide_cursor_and_do_not_move() { + for tool in [Tool::Brush, Tool::Eraser, Tool::Frame] { + let (cursor, guides) = command_drag(tool, true); + assert_ne!(cursor, GUIDE, "{tool:?}"); + assert_eq!(guides, vec![200.0], "{tool:?}"); + } +} + +/// Tools that keep ⌘ for themselves (Photoshop: the Pen's Direct Selection, the Hand, Zoom, Crop, +/// type, shapes, slices) neither show the guide cursor nor drag the guide. +#[test] +fn tools_with_their_own_command_keep_it() { + for tool in [Tool::Pen, Tool::Hand, Tool::Zoom, Tool::Crop, Tool::Type, Tool::Rectangle, Tool::Slice] { + let (cursor, guides) = command_drag(tool, false); + assert_ne!(cursor, GUIDE, "{tool:?}"); + assert_eq!(guides, vec![200.0], "{tool:?}"); + } +} + +/// Window › Modifier Keys: a latched ⌘ made the press take the guide but left the brush cursor. +#[test] +fn sticky_command_shows_the_guide_cursor() { + for tool in [Tool::Brush, Tool::Eraser, Tool::RectMarquee] { + let mut h = harness(tool); + h.state_mut().ui.shell.sticky_command = true; + assert_eq!(hover(&mut h, 200.0, 150.0, Modifiers::NONE), GUIDE, "{tool:?}"); + drag(&mut h, 200.0, 240.0, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().doc.guides.vertical, vec![240.0], "{tool:?}"); + } +} + +/// The one decision for "a ⌘ press here takes the guide" outside the Move tool: not without ⌘, +/// not with a locked guide, not during a stroke, not for a tool that keeps ⌘. +#[test] +fn command_guide_at_decides_for_every_tool_that_moves_guides() { + use crate::canvas::command_guide_at; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + let cmd = Modifiers::COMMAND; + for tool in [Tool::Brush, Tool::Eraser, Tool::Gradient, Tool::PaintBucket, Tool::Eyedropper, Tool::Frame, Tool::Artboard, Tool::MagicWand] { + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], cmd), Some((true, 0)), "{tool:?}"); + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], Modifiers::NONE), None, "{tool:?} without ⌘"); + assert_eq!(command_guide_at(&app, tool, [120.0, 100.0], cmd), None, "{tool:?} off the guide"); + } + for tool in [Tool::Move, Tool::Pen, Tool::Hand, Tool::Zoom, Tool::Crop, Tool::Type, Tool::Rectangle, Tool::Slice, Tool::PathSelection] { + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], cmd), None, "{tool:?} keeps ⌘"); + } + app.ui.extras.lock_guides = true; + assert_eq!(command_guide_at(&app, Tool::Brush, [200.0, 100.0], cmd), None, "locked guides"); + app.ui.extras.lock_guides = false; + // A stroke under way keeps the pointer: ⌘ pressed mid-stroke does not take the guide. + app.ui.tool = Tool::Brush; + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); + assert_eq!(command_guide_at(&app, Tool::Brush, [200.0, 100.0], cmd), None, "during a stroke"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5948bd4edf..fb2c42eaae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2835,6 +2835,9 @@ mod dropdown_press_tests; #[cfg(test)] mod marquee_tests; +#[cfg(test)] +mod guide_cursor_tests; + #[cfg(test)] mod canvas_menu_tests; diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 6caa6f00a9..a5f4bb0fd1 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -880,7 +880,17 @@ mod tests { fn command_drag_over_a_guide_moves_the_guide_with_any_tool() { use crate::canvas::{ToolEvent, tool_event}; use crate::state::Tool; - for (tool, locked) in [(Tool::Brush, false), (Tool::RectMarquee, false), (Tool::Lasso, false), (Tool::MagicWand, false), (Tool::RectMarquee, true)] { + for (tool, locked) in [ + (Tool::Brush, false), + (Tool::Eraser, false), + (Tool::Gradient, false), + (Tool::Frame, false), + (Tool::Artboard, false), + (Tool::RectMarquee, false), + (Tool::Lasso, false), + (Tool::MagicWand, false), + (Tool::RectMarquee, true), + ] { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap(); app.sync_views(); From f5af2f3ef5ca2a9d3d732ad9a7cfb25e4143ceed Mon Sep 17 00:00:00 2001 From: Persi <69083413+persi-man@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:22 +0200 Subject: [PATCH 254/304] PSD export: check the Background's opacity from its tiles (#5998) --- crates/io/src/flat.rs | 2 +- crates/io/src/psd_export.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 76f53d9703..39fd2dc634 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -495,7 +495,7 @@ fn drop_alpha(data: &mut Vec, color: usize, alpha: usize) { } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). -fn opaque_surface(s: &Surface, r: Rect) -> bool { +pub(crate) fn opaque_surface(s: &Surface, r: Rect) -> bool { let fmt = s.format(); let (ch, bps) = (fmt.channels(), fmt.sample.bytes()); let a = ch - 1; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 64a5e7073a..345406740d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -107,9 +107,7 @@ fn psd_background(doc: &Document) -> Option { if canvas.is_empty() || s.content_bounds() != canvas { return None; } - let n = s.channels(); - let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); - opaque.then_some(l.id) + crate::flat::opaque_surface(s, canvas).then_some(l.id) } /// How deep embedded documents are exported inside each other before a smart object is written From 8bed609ef045af7b7ef55301d48aa967325d3314 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:20:29 -0400 Subject: [PATCH 255/304] Flatpak on Wayland: say why a paste finds nothing, and how to paste anyway (#5031) (#5279) In a Flatpak on Wayland, arboard can't open at all: the sandbox hides the data-control protocol from it, and --socket=fallback-x11 gives no X server while a compositor runs. Paste then reported "the clipboard holds no image" even when it held one. Now the clipboard service reports that it had no clipboard to read, and the paste shows a notice that explains it and gives the XWayland command for this install. Also adds my contributors/people.toml entry. Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 14 ++++++-- contributors/people.toml | 3 ++ crates/ui-egui/src/i18n/cs.tsv | 4 +++ crates/ui-egui/src/i18n/de.tsv | 4 +++ crates/ui-egui/src/i18n/el.tsv | 4 +++ crates/ui-egui/src/i18n/es.tsv | 4 +++ crates/ui-egui/src/i18n/fr.tsv | 4 +++ crates/ui-egui/src/i18n/hu.tsv | 4 +++ crates/ui-egui/src/i18n/id.tsv | 4 +++ crates/ui-egui/src/i18n/it.tsv | 4 +++ crates/ui-egui/src/i18n/ja.tsv | 4 +++ crates/ui-egui/src/i18n/ko.tsv | 4 +++ crates/ui-egui/src/i18n/nl.tsv | 4 +++ crates/ui-egui/src/i18n/pl.tsv | 4 +++ crates/ui-egui/src/i18n/pt-br.tsv | 4 +++ crates/ui-egui/src/i18n/ru.tsv | 4 +++ crates/ui-egui/src/i18n/sr.tsv | 4 +++ crates/ui-egui/src/i18n/tr.tsv | 4 +++ crates/ui-egui/src/i18n/uk.tsv | 4 +++ crates/ui-egui/src/i18n/vi.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 +++ crates/ui-egui/src/lib.rs | 10 ++++++ crates/ui-egui/src/notices.rs | 56 +++++++++++++++++++++++++++++ 24 files changed, 161 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 80f0b2d3a1..fcacf81b26 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -6,7 +6,7 @@ use photocraft_doc::{Document, Layer, Size}; use photocraft_format::RecoveryStore; use photocraft_geom::Rect; use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services}; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -493,6 +493,7 @@ fn shell_word(s: &str) -> String { pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); + let clip_unreadable: Rc> = Rc::default(); let automation_read = automation.clone().map(|workspace| { Box::new(move |path: &str| { let bytes = workspace.read(path).map_err(|error| error.to_string())?; @@ -566,11 +567,19 @@ pub fn native(automation: Option) -> }), clipboard_get_image: Some({ let clip = clip.clone(); + let unreadable = clip_unreadable.clone(); Box::new(move || { let mut slot = clip.try_borrow_mut().ok()?; let cb = match slot.as_mut() { Some(c) => c, - None => slot.insert(arboard::Clipboard::new().ok()?), + None => { + // arboard opens only when some backend works. In a Flatpak on Wayland none + // does (#5031): the sandbox hides the data-control protocol from it, and + // `--socket=fallback-x11` gives no X server while a compositor runs. + let opened = arboard::Clipboard::new(); + unreadable.set(opened.is_err()); + slot.insert(opened.ok()?) + } }; // Copied files first: Finder also puts the file's icon on the clipboard as an // image, which would otherwise paste instead of the file. @@ -586,6 +595,7 @@ pub fn native(automation: Option) -> None }) }), + clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), diff --git a/contributors/people.toml b/contributors/people.toml index 34c50d4d9f..bc3533777b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -159,6 +159,9 @@ sync_github_name = false real_name = "Ufuk Efe" sync_github_name = true +[people.nicolasfalesy] +sync_github_name = true + [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7894c00941..f6046fc207 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3798,6 +3798,10 @@ navigator Zoom Zvětšení # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka + Pasting from other apps is unavailable Vkládání z jiných aplikací není k dispozici + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft v této relaci nemá přístup k systémové schránce. Na Waylandu sandbox Flatpaku skrývá protokol, přes který se čtou obrázky z jiných aplikací. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Chcete-li vkládat z jiných aplikací, spusťte PhotoCraft pod XWaylandem: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Není co vložit: PhotoCraft zde nemůže číst systémovou schránku Delete this set Smazat tuto sadu New Set… Nová sada… PhotoCraft Defaults Výchozí PhotoCraft diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6468ef3bf7..0ad7366c4b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3194,6 +3194,10 @@ curves Smooth Glätten # View › Guides › right-click a guide Edit Selected Guides Ausgewählte Hilfslinien bearbeiten Clear Selected Guides Ausgewählte Hilfslinien löschen + Pasting from other apps is unavailable Einfügen aus anderen Apps ist nicht verfügbar + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft kann in dieser Sitzung nicht auf die Systemzwischenablage zugreifen. Unter Wayland verbirgt eine Flatpak-Sandbox das Protokoll, über das Bilder aus anderen Apps gelesen werden. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Um aus anderen Apps einzufügen, starte PhotoCraft unter XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nichts einzufügen: PhotoCraft kann die Systemzwischenablage hier nicht lesen Delete this set Diesen Satz löschen New Set… Neuer Satz… PhotoCraft Defaults PhotoCraft-Standard diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a0f1fda29..83621629fe 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3890,6 +3890,10 @@ curves Smooth Εξομάλυνση # View › Guides › right-click a guide Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών + Pasting from other apps is unavailable Η επικόλληση από άλλες εφαρμογές δεν είναι διαθέσιμη + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. Το PhotoCraft δεν έχει πρόσβαση στο πρόχειρο του συστήματος σε αυτή τη συνεδρία. Στο Wayland, το sandbox του Flatpak κρύβει το πρωτόκολλο μέσω του οποίου διαβάζονται εικόνες από άλλες εφαρμογές. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Για επικόλληση από άλλες εφαρμογές, ξεκινήστε το PhotoCraft σε XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Τίποτα για επικόλληση: το PhotoCraft δεν μπορεί να διαβάσει εδώ το πρόχειρο του συστήματος Delete this set Διαγραφή αυτού του συνόλου New Set… Νέο σύνολο… PhotoCraft Defaults Προεπιλογές PhotoCraft diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ba1183354c..e23b4e84c7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4152,6 +4152,10 @@ navigator Zoom Zoom # View › Guides › right-click a guide Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas + Pasting from other apps is unavailable Pegar desde otras aplicaciones no está disponible + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft no puede acceder al portapapeles del sistema en esta sesión. En Wayland, el entorno aislado de Flatpak oculta el protocolo con el que se leen las imágenes de otras aplicaciones. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Para pegar desde otras aplicaciones, inicia PhotoCraft en XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nada que pegar: PhotoCraft no puede leer aquí el portapapeles del sistema Delete this set Eliminar este conjunto New Set… Nuevo conjunto… PhotoCraft Defaults Valores por defecto de PhotoCraft diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 12bafb51d3..3bcca7037c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3196,6 +3196,10 @@ curves Smooth Lisser # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés + Pasting from other apps is unavailable Le collage depuis d’autres applications est indisponible + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ne peut pas accéder au presse-papiers du système dans cette session. Sous Wayland, le bac à sable Flatpak masque le protocole par lequel les images des autres applications sont lues. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Pour coller depuis d’autres applications, lancez PhotoCraft sous XWayland : `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Rien à coller : PhotoCraft ne peut pas lire ici le presse-papiers du système Delete this set Supprimer ce jeu New Set… Nouveau jeu… PhotoCraft Defaults Valeurs par défaut de PhotoCraft diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c670182e6b..4907e2da0c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4109,6 +4109,10 @@ cameraRaw Yellow Sárga # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése + Pasting from other apps is unavailable A beillesztés más alkalmazásokból nem érhető el + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. A PhotoCraft ebben a munkamenetben nem fér hozzá a rendszer vágólapjához. Waylanden a Flatpak homokozó elrejti azt a protokollt, amelyen keresztül más alkalmazások képei olvashatók. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Ha más alkalmazásokból szeretne beilleszteni, indítsa a PhotoCraftot XWayland alatt: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nincs mit beilleszteni: a PhotoCraft itt nem tudja olvasni a rendszer vágólapját Delete this set Készlet törlése New Set… Új készlet… PhotoCraft Defaults PhotoCraft alapértelmezések diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5142781b01..fc0d66afae 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3183,6 +3183,10 @@ curves Smooth Haluskan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih + Pasting from other apps is unavailable Menempel dari aplikasi lain tidak tersedia + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft tidak dapat mengakses papan klip sistem dalam sesi ini. Di Wayland, sandbox Flatpak menyembunyikan protokol yang digunakan untuk membaca gambar dari aplikasi lain. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Untuk menempel dari aplikasi lain, jalankan PhotoCraft di bawah XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Tidak ada yang ditempel: PhotoCraft tidak dapat membaca papan klip sistem di sini Delete this set Hapus set ini New Set… Set Baru… PhotoCraft Defaults Bawaan PhotoCraft diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9ccb67dd8c..609469635f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4311,6 +4311,10 @@ shape Appearance Aspetto # View › Guides › right-click a guide Edit Selected Guides Modifica guide selezionate Clear Selected Guides Cancella guide selezionate + Pasting from other apps is unavailable Incollare da altre app non è disponibile + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft non può accedere agli appunti di sistema in questa sessione. Su Wayland, la sandbox di Flatpak nasconde il protocollo con cui vengono lette le immagini delle altre app. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Per incollare da altre app, avvia PhotoCraft in XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Niente da incollare: qui PhotoCraft non può leggere gli appunti di sistema Delete this set Elimina questo set New Set… Nuovo set… PhotoCraft Defaults Predefiniti di PhotoCraft diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 372cd06906..39a38a1142 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3866,6 +3866,10 @@ curves Smooth スムーズ # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 + Pasting from other apps is unavailable 他のアプリからの貼り付けは利用できません + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. このセッションでは PhotoCraft がシステムのクリップボードにアクセスできません。Wayland では、Flatpak のサンドボックスが他のアプリの画像を読み取るためのプロトコルを隠します。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 他のアプリから貼り付けるには、XWayland 上で PhotoCraft を起動してください: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 貼り付けるものがありません: ここでは PhotoCraft がシステムのクリップボードを読み取れません Delete this set このセットを削除 New Set… 新規セット… PhotoCraft Defaults PhotoCraft 初期設定 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 60f50975ab..241c81bef5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3370,6 +3370,10 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Pasting from other apps is unavailable 다른 앱에서 붙여넣기를 사용할 수 없습니다 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. 이 세션에서는 PhotoCraft가 시스템 클립보드에 접근할 수 없습니다. Wayland에서는 Flatpak 샌드박스가 다른 앱의 이미지를 읽는 데 쓰이는 프로토콜을 숨깁니다. + To paste from other apps, start PhotoCraft under XWayland: `{command}` 다른 앱에서 붙여넣으려면 XWayland에서 PhotoCraft를 실행하세요: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 붙여넣을 내용 없음: 여기서는 PhotoCraft가 시스템 클립보드를 읽을 수 없습니다 Delete this set 이 세트 삭제 New Set… 새 세트… PhotoCraft Defaults PhotoCraft 기본값 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 18c37120e4..e0dd17ff57 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3950,6 +3950,10 @@ curves Smooth Vloeiend maken # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen + Pasting from other apps is unavailable Plakken vanuit andere apps is niet beschikbaar + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft heeft in deze sessie geen toegang tot het systeemklembord. Onder Wayland verbergt een Flatpak-sandbox het protocol waarmee afbeeldingen uit andere apps worden gelezen. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Om vanuit andere apps te plakken, start je PhotoCraft onder XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Niets om te plakken: PhotoCraft kan het systeemklembord hier niet lezen Delete this set Deze set verwijderen New Set… Nieuwe set… PhotoCraft Defaults PhotoCraft-standaard diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ca1f466d8f..10435771f5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3788,6 +3788,10 @@ curves Smooth Wygładź # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice + Pasting from other apps is unavailable Wklejanie z innych aplikacji jest niedostępne + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft nie ma w tej sesji dostępu do schowka systemowego. W Waylandzie piaskownica Flatpaka ukrywa protokół, przez który odczytywane są obrazy z innych aplikacji. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Aby wklejać z innych aplikacji, uruchom PhotoCraft pod XWaylandem: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nie ma czego wkleić: PhotoCraft nie może tutaj odczytać schowka systemowego Delete this set Usuń ten zestaw New Set… Nowy zestaw… PhotoCraft Defaults Domyślne PhotoCraft diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6f98a43622..f4dc857f7d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3980,6 +3980,10 @@ layerLabel No Color Sem cor # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas + Pasting from other apps is unavailable Colar de outros aplicativos não está disponível + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. O PhotoCraft não consegue acessar a área de transferência do sistema nesta sessão. No Wayland, a sandbox do Flatpak oculta o protocolo pelo qual as imagens de outros aplicativos são lidas. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Para colar de outros aplicativos, execute o PhotoCraft no XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nada para colar: o PhotoCraft não consegue ler a área de transferência do sistema aqui Delete this set Excluir este conjunto New Set… Novo conjunto… PhotoCraft Defaults Padrões do PhotoCraft diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1fd816bf90..b497b41855 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3161,6 +3161,10 @@ curves Smooth Сгладить # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие + Pasting from other apps is unavailable Вставка из других приложений недоступна + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. В этом сеансе PhotoCraft не может получить доступ к системному буферу обмена. В Wayland песочница Flatpak скрывает протокол, через который читаются изображения из других приложений. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Чтобы вставлять из других приложений, запустите PhotoCraft под XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Нечего вставить: здесь PhotoCraft не может прочитать системный буфер обмена Delete this set Удалить этот набор New Set… Новый набор… PhotoCraft Defaults Стандартные PhotoCraft diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a9ff4435bf..dc3608c514 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4263,6 +4263,10 @@ shape Appearance Izgled # View › Guides › right-click a guide Edit Selected Guides Uredi izabrane vodilje Clear Selected Guides Ukloni izabrane vodilje + Pasting from other apps is unavailable Lepljenje iz drugih aplikacija nije dostupno + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft u ovoj sesiji ne može da pristupi sistemskoj ostavi. Na Wayland-u, Flatpak sandbox skriva protokol preko kog se čitaju slike iz drugih aplikacija. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Da biste lepili iz drugih aplikacija, pokrenite PhotoCraft pod XWayland-om: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nema šta da se nalepi: PhotoCraft ovde ne može da čita sistemsku ostavu Delete this set Obriši ovaj skup New Set… Novi skup… PhotoCraft Defaults Podrazumevano za PhotoCraft diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index fb4b426c2d..5e5326f9f0 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3920,6 +3920,10 @@ cameraData Lens Lens # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle + Pasting from other apps is unavailable Diğer uygulamalardan yapıştırma kullanılamıyor + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft bu oturumda sistem panosuna erişemiyor. Wayland'de Flatpak korumalı alanı, diğer uygulamalardaki görüntülerin okunduğu protokolü gizler. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Diğer uygulamalardan yapıştırmak için PhotoCraft'ı XWayland altında başlatın: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Yapıştırılacak bir şey yok: PhotoCraft burada sistem panosunu okuyamıyor Delete this set Bu Kümeyi Sil New Set… Yeni Küme… PhotoCraft Defaults PhotoCraft Varsayılanları diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d8510183de..cc3df88348 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3889,6 +3889,10 @@ curves Smooth Згладити # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні + Pasting from other apps is unavailable Вставлення з інших застосунків недоступне + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. У цьому сеансі PhotoCraft не має доступу до системного буфера обміну. У Wayland пісочниця Flatpak приховує протокол, через який читаються зображення з інших застосунків. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Щоб вставляти з інших застосунків, запустіть PhotoCraft під XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Нічого вставити: тут PhotoCraft не може прочитати системний буфер обміну Delete this set Видалити цей набір New Set… Новий набір… PhotoCraft Defaults Стандартні PhotoCraft diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a1106bbbc2..d19e3d576a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3676,6 +3676,10 @@ curves Smooth Làm mượt # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn Clear Selected Guides Xóa đường căn đã chọn + Pasting from other apps is unavailable Không thể dán từ ứng dụng khác + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft không truy cập được bộ nhớ tạm của hệ thống trong phiên này. Trên Wayland, sandbox của Flatpak ẩn giao thức dùng để đọc hình ảnh từ ứng dụng khác. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Để dán từ ứng dụng khác, hãy khởi động PhotoCraft dưới XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Không có gì để dán: PhotoCraft không thể đọc bộ nhớ tạm của hệ thống ở đây Delete this set Xóa bộ này New Set… Bộ mới… PhotoCraft Defaults Mặc định của PhotoCraft diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c10a287def..675a544509 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2811,6 +2811,10 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Pasting from other apps is unavailable 无法从其他应用粘贴 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此会话中无法访问系统剪贴板。在 Wayland 上,Flatpak 沙盒会隐藏读取其他应用图像所用的协议。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 要从其他应用粘贴,请在 XWayland 下启动 PhotoCraft:`{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 没有可粘贴的内容:PhotoCraft 在此无法读取系统剪贴板 Delete this set 删除此组 New Set… 新建组… PhotoCraft Defaults PhotoCraft 默认值 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 82015e5754..982fa97319 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2796,6 +2796,10 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Pasting from other apps is unavailable 無法從其他應用程式貼上 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此工作階段中無法存取系統剪貼簿。在 Wayland 上,Flatpak 沙盒會隱藏讀取其他應用程式影像所用的協定。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 若要從其他應用程式貼上,請在 XWayland 下啟動 PhotoCraft:`{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 沒有可貼上的內容:PhotoCraft 在此無法讀取系統剪貼簿 Delete this set 刪除此組合 New Set… 新增組合… PhotoCraft Defaults PhotoCraft 預設值 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb2c42eaae..89f293a2cb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -403,6 +403,8 @@ pub type EncodePngFn = Box Result, String>>; pub type ClipboardSetFn = Box Result<(), String>>; /// Read an RGBA8 image from the OS clipboard. pub type ClipboardGetFn = Box Option<(u32, u32, Vec)>>; +/// Whether the last clipboard read found no way to reach the OS clipboard at all. +pub type ClipboardUnreadableFn = Box bool>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). @@ -507,6 +509,9 @@ pub struct Services { /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, + /// True when the last `clipboard_get_image` read had no clipboard to read, as in a Flatpak on + /// Wayland (#5031), rather than a clipboard that holds no image. `None` where that can't happen. + pub clipboard_unreadable: Option, /// Preferences persistence: the platform config directory on native, browser storage on /// the web (see `prefs_ui`). pub load_prefs: Option, @@ -1136,6 +1141,11 @@ impl PhotocraftApp { self.import_os_clipboard(); } if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { + if self.services.clipboard_unreadable.as_ref().is_some_and(|unreadable| unreadable()) { + // The clipboard may well hold an image that can't be reached: say why. + notices::clipboard_unreadable(self); + return Ok(serde_json::json!({"pasted": false})); + } // Enabled on the strength of the OS clipboard, which held no image: a quiet no-op. let message = "Nothing to paste: the clipboard holds no image"; self.ui.status = tl!(message).into(); diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index cd974fbbe7..76cdc7b9bb 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::PhotocraftApp; const WAYLAND_FILE_DROP_DISMISSED: &str = "ui.waylandFileDropGuidanceDismissed"; +const CLIPBOARD_UNREADABLE: &str = "Pasting from other apps is unavailable"; /// At most this many notices are kept; older ones drop off. pub const MAX_NOTICES: usize = 3; /// Lines shown per notice before "…and N more". @@ -91,6 +92,30 @@ pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) { } } +/// A paste found no clipboard to read (#5031). In a Flatpak on Wayland the sandbox hides the +/// data-control protocol that other apps' images are read through, and there is no X server to +/// fall back to, so the clipboard may hold an image all the same. Say so, and give the command +/// that starts this install under XWayland, where the X11 clipboard works, when there is one. +pub fn clipboard_unreadable(app: &mut PhotocraftApp) { + app.ui.status = tl!("Nothing to paste: PhotoCraft can't read the system clipboard here").into(); + app.ui.status_error = false; + if app.ui.notices.iter().any(|notice| notice.title == CLIPBOARD_UNREADABLE) { + return; + } + // English templates, translated when drawn, as in `wayland_file_drop_guidance`. + let mut lines = + vec!["PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through.".to_owned()]; + let mut args = Vec::new(); + if let Some(command) = &app.services.xwayland_command { + lines.push("To paste from other apps, start PhotoCraft under XWayland: `{command}`".to_owned()); + args.push(("command".to_owned(), command.clone())); + } + let id = post(app, CLIPBOARD_UNREADABLE, lines, false, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|notice| notice.id == id) { + notice.args = args; + } +} + /// A full refresh of document `doc` fell back to the CPU compositor (`reason`, the GPU's) and took /// `ms`. The canvas doesn't respond while it runs, so once per document say why it was slow and /// which edits will do it again (#1015). @@ -278,6 +303,37 @@ mod tests { assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, ¬ice.title), "Native file drag-and-drop is unavailable"); } + /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to + /// paste anyway, once while shown; a clipboard that simply holds no image stays a quiet no-op. + #[test] + fn unreadable_clipboard_paste_explains_the_sandbox() { + let command = "flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft"; + let services = |unreadable: bool| Services { + clipboard_get_image: Some(Box::new(|| None)), + clipboard_unreadable: Some(Box::new(move || unreadable)), + xwayland_command: Some(command.to_string()), + ..Default::default() + }; + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(Session::new(), services(true)); + for _ in 0..2 { + let r = crate::menus::invoke(&mut app, &ctx, "edit.paste", serde_json::json!({})).unwrap(); + assert_eq!(r["pasted"], serde_json::json!(false)); + } + assert_eq!(app.ui.status, "Nothing to paste: PhotoCraft can't read the system clipboard here"); + let shown: Vec<_> = app.ui.notices.iter().filter(|notice| notice.title == CLIPBOARD_UNREADABLE).collect(); + assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice"); + let guidance = english(shown[0]); + assert!(guidance.contains(&format!("under XWayland: `{command}`")), "{guidance}"); + let es = crate::i18n::Lang::from_code("es").unwrap(); + assert!(shown[0].lines.iter().chain([&shown[0].title]).all(|line| crate::i18n::tr(es, line) != *line), "every line is a catalog template"); + + let mut empty = PhotocraftApp::new(Session::new(), services(false)); + crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap(); + assert_eq!(empty.ui.status, "Nothing to paste: the clipboard holds no image"); + assert!(empty.ui.notices.iter().all(|notice| notice.title != CLIPBOARD_UNREADABLE)); + } + /// A notice's lines in English with their arguments filled in. fn english(notice: &Notice) -> String { let args: Vec<(&str, &str)> = notice.args.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); From 1a03735cc6758d6281fc12d4ce5c231aa479a7c5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:35 +0200 Subject: [PATCH 256/304] fix(engine): apply document-wide image and colour conversions to stored video frames (#4956) (#5078) --- crates/engine/src/canvas_geom.rs | 7 +++++- crates/engine/src/canvas_geom/tests.rs | 24 +++++++++++++++++++ crates/engine/src/image_cmds.rs | 32 +++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 99f7e4cc80..8de32f4327 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -253,7 +253,8 @@ impl Turn { } /// Every pixel surface a canvas turn moves: content, rendered caches (type, shape, smart object, -/// fill), masks (layer, smart filter), alpha channels, Quick Mask and the selection. +/// fill), stored video frames (current and original), masks (layer, smart filter), alpha +/// channels, Quick Mask and the selection. fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) { let re = |s: &mut Surface| *s = remap_surface(s, map); @@ -264,6 +265,10 @@ fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { if let Some(fc) = &mut l.fill_cache { re(&mut fc.surface); } + // Frames away from the playhead come back on scrubbing; they turn with the canvas (#4956). + if let Some(v) = &mut l.video { + v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()).for_each(re); + } match &mut l.content { LayerContent::Raster(s) => re(s), LayerContent::Text(t) => t.cache.iter_mut().for_each(re), diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 0dbf03d2ef..2ba0dfbd66 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -325,6 +325,30 @@ fn arbitrary_rotation_moves_marks_and_unlinked_vector_masks() { assert!(mean < 3.0 / 255.0 && big < 0.02, "mean {mean} big {big}"); } +/// A canvas turn moves every stored video frame, not just the displayed one (#4956). +#[test] +fn turns_move_stored_video_frames() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |x, y| if (x, y) == (1, 2) { [1.0, 0.0, 0.0, 1.0] } else { [0.0; 4] }); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute(command(Turn::FlipHorizontal), json!({})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(W as i32 - 2, 2), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(frame.pixel(1, 2)[3], 0.0); + } +} + #[test] fn turns_on_empty_and_tiny_documents_never_panic() { for (w, h) in [(1, 1), (1, 7), (5, 1)] { diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 805fbde55b..54422f32c1 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -16,7 +16,8 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { } /// Applies `f` to every pixel surface of a layer tree: content (raster or -/// cached text/shape/smart pixels), fill caches, and (if `masks`) masks. +/// cached text/shape/smart pixels), fill caches, stored video frames (current and original), +/// and (if `masks`) masks. pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn FnMut(&mut Surface, bool)) { for l in layers { if masks && let Some(m) = &mut l.mask { @@ -25,6 +26,13 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn if let Some(fc) = &mut l.fill_cache { f(&mut fc.surface, false); } + // The displayed content is only a copy of the frame at the playhead; the other frames + // would keep the old geometry and depth and come back stale when scrubbing (#4956). + if let Some(v) = &mut l.video { + for frame in v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()) { + f(frame, false); + } + } match &mut l.content { LayerContent::Raster(s) => f(s, false), LayerContent::Text(t) => { @@ -938,6 +946,28 @@ mod tests { &s.active().unwrap().doc } + /// Document-wide conversions must move every stored video frame, not just the displayed one (#4956). + #[test] + fn crop_moves_stored_video_frames() { + let mut s = session(); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": -10, "y": -5, "width": 60, "height": 30})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + let red = vec![1.0, 0.0, 0.0, 1.0]; + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(20, 10), red); + assert_eq!(frame.pixel(5, 5)[3], 0.0); + } + } + /// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size /// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the /// resolution changes. From e306bcd68528ab6c5a0d8e3faad13319a7f7860a Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 20:20:42 +0200 Subject: [PATCH 257/304] Crop: a turned crop rotates only what the frame keeps (#5006) (#5007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turned crop rotated the whole canvas, then cropped to the frame. With Delete Cropped Pixels on, every layer, mask and channel is now trimmed to the frame's source (plus the interpolation kernel's reach) before the turn, so the rotation costs the frame's size: a 512x442 frame turned 3° on a 3859x2594 two-layer document goes from 541 ms to 42 ms. Rotate Canvas also fills the Background with shared tiles instead of a canvas-sized float buffer. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/examples/bench_crop.rs | 40 ++++++++++++ crates/engine/src/image_cmds.rs | 95 +++++++++++++++++++++++++++- crates/engine/src/mode_cmds.rs | 3 +- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 crates/engine/examples/bench_crop.rs diff --git a/crates/engine/examples/bench_crop.rs b/crates/engine/examples/bench_crop.rs new file mode 100644 index 0000000000..1d301ff19c --- /dev/null +++ b/crates/engine/examples/bench_crop.rs @@ -0,0 +1,40 @@ +//! End-to-end timing of `image.crop`, upright and turned, on a two-layer document. +//! `cargo run --release -p photocraft-engine --example bench_crop -- [image] [reps]` +//! Without an image a 3859×2594 noise photo stands in (the size of the Great Wave scan). +use std::time::{Duration, Instant}; + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn main() { + let args: Vec = std::env::args().collect(); + let reps: usize = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(3); + let mut s = Session::new(); + match args.get(1) { + Some(path) => { + let bytes = std::fs::read(path).expect("read the image"); + let doc = photocraft_io::import(path, &bytes).expect("decode the image").document; + s.add_document(doc, None); + } + None => { + s.execute("file.new", json!({"width": 3859, "height": 2594, "background": "white"})).expect("new"); + s.execute("filter.noise.addNoise", json!({"amount": 50})).expect("noise"); + } + } + s.execute("layer.duplicate", json!({})).expect("a second layer"); + let cases: [(&str, Value); 3] = [ + ("upright 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442})), + ("turned 3° 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442, "angle": 3})), + ("turned 3° 3000x2000", json!({"x": 400, "y": 300, "width": 3000, "height": 2000, "angle": 3})), + ]; + for (label, p) in cases { + let mut best = Duration::MAX; + for _ in 0..reps { + let t = Instant::now(); + s.execute("image.crop", p.clone()).expect("crop"); + best = best.min(t.elapsed()); + s.execute("edit.undo", json!({})).expect("undo"); + } + println!("{label:24} best of {reps}: {best:.2?}"); + } +} diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 54422f32c1..6d5342b931 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -668,6 +668,21 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar rotated: true, }; crate::analysis_cmds::compound(s, "Crop", |s| { + // Pixels the crop deletes needn't be turned first: keeping only what lands in the frame + // makes the rotation cost the frame's size, not the canvas's (a small turned crop of a + // 10 MP photo took over half a second). + if delete { + let keep = turned_crop_source(size, new, angle, target); + s.edit("Crop", |doc, _| { + for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep)); + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + ch.surface = crop_surface(&ch.surface, keep); + } + // The crop drops the selection anyway. + doc.selection = None; + Ok(()) + })?; + } crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; // Color Sampler points follow the crop's origin shift too (the rotation moved them above). s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0))); @@ -685,6 +700,26 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle }))) } +/// The pixels a turned crop reads, in document coordinates before the turn: the source of the +/// `target` frame (on the `new` canvas the rotation by `-angle` makes of `old`), grown by the widest +/// interpolation kernel's reach so every sample the frame's pixels take is kept. +fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect { + const KERNEL_REACH: f64 = 4.0; + // The inverse of `rotate_arbitrary`'s map: x = c·u + s·v + cx, y = −s·u + c·v + cy, where + // (u, v) is the point relative to the new canvas's centre. + let (sn, cs) = (-angle).to_radians().sin_cos(); + let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); + let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for (px, py) in [(target.x0, target.y0), (target.x1, target.y0), (target.x0, target.y1), (target.x1, target.y1)] { + let (u, v) = (f64::from(px) - nx, f64::from(py) - ny); + let (x, y) = (cs * u + sn * v + cx, -sn * u + cs * v + cy); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + // `as` saturates: a frame near the coordinate limits keeps more, never less. + 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) +} + /// Image → Trim. fn trim(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -1295,7 +1330,10 @@ mod tests { for y in 0..20 { for x in 0..10 { let (a, b) = (layer_rgba(&s, l, x, y), layer_rgba(&reference, l, x, y)); - assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); + // A fully transparent pixel's colour is invisible, and the turned crop + // doesn't turn the pixels it deletes, which the warp spreads colour from. + let n = if b[3] > 1e-3 { 4 } else { 1 }; + assert!(a.iter().rev().zip(b.iter().rev()).take(n).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); } } } @@ -1336,6 +1374,61 @@ mod tests { } } + /// A turned crop that deletes the cropped pixels turns only what lands in the frame; inside the + /// new canvas every layer, mask and channel is exactly what turning everything gives (the + /// frame spans several tiles, at several depths and angles). + #[test] + fn turned_crop_deleting_pixels_matches_turning_the_whole_canvas() { + use photocraft_color::PixelFormat; + let pixels = || (0..500).flat_map(|y| (0..700).map(move |x| (x, y))); + for depth in [8, 16, 32] { + for angle in [3.0, -7.5, 33.0, 181.0] { + let mut sessions = [true, false].map(|delete| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 700, "height": 500, "depth": depth, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let wave = |x: i32, y: i32, k: i32| ((x * 7 + y * 13 + k * 31) % 97) as f32 / 96.0; + let all = Rect::new(0, 0, 700, 500); + let rgba: Vec = pixels().flat_map(|(x, y)| [wave(x, y, 0), wave(x, y, 1), wave(x, y, 2), wave(y, x, 3)]).collect(); + let gray: Vec = pixels().map(|(x, y)| wave(y, x, 4)).collect(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().write_region(all, &rgba); + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.write_region(all, &gray); + l.mask = Some(mask); + let bg = doc.background_mut().unwrap().surface_mut().unwrap(); + bg.write_region(all, &pixels().flat_map(|(x, y)| [wave(x, y, 5), wave(x, y, 6), wave(x, y, 7), 1.0]).collect::>()); + let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false); + let mut ch = Surface::new(fmt); + ch.write_region(all, &gray); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch)); + Ok(()) + }) + .unwrap(); + let p = json!({"x": 150, "y": 120, "width": 330, "height": 290, "angle": angle, "deleteCroppedPixels": delete}); + s.execute("image.crop", p).unwrap(); + s + }); + let [deleted, kept] = &mut sessions; + let (a, b) = (doc(deleted), doc(kept)); + assert_eq!(a.size, b.size, "{depth} {angle}"); + let canvas = a.bounds(); + for (la, lb) in a.layers.iter().zip(&b.layers) { + let (sa, sb) = (la.surface().unwrap(), lb.surface().unwrap()); + assert!(sa.to_interleaved(canvas) == sb.to_interleaved(canvas), "{depth} {angle}: layer {}", la.name); + if let (Some(ma), Some(mb)) = (&la.mask, &lb.mask) { + assert!(ma.surface.to_interleaved(canvas) == mb.surface.to_interleaved(canvas), "{depth} {angle}: mask of {}", la.name); + } + } + assert!(a.channels[0].surface.to_interleaved(canvas) == b.channels[0].surface.to_interleaved(canvas), "{depth} {angle}: alpha channel"); + // Deleting keeps nothing past the canvas. + let layer = a.layers[1].surface().unwrap(); + assert!(canvas.contains_rect(&layer.content_bounds()), "{depth} {angle}: {:?}", layer.content_bounds()); + } + } + } + /// Angle 0 (or a whole turn) is the plain crop: no resampling, the same document. #[test] fn crop_at_angle_zero_is_the_plain_crop() { diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 41b4ddde1a..c28b17689a 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -112,7 +112,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp)?; let mut base = Surface::new(fmt); let fill = photocraft_raster::from_rgba(&fmt, bg); - base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize)); + // One shared tile per full tile, not a canvas-sized float buffer. + base.fill_rect(canvas, &fill); crate::transform_cmds::composite_over(&mut base, &rotated); base.prune(); *surf = base; From 4f15642ee8de34f9353c2d5b8c1545f8812044a1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:48 +0200 Subject: [PATCH 258/304] Toolbar: Edit Toolbar, Quick Mask and Screen Mode in every theme (part of #4653) (#4680) --- crates/ui-egui/src/panels.rs | 160 ++++++++++++++++++++--------------- docs/ui-design.md | 4 +- 2 files changed, 94 insertions(+), 70 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9e0a701cee..04e775439e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -128,7 +128,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let footer = (if t.pro { 8.0 } else { 14.0 }) + chips_height(t.pro, t.round_chips, t.round_chips && double) - + if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 } + + 8.0 + + if double { bx } else { bx * 2.0 + 3.0 } + 6.0; // As tall as the tools, so the colour chips sit right under them (Photoshop); a column // taller than the window scrolls and keeps the chips at the bottom. @@ -287,81 +288,79 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } } - if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { + if icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); } reveal_focused_control(ui); }); ui.add_space(if t.pro { 8.0 } else { 14.0 }); color_chips(app, ui, double); - if t.pro { - ui.add_space(8.0); + ui.add_space(8.0); - let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; + let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; - ui.with_layout(layout, |ui| { - let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button_with_icon_size_tip( - ui, - "square-dashed", - bx, - (bx * 0.52).round(), - quick_mask, - if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, - tool_tips, - ) - .clicked() - { - 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); - paint_toolbar_flyout_indicator(ui, &t, &sm); + ui.with_layout(layout, |ui| { + let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); + if icons::button_with_icon_size_tip( + ui, + "square-dashed", + bx, + (bx * 0.52).round(), + quick_mask, + if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + tool_tips, + ) + .clicked() + { + 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); + paint_toolbar_flyout_indicator(ui, &t, &sm); - // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, - // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. - let held_id = sm.id.with("held"); - let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); - let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); - if let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); - if long_press { - ui.data_mut(|d| d.insert_temp(held_id, true)); - } else if was_held && !sm.is_pointer_button_down_on() { - ui.data_mut(|d| d.remove::(held_id)); - } - let cycle = sm.clicked() && !was_held; - if cycle { - let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); - } - let open = if sm.secondary_clicked() || long_press { - Some(egui::SetOpenCommand::Bool(true)) - } else if cycle { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; - // The release ending the hold counts as a click; it mustn't close the list it opened. - let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; - egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { - ui.set_min_width(220.0); - for (id, label) in [ - ("view.screenMode.standard", tl!("Standard Screen Mode")), - ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), - ("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 _ = crate::menus::invoke(app, &ctx, id, json!({})); - ui.close(); - } + // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, + // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. + let held_id = sm.id.with("held"); + let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); + let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, true)); + } else if was_held && !sm.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(held_id)); + } + let cycle = sm.clicked() && !was_held; + if cycle { + let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); + } + let open = if sm.secondary_clicked() || long_press { + Some(egui::SetOpenCommand::Bool(true)) + } else if cycle { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + // The release ending the hold counts as a click; it mustn't close the list it opened. + let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { + ui.set_min_width(220.0); + for (id, label) in [ + ("view.screenMode.standard", tl!("Standard Screen Mode")), + ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), + ("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 _ = crate::menus::invoke(app, &ctx, id, json!({})); + ui.close(); } - }); + } }); - } + }); }); } @@ -5810,6 +5809,27 @@ mod toolbar_tests { } } + /// #4653: every theme draws Edit Toolbar, Quick Mask and Screen Mode, not only Pro. + #[test] + fn every_theme_has_the_toolbar_controls() { + for kind in crate::theme::ThemeKind::ALL { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + toolbar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(2); + for label in ["Edit Toolbar…", "Edit in Quick Mask Mode (Q)", "Change Screen Mode (F)"] { + assert!(h.query_by_label(label).is_some(), "{kind:?}: no {label:?} button"); + } + } + } + fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) { h.hover_at(p); h.run_steps(1); @@ -6180,6 +6200,10 @@ mod compact_visual_regressions { phase: egui::TouchPhase::Move, }); harness.run_steps(30); + // Off the toolbar, so a tooltip of the tool scrolled under the pointer isn't a + // second "Zoom Tool" node. + harness.event(egui::Event::PointerMoved(pos2(300.0, 100.0))); + harness.run_steps(2); let node = harness.get_by_label("Zoom Tool (Z)"); let rect = node.rect(); assert!(rect.top() >= 0.0 && rect.bottom() <= 360.0, "{kind:?} at {scale}x: last tool remains outside the toolbar: {rect:?}"); @@ -6217,13 +6241,13 @@ mod compact_visual_regressions { } } - /// A window taller than the tools keeps the colour chips right under the last tool, as - /// Photoshop does, instead of at the bottom of the toolbar. + /// A window taller than the tools keeps the colour chips right under the tool column (its + /// last row is Edit Toolbar), as Photoshop does, instead of at the bottom of the toolbar. #[test] fn tall_toolbar_keeps_the_colour_chips_under_the_tools() { for kind in theme::ThemeKind::ALL { let harness = toolbar_harness_sized(kind, 1.0, 1600.0); - let last = harness.get_by_label("Zoom Tool (Z)").rect(); + let last = harness.get_by_label("Edit Toolbar…").rect(); let swap = harness.get_by_label("Swap colours (X)").rect(); assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}"); } diff --git a/docs/ui-design.md b/docs/ui-design.md index d4ac18d297..d0ee20f7bb 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -103,8 +103,8 @@ The app's top bar (`panels::title_bar`) starts with the brand mark (the app icon ## Compact tool controls The toolbar scrolls its tool rows when two columns still exceed the available height. Foreground -and background colours stay visible below those rows, together with Pro's Quick Mask and Screen -Mode controls. Long options rows scroll horizontally. Both scrollers bring newly focused +and background colours stay visible below those rows, together with the Quick Mask and Screen +Mode controls in every theme. Long options rows scroll horizontally. Both scrollers bring newly focused controls into view and retain the existing tool actions and popups. The offscreen regression fixture uses production panels, fonts and SVG loaders across every From d5c7edacdf5c81b8d44f5e9b0c87eb7102244b70 Mon Sep 17 00:00:00 2001 From: llduyll10 <37955357+llduyll10@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:21:01 +0700 Subject: [PATCH 259/304] Layered TIFF: write PSD-style layer data unless a length overflows (Photoshop opened only the composite) (#5514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layered TIFF writer let the PSD exporter pick PSB from its size estimate, which counts every layer plane uncompressed. A document with many canvas-sized layers passes 2 GB there while its real layer data is a small fraction of it (a 3000×3441 mockup with 180 layers: estimate ~2.57 GB, real data ~111 MB), so tag 37724 got the PSB-style header ("Adobe Photoshop Document Data V0002", 8-byte lengths). Photoshop does not read that in a TIFF: it opens only the composite, as one locked Background. PsdExportOptions gains `size_limit_psb` (default true, .psd saves unchanged). The layered TIFF turns it off and writes PSD-style data ("Document Data Block"), falling back to PSB only past 30000 px, when forced, or when a length really overflows 32 bits. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/io/src/psd_export.rs | 17 +++++++- crates/io/src/tiff_layers.rs | 76 ++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 345406740d..57e30380c1 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -29,6 +29,12 @@ const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; pub struct PsdExportOptions { /// Write PSB even when the document fits PSD limits. pub force_psb: bool, + /// Switch to PSB when the estimated encoded size exceeds PSD's 2 GB limit. The estimate + /// counts every layer plane uncompressed, so a document with many canvas-sized layers can + /// pass it while its real file is far smaller. A writer that can measure the real size + /// instead turns this off: the layered TIFF writes PSD-style layer data and falls back to + /// PSB only when a length really overflows 32 bits. + pub size_limit_psb: bool, /// Matte the merged image against white where it is translucent, as Photoshop does /// (`false` keeps straight colour under the alpha: for containers that store the composite /// with its own alpha, such as a layered TIFF). @@ -48,7 +54,14 @@ pub struct PsdExportOptions { impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None } + PsdExportOptions { + force_psb: false, + size_limit_psb: true, + merged_matte: true, + merged_compression: Compression::Rle, + maximize_compatibility: true, + document_path: None, + } } } @@ -1522,7 +1535,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; - let size_exceeds_limit = !opts.force_psb && !big && psd_size_exceeds_limit(doc); + let size_exceeds_limit = opts.size_limit_psb && !opts.force_psb && !big && psd_size_exceeds_limit(doc); let version = psd_version(doc, opts.force_psb, size_exceeds_limit); let mut ex = Ex { fmt, diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 628517b6f7..7da61fa466 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -202,19 +202,38 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). + // Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as + // Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a + // TIFF and opens only the composite, as one locked Background. The exporter's size estimate + // counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB + // while its real layer data is a small fraction of that; it is not used here. PSB-style data + // is written only past 30000 px, when asked for, or when a length really overflows 32 bits. + let force_psb = opts.force_psb || big; let psd_opts = PsdExportOptions { - force_psb: opts.force_psb || big, + force_psb, + size_limit_psb: false, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true, document_path: opts.document_path.clone(), }; - let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); + let (mut file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = ImageSourceData::from_psd(&file).to_bytes(order)?; + let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) { + Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { + let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; + let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); + ctl.check().map_err(|_| IoError::Cancelled)?; + warnings.extend(w); + warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); + file = psb; + ImageSourceData::from_psd(&file).to_bytes(order)? + } + r => r?, + }; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_LAYERS_DONE); warnings.extend(w.into_iter().map(|w| format!("layer data: {w}"))); @@ -284,3 +303,54 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec