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 01/69] 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 ebd3c12270083e9bee36e91cb8ec1fdc18e74671 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 18:20:12 +0200 Subject: [PATCH 02/69] 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 78d06781a03d0bfd12735c45b80a897f64b05a28 Mon Sep 17 00:00:00 2001 From: Juan Antonio Redondo Date: Wed, 14 Oct 2026 12:12:54 +0200 Subject: [PATCH 03/69] 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 04/69] 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 c71f951b903cafcc2f3bfbf1801470033aeada2e Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Thu, 15 Oct 2026 00:06:16 +0300 Subject: [PATCH 05/69] =?UTF-8?q?Fix=20View=20=E2=80=BA=20Snap=20off=20sti?= =?UTF-8?q?ll=20snapping=20via=20smart=20guides=20(storytold/photocraft#57?= =?UTF-8?q?82).?= 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 2750809257fb315436442d5ee8089b04fb4228c0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 14 Oct 2026 18:56:28 -0400 Subject: [PATCH 06/69] 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 07/69] 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 08/69] 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 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 09/69] 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 10/69] 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 11/69] 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 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 12/69] 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 13/69] 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 14/69] 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 15/69] 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 16/69] 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 cf3d26881d77d9e92e1def2e7d713da41095bed0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 20:41:08 -0400 Subject: [PATCH 17/69] 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 5f8d92f9f02ef896d9c49381eb368a7a435be5f0 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 22:27:09 -0400 Subject: [PATCH 18/69] 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 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 19/69] 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 2e8d20ca9cd0b9cd2cd4f13151728d20c2ac5879 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 00:24:53 +0300 Subject: [PATCH 20/69] 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 c9334c46619ae14762b95eae0ff70215b88280b3 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 19:39:39 -0400 Subject: [PATCH 21/69] 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 425e782ac2fd4d3f754d9015b6925f44aac4ce30 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 00:25:15 -0400 Subject: [PATCH 22/69] 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 23/69] 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 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 24/69] 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 25/69] 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 26/69] 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 204c76ce36ab5e7ddf6d406074f099d3759fd8ff Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 20:03:18 -0400 Subject: [PATCH 27/69] 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 28/69] 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 b3bee7496e847a36532ade7dfe0231873dd6adb7 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 15:04:16 -0400 Subject: [PATCH 29/69] 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 eeeeb13f0ea156b90cf68d26e1fb5a4a9f66f663 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 18:25:22 -0400 Subject: [PATCH 30/69] 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 31/69] 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 ea0eec98a3cd238e97505a0bb7c978330f4f9791 Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Fri, 16 Oct 2026 08:42:52 -0700 Subject: [PATCH 32/69] 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 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 33/69] 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 34/69] 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 c8907e2cd2809a328f2c331191f11e90e9857fab Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 05:23:03 -0600 Subject: [PATCH 35/69] 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 b862323a8e1a7aebebd736bd9420e91d784e4ecd Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 05:39:46 -0600 Subject: [PATCH 36/69] 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 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 37/69] 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 38/69] 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 b892c0412e2e67c402af073dc3a3ff1f2b9d1cd3 Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 13:16:53 -0400 Subject: [PATCH 39/69] 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 f11e51b8993daebc02f81574dd0f9c3cef105713 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:34:06 -0600 Subject: [PATCH 40/69] Re-run CI Co-Authored-By: Claude Opus 5.5 From 0b6480cdc5d67582c4d78edc320a6bed9e037438 Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 14:13:55 -0400 Subject: [PATCH 41/69] 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 2f0886038318f6f87b27a51f52b2f56927c9d762 Mon Sep 17 00:00:00 2001 From: Wolf Date: Mon, 19 Oct 2026 21:46:41 +0200 Subject: [PATCH 42/69] Print: Portrait and Landscape in the Print dialog on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows the Print dialog takes the paper and its orientation from the printer driver, and had no way to change the orientation short of the driver's own Print Settings… dialog. Photoshop has Layout buttons for it. A print laid out landscape on portrait driver settings (from a script, the CLI or another app) stopped with "the driver paper or orientation differs from the print layout". - The dialog has an Orientation row (Portrait, Landscape) under Print Settings…. It turns the driver settings through file.print.page and takes the paper from the driver again, so the paper and preview turn. - file.print.page takes "orientation": "portrait|landscape" and returns the turned settings as printerSettings, with the page they give. - print_windows turns the settings (the given ones or the printer's defaults) to the layout's orientation when the driver has the layout's paper the other way round; another paper is still refused. Turning sets dmOrientation and its dmFields bit in the DEVMODE and keeps every other byte, the driver's private settings included; the driver validates the result as it does settings from Print Settings…. No new UI strings: "Orientation", "Portrait" and "Landscape" are in every catalog. Tests: the turned settings (orientation set, all else kept, with and without private driver bytes), when a paper counts as turned, an unknown orientation refused before any printer is asked, the dialog's reading of the driver paper and its error path, and, run by hand on Windows, a real "Microsoft Print to PDF" driver turning for a landscape layout. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/print_cmds.rs | 88 ++++++++++++++++++++++++--- crates/engine/src/print_cmds/tests.rs | 73 ++++++++++++++++++++++ crates/ui-egui/src/file_ui.rs | 65 ++++++++++++++++++++ docs/printing.md | 11 ++++ 4 files changed, 230 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 0a1701a166..483267d333 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -708,6 +708,61 @@ fn printer_settings(p: &Value) -> Result> { } } +/// `dmFields` and `dmOrientation` in DEVMODEW (wingdi.h: after the 32-wchar device name and four +/// header words), and the `dmFields` bit that says `dmOrientation` is set. +const DM_FIELDS_AT: usize = 72; +const DM_ORIENTATION_AT: usize = 76; +const DM_ORIENTATION: u32 = 0x1; +/// `DMORIENT_PORTRAIT` and `DMORIENT_LANDSCAPE`. +const DMORIENT_PORTRAIT: i16 = 1; +const DMORIENT_LANDSCAPE: i16 = 2; + +/// Driver settings turned to portrait or landscape, as Photoshop's Layout buttons turn them: +/// `dmOrientation` set and flagged in `dmFields`, every other byte (the driver's private settings +/// included) kept. The driver validates the result when it lays out the page, as it does for +/// settings from Print Settings…. +pub fn turned_settings(settings: &craft_print::rgb::DevMode, landscape: bool) -> Result { + let mut b = settings.as_bytes().to_vec(); + let short = || other("printer settings are too short to hold an orientation"); + let fields: [u8; 4] = b.get(DM_FIELDS_AT..DM_FIELDS_AT + 4).and_then(|s| s.try_into().ok()).ok_or_else(short)?; + let fields = u32::from_le_bytes(fields) | DM_ORIENTATION; + b.get_mut(DM_FIELDS_AT..DM_FIELDS_AT + 4).ok_or_else(short)?.copy_from_slice(&fields.to_le_bytes()); + let orientation = if landscape { DMORIENT_LANDSCAPE } else { DMORIENT_PORTRAIT }; + b.get_mut(DM_ORIENTATION_AT..DM_ORIENTATION_AT + 2).ok_or_else(short)?.copy_from_slice(&orientation.to_le_bytes()); + craft_print::rgb::DevMode::from_bytes(b).map_err(other) +} + +/// Whether a driver page of `driver` points is the layout's `paper` turned the other way round +/// (within the 2 pt the driver and layout may differ by): then turning the driver settings, not +/// choosing another paper, makes them agree. A square paper is never turned. +fn turned_paper(driver: (f64, f64), paper: (f64, f64)) -> bool { + let near = |a: f64, b: f64| (a - b).abs() <= 2.0; + let same = near(driver.0, paper.0) && near(driver.1, paper.1); + !same && near(driver.0, paper.1) && near(driver.1, paper.0) +} + +/// The driver settings and page for printing a layout of `paper` points on `printer`: the given +/// settings (or the printer's own) as they are, or turned to the layout's orientation when the +/// driver has that paper the other way round. A landscape layout so prints landscape with portrait +/// Print Settings, as a script or another app asking for landscape expects. +fn settings_for_layout( + printer: &str, + settings: Option, + paper: (f64, f64), +) -> Result<(Option, craft_print::rgb::Page)> { + let page = craft_print::rgb::page(printer, settings.as_ref()).map_err(other)?; + if !turned_paper(page.paper, paper) { + return Ok((settings, page)); + } + let base = match settings { + Some(s) => s, + None => craft_print::rgb::default_settings(printer).map_err(other)?, + }; + let turned = turned_settings(&base, paper.0 > paper.1)?; + let page = craft_print::rgb::page(printer, Some(&turned)).map_err(other)?; + Ok((Some(turned), page)) +} + /// The page's image as 8-bit RGB rows (gray expanded), mirrored for Emulsion Down. pub fn page_rgb(page: &PrintPage) -> Result> { let img = &page.image; @@ -742,8 +797,7 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) { /// PDF-only (no font/text layout in the GDI path yet). fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result { let printer = printer_name(p)?; - let settings = printer_settings(p)?; - let device_page = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?; + let (settings, device_page) = settings_for_layout(&printer, printer_settings(p)?, page.paper)?; if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 { return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing")); } @@ -780,11 +834,31 @@ fn printers(_: &mut Session, _: &Value) -> Result { } fn printer_page(_: &mut Session, p: &Value) -> Result { + // Checked before the printer, so a bad value is the error whether or not a printer answers. + let landscape = match p.get("orientation").filter(|v| !v.is_null()) { + None => None, + Some(v) => match v.as_str() { + Some("portrait") => Some(false), + Some("landscape") => Some(true), + _ => return Err(bad("file.print.page", "orientation is \"portrait\" or \"landscape\"")), + }, + }; let printer = printer_name(p)?; - let pg = craft_print::rgb::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?; - Ok( - json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}), - ) + let mut settings = printer_settings(p)?; + if let Some(landscape) = landscape { + let base = match settings { + Some(s) => s, + None => craft_print::rgb::default_settings(&printer).map_err(other)?, + }; + settings = Some(turned_settings(&base, landscape)?); + } + let pg = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?; + let mut out = json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}); + // The turned settings, to pass on as `printerSettings` (the Print dialog's Orientation does). + if let (Some(_), Some(s), Some(o)) = (landscape, settings, out.as_object_mut()) { + o.insert("printerSettings".into(), json!(s.to_hex())); + } + Ok(out) } /// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer. @@ -1014,7 +1088,7 @@ pub fn specs() -> Vec { label: "Printer Page", menu: &[], shortcut: None, - params: r##"{"printer":name? (default printer),"printerSettings":hex?} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y]} (Windows)"##, + params: r##"{"printer":name? (default printer),"printerSettings":hex?,"orientation":"portrait|landscape"? (turn the settings, or the printer's own, that way)} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y], printerSettings: hex (only with orientation: the turned settings)} (Windows)"##, enabled: |_| Ok(()), journal: false, run: printer_page, diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index c54dddf6f8..685c0ec4d3 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -207,6 +207,79 @@ fn printer_commands_answer_without_a_document_and_reject_bad_settings() { assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err()); } +/// Synthetic driver settings: a DEVMODEW of `size` bytes plus `extra` private driver bytes, every +/// byte a pattern so a stray write shows, with `dmFields` saying only the paper size is set. +fn devmode(size: u16, extra: u16) -> Vec { + let mut b: Vec = (0..usize::from(size) + usize::from(extra)).map(|i| (i % 251) as u8).collect(); + b[68..70].copy_from_slice(&size.to_le_bytes()); + b[70..72].copy_from_slice(&extra.to_le_bytes()); + b[72..76].copy_from_slice(&0x2u32.to_le_bytes()); // DM_PAPERSIZE + b +} + +/// Photoshop's Layout buttons: driver settings turned to landscape or portrait set `dmOrientation` +/// and its `dmFields` bit, and keep every other byte, the driver's private settings included. +#[test] +fn turned_settings_set_the_orientation_and_keep_the_rest() { + for (size, extra) in [(220u16, 0u16), (220, 48)] { + let before = devmode(size, extra); + let dm = craft_print::rgb::DevMode::from_bytes(before.clone()).unwrap(); + for (landscape, orient) in [(true, 2i16), (false, 1i16)] { + let after = turned_settings(&dm, landscape).unwrap().as_bytes().to_vec(); + assert_eq!(after.len(), before.len()); + assert_eq!(i16::from_le_bytes([after[76], after[77]]), orient, "{size}+{extra} landscape={landscape}"); + assert_eq!(u32::from_le_bytes([after[72], after[73], after[74], after[75]]), 0x3, "DM_ORIENTATION added, DM_PAPERSIZE kept"); + for (i, (a, b)) in after.iter().zip(&before).enumerate() { + if !(72..76).contains(&i) && !(76..78).contains(&i) { + assert_eq!(a, b, "byte {i} changed"); + } + } + } + } +} + +/// The driver's paper is turned only when it is the layout's paper the other way round (within +/// the 2 pt a driver's paper may differ by): not for the same paper, another paper or a square one. +#[test] +fn only_the_same_paper_the_other_way_round_is_turned() { + let (a3, a3_wide) = ((842.29, 1191.14), (1190.55, 841.89)); + assert!(turned_paper(a3, a3_wide), "portrait driver, landscape layout"); + assert!(turned_paper((1191.14, 842.29), (841.89, 1190.55)), "landscape driver, portrait layout"); + assert!(!turned_paper(a3, (841.89, 1190.55)), "same orientation"); + assert!(!turned_paper((595.32, 841.92), a3_wide), "an A4 driver for an A3 layout"); + assert!(!turned_paper((612.0, 612.0), (612.0, 612.0)), "square"); +} + +/// `file.print.page` takes an orientation and refuses any other value before asking a printer, so +/// the error is the same with or without one. +#[test] +fn printer_page_refuses_an_unknown_orientation() { + let mut s = Session::new(); + for bad in [json!("sideways"), json!(2), json!(true)] { + let e = s.execute("file.print.page", json!({"printer": "Anything", "orientation": bad})).unwrap_err().to_string(); + assert!(e.contains("orientation"), "{bad}: {e}"); + } +} + +/// A real driver turns: Windows' "Microsoft Print to PDF" on its default (portrait) settings, for a +/// landscape A4 layout, gets settings turned to landscape and a wide page; a portrait layout keeps +/// the driver's own settings. Needs that printer, so it runs by hand: +/// `cargo test -p photocraft-engine --lib a_portrait_driver -- --ignored`. +#[cfg(windows)] +#[test] +#[ignore] +fn a_portrait_driver_turns_for_a_landscape_layout() { + let printer = "Microsoft Print to PDF"; + let defaults = craft_print::rgb::default_settings(printer).unwrap(); + let a4 = craft_print::rgb::page(printer, Some(&defaults)).unwrap().paper; + let (settings, page) = settings_for_layout(printer, None, (a4.1, a4.0)).unwrap(); + assert!(settings.is_some(), "turned settings come back"); + assert!((page.paper.0 - a4.1).abs() <= 2.0 && (page.paper.1 - a4.0).abs() <= 2.0, "wide: {:?}", page.paper); + let (settings, page) = settings_for_layout(printer, None, a4).unwrap(); + assert!(settings.is_none(), "nothing to turn"); + assert!((page.paper.0 - a4.0).abs() <= 2.0, "{:?}", page.paper); +} + #[test] fn printer_profile_can_be_an_installed_icc_file() { let dir = tmp("icc"); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 14a512c288..7573e1757b 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -772,6 +772,34 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { app.ui.open_dialog(DialogKind::Command, f) } +/// Whether the driver's paper, as `driver_page` took it, is wider than tall: the Orientation +/// buttons show landscape then. +fn driver_paper_is_wide(f: &Map) -> bool { + let side = |i: usize| f.get("paper").and_then(Value::as_array).and_then(|a| a.get(i)).and_then(Value::as_f64); + matches!((side(0), side(1)), (Some(w), Some(h)) if w > h) +} + +/// The Orientation buttons: the driver settings (or the printer's own) turned portrait or +/// landscape through `file.print.page`, then the paper taken from the driver again. An error stays +/// on the dialog, with the settings left as they were. +fn turn_driver(app: &mut PhotocraftApp, f: &mut Map, landscape: bool) { + let mut q = json!({"printer": s(f, "printer", ""), "orientation": if landscape { "landscape" } else { "portrait" }}); + if let (Some(h), Some(o)) = (f.get("printerSettings").cloned(), q.as_object_mut()) { + o.insert("printerSettings".into(), h); + } + match app.session.execute("file.print.page", q) { + Ok(r) => { + if let Some(h) = r.get("printerSettings").cloned() { + f.insert("printerSettings".into(), h); + driver_page(f); + } + } + Err(e) => { + f.insert("__pageError".into(), json!(e.to_string())); + } + } +} + /// Takes the paper from the printer's driver settings (Windows), as Photoshop does. fn driver_page(f: &mut Map) { let printer = s(f, "printer", ""); @@ -1006,6 +1034,17 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map format!("{:.0} × {:.0} mm · {}", w / 72.0 * 25.4, h / 72.0 * 25.4, tl!("set in Print Settings…")), @@ -1530,6 +1569,32 @@ mod tests { std::fs::remove_file(out).unwrap(); } } + + /// The Orientation buttons show the driver paper's shape: landscape when it is wider than tall. + #[test] + fn orientation_follows_the_driver_paper() { + let with = |paper: Value| { + let mut f = Map::new(); + f.insert("paper".into(), paper); + f + }; + assert!(driver_paper_is_wide(&with(json!([1191.14, 842.29])))); + assert!(!driver_paper_is_wide(&with(json!([842.29, 1191.14])))); + assert!(!driver_paper_is_wide(&with(json!("a3"))), "a named paper is not a driver page"); + assert!(!driver_paper_is_wide(&Map::new())); + } + + /// Turning a printer whose driver can't be asked keeps its settings and says why on the dialog. + #[test] + fn turning_without_a_driver_keeps_the_settings_and_shows_the_error() { + let (mut app, _) = app(); + let mut f = Map::new(); + f.insert("printer".into(), json!("photocraft-no-such-printer")); + f.insert("printerSettings".into(), json!("00ff")); + turn_driver(&mut app, &mut f, true); + assert_eq!(f.get("printerSettings"), Some(&json!("00ff"))); + assert!(f.get("__pageError").is_some()); + } } #[cfg(test)] diff --git a/docs/printing.md b/docs/printing.md index 68015d278e..399944d57d 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -38,6 +38,17 @@ with a `note` when no server answers. `"printServer"` on `file.print` sends one server; `"dryRun": true` renders the page and reports the equivalent `lp` command without spooling. +## Orientation (Windows) + +On Windows the paper and its orientation are the driver's: the Print dialog takes them from the +printer's settings. Its Orientation buttons (Portrait, Landscape; Photoshop's Layout) turn those +settings, as Print Settings… would, and the paper and preview turn with them. +`file.print.page {"orientation": "portrait|landscape"}` does the same for scripts: it returns the +turned settings as `printerSettings`, with the page they give. A print sent to a Windows printer +whose layout is the driver's paper the other way round (a landscape layout, portrait settings) +turns the settings to the layout before printing, rather than stopping with "the driver paper or +orientation differs"; another paper is still refused. + ## Results `file.print` returns `sent`, `backend` (`lp`, `windows`, `ipp`, `portal`), `spooler` (where the From 2644d0608113e51177d40479f93999f147e7461f Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 16:07:04 -0400 Subject: [PATCH 43/69] Review: rustfmt, cap the env adapter name, docs in their own paragraph - cargo fmt on the adapter selection and its launch code. - PHOTOCRAFT_GPU_ADAPTER is capped at 256 characters like the saved preference. - Put the multi-GPU paragraph in docs/development.md on its own instead of appending to the --safe-gpu paragraph, so the diff only adds lines. Co-Authored-By: Claude Sonnet 5.5 --- apps/photocraft/src/gpu_startup.rs | 10 +++------- apps/photocraft/src/main.rs | 6 ++---- docs/development.md | 4 +++- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 411708c8f8..56dbbb9400 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -267,12 +267,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option { 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) + 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 @@ -290,7 +285,7 @@ pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool { /// 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) + [env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect()) } /// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the @@ -684,6 +679,7 @@ mod tests { 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())); + assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256)); } #[test] diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 1c06e23898..0c2be25232 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -682,10 +682,8 @@ 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. - 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()), - ); + 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. diff --git a/docs/development.md b/docs/development.md index bf4898482d..2691f55d1c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,9 @@ 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. 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. +`--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. + +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. 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 0e97ac4ca8ec11fb1081836be01c65c605892a5b Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 16:19:51 -0400 Subject: [PATCH 44/69] Make adapter choice Linux-only everywhere, not just the dropdown The Preferences row was Linux-only, but PHOTOCRAFT_GPU_ADAPTER and performance.gpuAdapter were honoured on every platform, so Windows and macOS could pick a non-default adapter without being tested for it. Ignore the request off Linux (and in CPU mode, as before), and don't make a launch standalone for a variable that is ignored there. Co-Authored-By: Claude Sonnet 5.5 --- apps/photocraft/src/gpu_startup.rs | 23 +++++++++++++++++++++-- apps/photocraft/src/main.rs | 2 +- docs/development.md | 4 ++-- docs/hardware-parity.md | 2 +- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 56dbbb9400..d945e3897e 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -282,6 +282,16 @@ pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool { wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted) } +/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a +/// single Metal path, and neither is tested with a requested adapter. +pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux"); + +/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode, +/// which wants a software adapter whatever the preference says. +pub fn applicable_adapter(wanted: Option, backend: GpuBackend, linux: bool) -> Option { + wanted.filter(|_| linux && backend != GpuBackend::Cpu) +} + /// 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 { @@ -322,8 +332,7 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: 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)); } - // CPU mode wants a software adapter, whatever the preference says. - let wanted = wanted.filter(|_| plan.backend != GpuBackend::Cpu); + let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE); 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; @@ -669,6 +678,16 @@ mod tests { assert_eq!(pick(&gl, Auto, Os::Other), Some(1)); } + #[test] + fn an_adapter_request_applies_on_linux_outside_cpu_mode() { + let w = || Some("nvidia".to_string()); + assert_eq!(applicable_adapter(w(), Auto, true), w()); + assert_eq!(applicable_adapter(w(), Vulkan, true), w()); + assert_eq!(applicable_adapter(w(), Cpu, true), None); + assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection"); + assert_eq!(applicable_adapter(None, Auto, true), None); + } + #[test] fn adapter_names_match_loosely_and_the_environment_wins() { assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 0c2be25232..d110f849b7 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -517,7 +517,7 @@ 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()) { + if gpu_startup::ADAPTER_CHOICE && 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 diff --git a/docs/development.md b/docs/development.md index 2691f55d1c..8a63b825c9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -88,7 +88,7 @@ On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.l `--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. -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. +On Linux, 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. 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). @@ -118,7 +118,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 | +| `PHOTOCRAFT_GPU_ADAPTER=nvidia` | Linux: 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 1417821f0d..391c92f0ce 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +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 | +| Choosing the GPU on a multi-GPU machine | Preferences › Performance › Use Graphics Processor | Linux only: Preferences › Performance › Advanced › GPU adapter and `PHOTOCRAFT_GPU_ADAPTER` (#1885); ignored on Windows and macOS | partial | | Old / integrated GPUs | yes | black line on Intel HD (#2426), OpenCore Macs fail (#1859), Rgba32Float panic (#2020) | partial | ## Pen tablets From 53effe3c838d823081157c84808f87773198261a Mon Sep 17 00:00:00 2001 From: Ernie Smith Date: Mon, 19 Oct 2026 16:31:57 -0400 Subject: [PATCH 45/69] Review: keep the missing-adapter note, and forward the override in CPU mode - A requested adapter that couldn't be used, followed by a software window adapter, lost its System Info note to the software-graphics message. Append instead of replacing. - PHOTOCRAFT_GPU_ADAPTER only forces its own process when startup acts on it (Linux, outside CPU mode); in CPU mode it is ignored, so the launch may still go to the running window. Co-Authored-By: Claude Sonnet 5.5 --- apps/photocraft/src/gpu_startup.rs | 18 ++++++++++++++++++ apps/photocraft/src/main.rs | 11 +++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index d945e3897e..c68799b2db 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -292,6 +292,13 @@ pub fn applicable_adapter(wanted: Option, backend: GpuBackend, linux: bo wanted.filter(|_| linux && backend != GpuBackend::Cpu) } +/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act +/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the +/// launch may still be forwarded to the running window. +pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool { + env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu +} + /// 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 { @@ -688,6 +695,17 @@ mod tests { assert_eq!(applicable_adapter(None, Auto, true), None); } + #[test] + fn an_adapter_override_keeps_its_own_process_only_when_it_applies() { + let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux); + assert!(on(Some("nvidia"), RenderingMode::Auto, true)); + assert!(on(Some("nvidia"), RenderingMode::Gpu, true)); + assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode"); + assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux"); + assert!(!on(Some(" "), RenderingMode::Auto, true)); + assert!(!on(None, RenderingMode::Auto, true)); + } + #[test] fn adapter_names_match_loosely_and_the_environment_wins() { assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia")); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index d110f849b7..acac4211e5 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -517,7 +517,8 @@ fn main() -> eframe::Result { } // An adapter named in the environment is chosen at startup, which a forwarded launch never reaches. - if gpu_startup::ADAPTER_CHOICE && std::env::var("PHOTOCRAFT_GPU_ADAPTER").is_ok_and(|v| !v.trim().is_empty()) { + let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1; + if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) { launch_options.push("PHOTOCRAFT_GPU_ADAPTER"); } // One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs @@ -783,7 +784,13 @@ fn main() -> eframe::Result { 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()); + // Keep an earlier note, such as a requested adapter that couldn't be used. + let software = "No compatible hardware graphics adapter; using software graphics."; + let fallback = &mut app.perf.gpu_info.fallback; + *fallback = Some(match fallback.take() { + Some(earlier) => format!("{earlier}. {software}"), + None => software.into(), + }); } if !software_window && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu From f8117a535c2f20de9a15280e3c2f70412622da27 Mon Sep 17 00:00:00 2001 From: Wolf Date: Mon, 19 Oct 2026 22:54:46 +0200 Subject: [PATCH 46/69] Print: a turn takes settings and paper from one answer (#7847 review) The Orientation buttons stored the turned settings from file.print.page and then asked the driver again (driver_page) for the paper. If that second query failed, the dialog kept the new settings with the old paper. take_turned_page now takes both, and the orientation, from the one answer; an answer without both leaves the dialog as it was and shows why. The no-driver test used "00ff", which fails when the settings are read, before any printer is asked, so it never tested the failed-driver path. It now uses valid settings (a minimal DEVMODEW) and asserts the error comes from the printer, not from reading the settings; with "00ff" it fails. a_turn_takes_settings_and_paper_together covers the success path and partial answers. Reported by CodeRabbit in review. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/file_ui.rs | 65 +++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 7573e1757b..32f97bc4e8 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -780,26 +780,36 @@ fn driver_paper_is_wide(f: &Map) -> bool { } /// The Orientation buttons: the driver settings (or the printer's own) turned portrait or -/// landscape through `file.print.page`, then the paper taken from the driver again. An error stays -/// on the dialog, with the settings left as they were. +/// landscape through `file.print.page`. An error stays on the dialog, with the settings left as +/// they were. fn turn_driver(app: &mut PhotocraftApp, f: &mut Map, landscape: bool) { let mut q = json!({"printer": s(f, "printer", ""), "orientation": if landscape { "landscape" } else { "portrait" }}); if let (Some(h), Some(o)) = (f.get("printerSettings").cloned(), q.as_object_mut()) { o.insert("printerSettings".into(), h); } match app.session.execute("file.print.page", q) { - Ok(r) => { - if let Some(h) = r.get("printerSettings").cloned() { - f.insert("printerSettings".into(), h); - driver_page(f); - } - } + Ok(r) => take_turned_page(f, &r), Err(e) => { f.insert("__pageError".into(), json!(e.to_string())); } } } +/// The turned settings and the paper they give, both from the one `file.print.page` answer, as +/// `driver_page` takes a page: asking the driver again could fail and leave new settings with the +/// old paper. An answer without both leaves the dialog as it was, with the reason shown. +fn take_turned_page(f: &mut Map, r: &Value) { + let paper = r.get("paper").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let (Some(settings), Some((w, h))) = (r.get("printerSettings").and_then(Value::as_str), paper) else { + f.insert("__pageError".into(), json!("the printer did not report the turned page")); + return; + }; + f.insert("printerSettings".into(), json!(settings)); + f.insert("paper".into(), json!([w, h])); + f.insert("orientation".into(), json!("portrait")); + f.remove("__pageError"); +} + /// Takes the paper from the printer's driver settings (Windows), as Photoshop does. fn driver_page(f: &mut Map) { let printer = s(f, "printer", ""); @@ -1587,13 +1597,46 @@ mod tests { /// Turning a printer whose driver can't be asked keeps its settings and says why on the dialog. #[test] fn turning_without_a_driver_keeps_the_settings_and_shows_the_error() { + // Valid driver settings (a minimal DEVMODEW: 220 bytes, no private part), so the command + // gets past reading them and fails where it asks the (missing) printer for its page. + let mut dm = vec![0u8; 220]; + dm[68..70].copy_from_slice(&220u16.to_le_bytes()); + let hex: String = dm.iter().map(|b| format!("{b:02x}")).collect(); + assert!(craft_print::rgb::DevMode::from_hex(&hex).is_ok()); let (mut app, _) = app(); let mut f = Map::new(); f.insert("printer".into(), json!("photocraft-no-such-printer")); - f.insert("printerSettings".into(), json!("00ff")); + f.insert("printerSettings".into(), json!(hex)); + f.insert("paper".into(), json!([595.28, 841.89])); turn_driver(&mut app, &mut f, true); - assert_eq!(f.get("printerSettings"), Some(&json!("00ff"))); - assert!(f.get("__pageError").is_some()); + assert_eq!(f.get("printerSettings"), Some(&json!(hex))); + assert_eq!(f.get("paper"), Some(&json!([595.28, 841.89]))); + let error = f.get("__pageError").and_then(Value::as_str).unwrap_or_default(); + // Reading bad settings fails with "printer settings are too short / damaged"; this must not. + assert!(!error.is_empty() && !error.contains("printer settings are"), "failed at the printer, not at reading the settings: {error}"); + } + + /// A turn takes the settings and the paper from the same answer, never one without the other. + #[test] + fn a_turn_takes_settings_and_paper_together() { + let fields = || { + let mut f = Map::new(); + f.insert("printerSettings".into(), json!("old")); + f.insert("paper".into(), json!([595.28, 841.89])); + f.insert("__pageError".into(), json!("earlier")); + f + }; + let mut f = fields(); + take_turned_page(&mut f, &json!({"printerSettings": "new", "paper": [841.89, 595.28]})); + assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("new")), Some(&json!([841.89, 595.28])))); + assert_eq!(f.get("orientation"), Some(&json!("portrait")), "the driver paper is already turned"); + assert!(f.get("__pageError").is_none()); + for partial in [json!({"printerSettings": "new"}), json!({"paper": [841.89, 595.28]}), json!({"printerSettings": "new", "paper": [841.89]})] { + let mut f = fields(); + take_turned_page(&mut f, &partial); + assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("old")), Some(&json!([595.28, 841.89]))), "{partial}"); + assert!(f.get("__pageError").is_some(), "{partial}"); + } } } From a2f63703a10286601b19ae4903b4e1b2f4620248 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 16:28:29 -0600 Subject: [PATCH 47/69] Re-run CI Co-Authored-By: Claude Opus 5.5 From 04a2169b447f41c2d0c4450124653f9279d43d38 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:45:16 +0300 Subject: [PATCH 48/69] fix(io): an indexed GIF opens in Indexed Color with its colour table (#7886) --- crates/codecs/src/codecs/gif_palette.rs | 64 +++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/lib.rs | 6 +++ crates/io/src/flat.rs | 5 +- 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 crates/codecs/src/codecs/gif_palette.rs diff --git a/crates/codecs/src/codecs/gif_palette.rs b/crates/codecs/src/codecs/gif_palette.rs new file mode 100644 index 0000000000..aa71f0237d --- /dev/null +++ b/crates/codecs/src/codecs/gif_palette.rs @@ -0,0 +1,64 @@ +//! The colour table of a GIF, read from its blocks without decoding pixels (GIF89a +//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control +//! Extension). +//! +//! A GIF is always palette-based, so it opens in Indexed Color with the table of the frame that +//! [`crate::decode`] keeps, the first: its Local Color Table when it has one, else the Global +//! one, and the transparent entry that frame's Graphic Control Extension names. + +/// Colours of the table the first frame uses, and its transparent entry. `None` for anything +/// that isn't a well-formed GIF up to its first image, or a first frame without a table. +pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { + if !(b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a")) { + return None; + } + // Logical Screen Descriptor: width, height, packed fields, background, aspect. + let packed = *b.get(10)?; + let mut at = 13; + let table = |at: usize, packed: u8| -> Option> { + let n = 2usize << (packed & 0x07); + let bytes = b.get(at..at.checked_add(n.checked_mul(3)?)?)?; + Some(bytes.as_chunks::<3>().0.to_vec()) + }; + let global = if packed & 0x80 != 0 { + let t = table(at, packed)?; + at += t.len() * 3; + Some(t) + } else { + None + }; + let mut transparent = None; + loop { + match *b.get(at)? { + // Extension: label, then data sub-blocks up to a zero-length one. + 0x21 => { + let label = *b.get(at + 1)?; + at += 2; + // Graphic Control Extension: block size 4, packed (bit 0: transparent colour + // given), delay (2 bytes), transparent colour index. + if label == 0xF9 && *b.get(at)? == 4 { + let flags = *b.get(at + 1)?; + transparent = (flags & 0x01 != 0).then_some(*b.get(at + 4)?); + } + loop { + let size = usize::from(*b.get(at)?); + at = at.checked_add(1 + size)?; + if size == 0 { + break; + } + } + } + // Image Descriptor: position and size (8 bytes), packed fields, then the Local Color + // Table if bit 7 is set. This is the first frame: its table is the one used. + 0x2C => { + let packed = *b.get(at + 9)?; + let colors = if packed & 0x80 != 0 { table(at + 10, packed)? } else { global? }; + let transparent = transparent.filter(|i| usize::from(*i) < colors.len()); + return Some((colors, transparent)); + } + // Trailer, or anything else: no image. + _ => return None, + } + } +} + diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 32e68b336a..41e1e89a14 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; pub(crate) mod gif_frames; +pub(crate) mod gif_palette; pub(crate) mod heif; pub(crate) mod jpeg; pub(crate) mod pcx; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 67369d0266..0966fba07f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -126,6 +126,12 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) } +/// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the +/// entry its Graphic Control Extension makes transparent, read without decoding pixels. +pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { + crate::codecs::gif_palette::palette(bytes) +} + /// Returns the embedded colour table for an indexed BMP, if it has one. pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { via_image::bmp_palette(bytes) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 39fd2dc634..c9e3a410ae 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -22,11 +22,12 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { return import_tiff_page(name, bytes, None); } let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; - // A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop - // (#3802, #4804). + // A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in + // Photoshop (#3802, #4804, #7886). A GIF is always palette-based: the first frame's table. let palette = match format { Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)), Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)), + Some(Format::Gif) => codecs::gif_palette(bytes), _ => None, }; let mut r = image_to_document(name, &img)?; From f708151c914873ef850fa78e2dbc5606c2881662 Mon Sep 17 00:00:00 2001 From: fjamesprice Date: Fri, 16 Oct 2026 22:15:05 -0400 Subject: [PATCH 49/69] perf(camera-raw): develop the preview off the UI thread (#5964) The Camera Raw dialog developed its preview proxy on the UI thread at the start of every frame after a control changed. With Shadows or Highlights set that develop takes 190-250 ms on a 24 MP document (858x572 proxy), so each frame of a slider drag blocked for that long. The dialog's own controls now hand the develop, histogram and display conversion to a worker. One runs at a time; settings changed meanwhile are developed when it finishes, and the preview shows the latest finished frame. The control channel and the first frame still render on the UI thread so a reply describes its own settings, and such a render drops any worker still running with older settings. The wasm build keeps rendering synchronously. describe() reports previewPending. Release build, 6000x4000 document, 20 Highlights/Shadows edits: worst UI frame 237 ms before, 35.5 ms after. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/camera_raw_ui.rs | 284 ++++++++++++++++++++++++---- 1 file changed, 249 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index d2f78a35c2..6049f557fa 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -3,7 +3,8 @@ //! //! The preview runs [`photocraft_algo::camera_raw::develop`] on a CPU proxy of the layer //! (≤ 900 px, `pixel_scale` keeps pixel radii true to the full image), recomputed when a -//! control changes. OK runs `filter.cameraRaw` with the non-default settings, so the result is +//! control changes: on a worker for the dialog's own controls, so a drag never waits for it, and +//! on the UI thread for the control channel, so a reply describes its own settings. OK runs `filter.cameraRaw` with the non-default settings, so the result is //! one history step (or a smart filter on a smart object) and exactly replayable. //! //! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…}, @@ -38,8 +39,8 @@ pub struct CameraRawDialog { curve_state: crate::state::PointCurveState, curve_rect: Option, /// Proxy pixels (straight RGBA) and size. - pub(crate) proxy: Vec<[f32; 4]>, - pub(crate) selection: Option>, + pub(crate) proxy: std::sync::Arc>, + pub(crate) selection: Option>>, pub(crate) pw: usize, pub(crate) ph: usize, full_w: usize, @@ -68,7 +69,9 @@ pub struct CameraRawDialog { raw_open: Option, /// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there, /// in linear light before this curve, and the preview undoes it to show the same. - raw_curve: Option>, + raw_curve: Option>>, + /// The proxy develop running on a worker ([`Self::request_render`]). + pending: Option>>, /// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100). undo: Vec, redo: Vec, @@ -91,6 +94,7 @@ impl CameraRawDialog { result["openingRaw"] = json!(self.raw_open.as_ref().map(|r| json!({"name": r.name, "path": r.path}))); result["view"] = json!(navigation); result["sourceSize"] = json!([self.full_w, self.full_h]); + result["previewPending"] = json!(self.render_pending()); result["previewApproximate"] = json!(!self.detail.ready && (self.pw != self.full_w || self.ph != self.full_h)); result["detailPending"] = json!(self.detail.pending()); result["detailError"] = json!(self.detail.error); @@ -139,45 +143,150 @@ impl CameraRawDialog { }) } + /// Develops the proxy with the current settings and shows it, on this thread. The control + /// channel and the first frame use this, so a reply always describes its own settings; the + /// dialog's own controls go through [`Self::request_render`]. pub(crate) fn render(&mut self, ctx: &egui::Context) { + // A render still running on the worker has older settings than this one. + self.pending = None; + let frame = self.proxy_job().run(); + self.publish(ctx, frame); + self.dirty = false; + } + + fn proxy_job(&self) -> ProxyJob { + ProxyJob { + proxy: self.proxy.clone(), + selection: self.selection.clone(), + raw_curve: self.raw_curve.clone(), + params: self.params.clone(), + float: self.float, + size: (self.pw, self.ph), + full_w: self.full_w, + display: self.display.clone(), + } + } + + fn publish(&mut self, ctx: &egui::Context, frame: ProxyFrame) { + match &mut self.tex { + Some(t) => t.set(frame.image, egui::TextureOptions::LINEAR), + None => self.tex = Some(ctx.load_texture("camera-raw-after", frame.image, egui::TextureOptions::LINEAR)), + } + if self.before_display_key != Some(frame.display.key) { + let before = Self::image(&self.proxy, self.pw, self.ph, &frame.display); + match &mut self.before_tex { + Some(t) => t.set(before, egui::TextureOptions::LINEAR), + None => self.before_tex = Some(ctx.load_texture("camera-raw-before", before, egui::TextureOptions::LINEAR)), + } + self.before_display_key = Some(frame.display.key); + } + self.after_histogram = frame.histogram; + self.histogram_ms = frame.histogram_ms; + self.processed = frame.px; + self.scope.cache = None; + self.render_ms = frame.ms; + self.preview_revision = self.preview_revision.saturating_add(1); + } + + /// Starts developing the proxy with the current settings on a worker, unless one is already + /// running: then [`Self::poll_render`] starts the next one with the settings of that moment. + /// A slider drag never waits for the develop, which takes hundreds of milliseconds with + /// Shadows or Highlights set (#5964); the preview shows the latest finished frame. + fn request_render(&mut self, ctx: &egui::Context) { + if !self.dirty || self.pending.is_some() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let job = self.proxy_job(); + let (tx, rx) = std::sync::mpsc::channel::>(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("camera-raw-proxy".into()).spawn(move || { + let frame = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run())).map_err(|_| "Camera Raw preview failed".to_string()); + // The dialog may have closed or rendered on its own meanwhile; then nobody listens. + if tx.send(frame).is_ok() { + repaint.request_repaint(); + } + }); + match spawned { + Ok(_) => { + self.pending = Some(rx); + self.dirty = false; + } + // No thread to spare: develop here rather than show stale settings. + Err(_) => self.render(ctx), + } + } + #[cfg(target_arch = "wasm32")] + self.render(ctx); + } + + /// Shows the worker's frame once it is done. `Err` when the develop failed; the preview keeps + /// the last frame then. + fn poll_render(&mut self, ctx: &egui::Context) -> Result<(), String> { + let Some(rx) = &self.pending else { return Ok(()) }; + let frame = match rx.try_recv() { + Ok(frame) => frame, + Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()), + Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("Camera Raw preview worker stopped".to_string()), + }; + self.pending = None; + self.publish(ctx, frame?); + Ok(()) + } + + pub(crate) fn render_pending(&self) -> bool { + self.pending.is_some() + } +} + +/// Everything one develop of the proxy reads, owned, so it can run off the UI thread. +struct ProxyJob { + proxy: std::sync::Arc>, + selection: Option>>, + raw_curve: Option>>, + params: CameraRaw, + float: bool, + size: (usize, usize), + full_w: usize, + display: std::sync::Arc, +} + +/// A developed proxy, ready to show. +struct ProxyFrame { + px: Vec<[f32; 4]>, + histogram: RgbHistogram, + histogram_ms: f64, + image: egui::ColorImage, + display: std::sync::Arc, + ms: f64, +} + +impl ProxyJob { + fn run(self) -> ProxyFrame { let t0 = crate::gpu_canvas::now_ms(); - let mut px = self.proxy.clone(); - let mut p = self.params.clone(); - p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0); + let (pw, ph) = self.size; + let mut px = self.proxy.to_vec(); + let mut p = self.params; + p.pixel_scale = (pw as f32 / self.full_w.max(1) as f32).min(1.0); if let Some(curve) = &self.raw_curve { let tone = raw_stage(&p); - photocraft_io::raw::retone(&mut px, self.pw, self.ph, curve, tone); + photocraft_io::raw::retone(&mut px, pw, ph, curve, tone); (p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0); } - photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float); + photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float); if let Some(mask) = &self.selection { - for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) { + for ((out, original), coverage) in px.iter_mut().zip(self.proxy.iter()).zip(mask.iter()) { for (out, original) in out.iter_mut().zip(original) { *out = *original + (*out - *original) * coverage; } } } let histogram_start = crate::gpu_canvas::now_ms(); - self.after_histogram = RgbHistogram::from_rgba(&px); - self.histogram_ms = crate::gpu_canvas::now_ms() - histogram_start; - let img = Self::image(&px, self.pw, self.ph, &self.display); - match &mut self.tex { - Some(t) => t.set(img, egui::TextureOptions::LINEAR), - None => self.tex = Some(ctx.load_texture("camera-raw-after", img, egui::TextureOptions::LINEAR)), - } - if self.before_display_key != Some(self.display.key) { - let before = Self::image(&self.proxy, self.pw, self.ph, &self.display); - match &mut self.before_tex { - Some(t) => t.set(before, egui::TextureOptions::LINEAR), - None => self.before_tex = Some(ctx.load_texture("camera-raw-before", before, egui::TextureOptions::LINEAR)), - } - self.before_display_key = Some(self.display.key); - } - self.processed = px; - self.scope.cache = None; - self.render_ms = crate::gpu_canvas::now_ms() - t0; - self.preview_revision = self.preview_revision.saturating_add(1); - self.dirty = false; + let histogram = RgbHistogram::from_rgba(&px); + let histogram_ms = crate::gpu_canvas::now_ms() - histogram_start; + let image = CameraRawDialog::image(&px, pw, ph, &self.display); + ProxyFrame { px, histogram, histogram_ms, image, display: self.display, ms: crate::gpu_canvas::now_ms() - t0 } } } @@ -245,7 +354,7 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul }; if let Some(d) = app.camera_raw.as_mut() { d.layer_name = raw.name.clone(); - d.raw_curve = curve; + d.raw_curve = curve.map(std::sync::Arc::new); d.raw_open = Some(raw); } Ok(()) @@ -611,8 +720,8 @@ fn open_pixels( after_histogram: RgbHistogram::default(), preview_revision: 0, histogram_ms: 0.0, - proxy, - selection, + proxy: std::sync::Arc::new(proxy), + selection: selection.map(std::sync::Arc::new), pw, ph, full_w: w, @@ -643,6 +752,7 @@ fn open_pixels( render_ms: 0.0, raw_open: None, raw_curve: None, + pending: None, undo: Vec::new(), redo: Vec::new(), edit_origin: None, @@ -1073,9 +1183,11 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } d.curve_rect = None; d.scope.vectorscope_rect = None; - if d.dirty { - d.render(ctx); + if let Err(e) = d.poll_render(ctx) { + app.ui.status = e; + app.ui.status_error = true; } + d.request_render(ctx); let (full, _) = ui.allocate_exact_size(screen.size(), Sense::click()); let painter = ui.painter().clone(); painter.rect_filled(full, 0.0, t.chrome); @@ -1247,6 +1359,10 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { if d.dirty { ctx.request_repaint(); } + if d.render_pending() { + // The worker asks for a repaint when it is done; this catches a missed wake-up. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } // Footer. let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max); painter.rect_filled(foot, 0.0, t.dock); @@ -1398,6 +1514,104 @@ mod tests { assert_eq!(app.session.active().unwrap().history.past_len(), steps); } + /// A layer with tonal variation, a selection over part of it and the dialog open on it. + fn worker_app() -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#406080"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + open(&mut app, &ctx).unwrap(); + (app, ctx) + } + + fn finish_worker(d: &mut CameraRawDialog, ctx: &egui::Context) { + for _ in 0..2000 { + d.poll_render(ctx).unwrap(); + if !d.render_pending() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the Camera Raw preview worker never finished"); + } + + /// The dialog's controls develop the proxy on a worker (#5964): the frame it shows is the one + /// the UI thread would have developed, and settings changed while it runs are developed next. + #[test] + fn worker_preview_matches_the_synchronous_render_and_ends_on_the_latest_settings() { + let (mut app, ctx) = worker_app(); + let d = app.camera_raw.as_mut().unwrap(); + assert!(d.selection.is_some()); + let revision = d.preview_revision; + d.params.exposure = 1.0; + d.dirty = true; + d.request_render(&ctx); + assert!(d.render_pending() && !d.dirty); + // A drag moves on while the first develop runs; the request waits for it. + d.params.highlights = -60.0; + d.params.shadows = 45.0; + d.dirty = true; + d.request_render(&ctx); + finish_worker(d, &ctx); + assert_eq!(d.preview_revision, revision + 1); + assert!(d.dirty, "the newer settings are still to be developed"); + d.request_render(&ctx); + finish_worker(d, &ctx); + assert_eq!(d.preview_revision, revision + 2); + let (from_worker, histogram) = (d.processed.clone(), d.after_histogram.channels); + d.render(&ctx); + assert_eq!(d.processed, from_worker, "the worker develops exactly what the UI thread does"); + assert_eq!(d.after_histogram.channels, histogram); + assert!(d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)["previewPending"] == false); + } + + /// The control channel renders on its own thread so its reply describes its settings; a worker + /// still running with older settings must not replace that frame afterwards. + #[test] + fn a_stale_worker_never_replaces_a_newer_synchronous_render() { + let (mut app, ctx) = worker_app(); + let d = app.camera_raw.as_mut().unwrap(); + d.params.highlights = -80.0; + d.dirty = true; + d.request_render(&ctx); + assert!(d.render_pending()); + let reply = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"highlights": 0, "exposure": -1.0}}})).unwrap().unwrap(); + assert_eq!(reply["previewPending"], false); + let d = app.camera_raw.as_mut().unwrap(); + let (revision, shown) = (d.preview_revision, d.processed.clone()); + std::thread::sleep(std::time::Duration::from_millis(200)); + d.poll_render(&ctx).unwrap(); + assert_eq!((d.preview_revision, &d.processed), (revision, &shown)); + d.render(&ctx); + assert_eq!(d.processed, shown, "the frame shown is the one for the control channel's settings"); + } + + /// Dragging a slider in the dialog itself never develops on the UI thread: the frame that + /// takes the change returns with the develop still running, and the preview catches up. + #[test] + fn a_dialog_edit_develops_off_the_ui_thread() { + let (mut app, ctx) = worker_app(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro); + let frame = |app: &mut PhotocraftApp| { + ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())).textures_delta.clear(); + }; + frame(&mut app); + let d = app.camera_raw.as_mut().unwrap(); + let revision = d.preview_revision; + d.params.highlights = -60.0; + d.dirty = true; + frame(&mut app); + let d = app.camera_raw.as_mut().unwrap(); + assert!(d.render_pending() && d.preview_revision == revision, "the frame returned before the develop finished"); + finish_worker(d, &ctx); + assert_eq!(d.preview_revision, revision + 1); + assert!(!d.dirty); + frame(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + } + #[test] fn smart_filter_double_click_edits_the_stored_camera_raw_filter_in_its_own_dialog() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); From eafc410ce2a5f27d0b98bf1cfe31cf8148dfe299 Mon Sep 17 00:00:00 2001 From: fjamesprice Date: Fri, 16 Oct 2026 22:15:06 -0400 Subject: [PATCH 50/69] contributors: credit fjamesprice by GitHub profile name Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bc3533777b..7666ed5b66 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -181,3 +181,6 @@ sync_github_name = true [people.00xJS] sync_github_name = true + +[people.fjamesprice] +sync_github_name = true From bdf2e6cb31e8d5c49e8d8bb01bbc3465f2cc5289 Mon Sep 17 00:00:00 2001 From: fjamesprice Date: Sat, 17 Oct 2026 16:23:14 -0400 Subject: [PATCH 51/69] test(camera-raw): wait for the proxy worker before recording a revision The dialog's own controls develop the preview on a worker (#5964), so after the scope tone drags its last frame could land after the test recorded previewRevision and bump it during the hover check that must not re-render. A settle() helper waits until previewPending is false; the fixture and the drag test call it before taking a baseline. With a 20 ms delay added to the worker, the old test failed 9 of 10 runs with the reviewer's assertion; with settle() all 21 tests passed 10 of 10. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/camera_raw_scope.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/tests/camera_raw_scope.rs b/crates/ui-egui/tests/camera_raw_scope.rs index 7d9957ce3f..7ff1236077 100644 --- a/crates/ui-egui/tests/camera_raw_scope.rs +++ b/crates/ui-egui/tests/camera_raw_scope.rs @@ -39,8 +39,21 @@ fn fixture_render(theme: ThemeKind, gpu: bool) -> Harness<'static, PhotocraftApp h.state_mut().set_theme(&ctx, theme); h.run_steps(3); assert_eq!(h.state().ui.theme, theme); + settle(&mut h); h } +/// Waits for the proxy develop that the dialog's own controls start on a worker (#5964), so its +/// frame can't land, and bump `previewRevision`, after a test has recorded the revision. +fn settle(h: &mut Harness<'_, PhotocraftApp>) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while inspect(h)["previewPending"] == true { + assert!(std::time::Instant::now() < deadline, "the Camera Raw preview worker never finished"); + std::thread::sleep(std::time::Duration::from_millis(2)); + h.run_steps(1); + } + // The frame that picked up the result also rebuilt the scope; let it go quiet. + h.run_steps(2); +} fn press(h: &mut Harness<'_, PhotocraftApp>, p: Pos2, pressed: bool, modifiers: Modifiers) { h.event(Event::ModifiersChanged(modifiers)); h.event(Event::PointerMoved(p)); @@ -218,6 +231,8 @@ fn real_drag_hover_warnings_sampler_motion_and_floating_scope_across_themes() { let ctx = h.ctx.clone(); menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"samplerTool":true,"vectorscope":true,"shadows":true,"highlights":true}}})).unwrap(); h.run_steps(3); + // The tone drags above developed on the worker; the last frame may still be on its way. + settle(&mut h); let revision = inspect(&h)["previewRevision"].clone(); let scope_revision = inspect(&h)["scopeRevision"].clone(); let preview = rect(&h, "previewRect"); From e92e5164a87e7c1a65a96c39b9fd402a36b070b9 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:45:17 +0300 Subject: [PATCH 52/69] test(io): an indexed GIF keeps its table and transparent entry; malformed GIFs give no table (#7886) --- apps/photocraft-cli/tests/cli.rs | 20 ++++++-- crates/codecs/src/codecs/gif_palette.rs | 62 +++++++++++++++++++++++++ crates/io/tests/flat.rs | 54 +++++++++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 3c28a8196e..8e4f237f6d 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() { gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0"); } gif.push(0x3B); + // A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it + // writes, after the decode warning. let cases = [ - ("whole.jpg", &jpeg[..], None), - ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")), - ("anim.gif", &gif[..], Some("only the first of 3 frames was imported")), + ("whole.jpg", &jpeg[..], None, None), + ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None), + ("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")), ]; - for (name, bytes, warning) in cases { + for (name, bytes, warning, export) in cases { let file = d.join(name); std::fs::write(&file, bytes).unwrap(); let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact")); let v: Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["warnings"], json!(warning.into_iter().collect::>()), "{name}"); let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))); - assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}"); + let expected: Vec = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect(); + assert_eq!( + err.trim(), + expected.join( + " +" + ), + "{name}" + ); } std::fs::remove_dir_all(d).unwrap(); } diff --git a/crates/codecs/src/codecs/gif_palette.rs b/crates/codecs/src/codecs/gif_palette.rs index aa71f0237d..eb0a0e2180 100644 --- a/crates/codecs/src/codecs/gif_palette.rs +++ b/crates/codecs/src/codecs/gif_palette.rs @@ -62,3 +62,65 @@ pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { } } +#[cfg(test)] +mod tests { + use super::palette; + + /// A GIF up to its first image: `global` table entries, optional GCE transparency, optional + /// local table. + fn gif(global: &[[u8; 3]], transparent: Option, local: Option<&[[u8; 3]]>) -> Vec { + let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8; + let mut b = b"GIF89a".to_vec(); + b.extend([4, 0, 4, 0]); + b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) }); + b.extend([0, 0]); + let pad = |b: &mut Vec, t: &[[u8; 3]]| { + let n = 2usize << size_bits(t.len()); + for k in 0..n { + b.extend(t.get(k).copied().unwrap_or([0, 0, 0])); + } + }; + if !global.is_empty() { + pad(&mut b, global); + } + // A comment extension first, to skip. + b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]); + if let Some(i) = transparent { + b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]); + } + b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]); + match local { + Some(t) => { + b.push(0x80 | size_bits(t.len())); + pad(&mut b, t); + } + None => b.push(0), + } + b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]); + b + } + + #[test] + fn the_first_frames_table_and_transparent_entry() { + let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]]; + assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None))); + assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2)))); + // A local table wins over the global one. + let l = [[1, 2, 3], [4, 5, 6]]; + assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1)))); + // A transparent index past the table is no transparent entry. + assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None))); + } + + #[test] + fn no_table_or_not_a_gif_is_none() { + assert_eq!(palette(&gif(&[], None, None)), None, "no table at all"); + assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None); + assert_eq!(palette(b""), None); + let whole = gif(&[[9, 9, 9], [8, 8, 8]], Some(0), None); + for cut in 0..whole.len() - 6 { + // Cut short anywhere before the image descriptor's fields: never a panic. + let _ = palette(&whole[..cut]); + } + } +} diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index e591fbd5c1..72126d0bc0 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -118,6 +118,60 @@ fn indexed_png_opens_with_its_color_table() { assert_eq!(s.rgba(0, 1)[3], 0.0, "the tRNS entry is transparent"); } +/// A 2 × 2 GIF89a of palette indices `idx` over `pal` (padded to 4 entries), with `transparent` +/// set through a Graphic Control Extension. Its LZW stream clears before every pixel, so every +/// code stays 3 bits: the simplest valid stream any decoder reads. +fn indexed_gif(pal: &[[u8; 3]; 4], idx: [u8; 4], transparent: Option) -> Vec { + let mut b = b"GIF89a".to_vec(); + b.extend([2, 0, 2, 0, 0x81, 0, 0]); // 2 × 2, global table of 4 + pal.iter().for_each(|c| b.extend(c)); + if let Some(t) = transparent { + b.extend([0x21, 0xF9, 4, 0x01, 0, 0, t, 0]); + } + b.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]); + let (clear, end) = (4u32, 5u32); + let codes: Vec = idx.iter().flat_map(|&i| [clear, u32::from(i)]).chain([end]).collect(); + let (mut bits, mut n, mut data) = (0u32, 0u32, Vec::new()); + for c in codes { + bits |= c << n; + n += 3; + while n >= 8 { + data.push((bits & 0xFF) as u8); + bits >>= 8; + n -= 8; + } + } + if n > 0 { + data.push(bits as u8); + } + b.push(2); // LZW minimum code size + b.push(data.len() as u8); + b.extend(&data); + b.extend([0, 0x3B]); + b +} + +/// An indexed GIF opens in Indexed Color mode with its colour table and transparent entry, as a +/// palette PNG does (#7886); it used to open as RGB. +#[test] +fn indexed_gif_opens_with_its_color_table() { + let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]]; + let doc = import("indexed.gif", &indexed_gif(&pal, [1, 0, 2, 1], Some(2))).unwrap().document; + assert_eq!(doc.mode, ColorMode::Indexed); + assert_eq!(doc.depth, SampleType::U8); + let table = doc.color_table.as_ref().unwrap(); + assert_eq!(table.colors, pal, "every entry, used or not"); + assert_eq!(table.transparent, Some(2)); + let s = doc.layers[0].surface().unwrap(); + let px = s.rgba(0, 0); + assert!((px[0] - 200.0 / 255.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6, "{px:?}"); + assert_eq!(s.rgba(0, 1)[3], 0.0, "the transparent entry is transparent"); + // Without a transparent entry, the table alone. + let doc = import("opaque.gif", &indexed_gif(&pal, [3, 3, 0, 1], None)).unwrap().document; + assert_eq!(doc.mode, ColorMode::Indexed); + assert_eq!(doc.color_table.as_ref().unwrap().transparent, None); +} + /// FITS header cards travel as the document's text metadata, so an astro frame keeps its /// OBJECT, EXPTIME and the rest through an edit. #[test] From c57947d6d83388eee10f68ccd0ffdee875f1af14 Mon Sep 17 00:00:00 2001 From: fjamesprice Date: Tue, 20 Oct 2026 00:44:33 -0400 Subject: [PATCH 53/69] test(camera-raw): settle() fails when the proxy worker's develop fails poll_render clears the pending develop before it reports the worker's error, so previewPending turned false with no new frame and settle() returned as if one had landed. It now fails with the status message when the dialog reported an error. With the worker forced to fail, all 21 scope tests passed before this change; now the one that settles after a worker develop fails with "the Camera Raw preview worker failed". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/camera_raw_scope.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/tests/camera_raw_scope.rs b/crates/ui-egui/tests/camera_raw_scope.rs index 7ff1236077..418d59c6ec 100644 --- a/crates/ui-egui/tests/camera_raw_scope.rs +++ b/crates/ui-egui/tests/camera_raw_scope.rs @@ -51,6 +51,9 @@ fn settle(h: &mut Harness<'_, PhotocraftApp>) { std::thread::sleep(std::time::Duration::from_millis(2)); h.run_steps(1); } + // A failed worker develop also clears `previewPending`, without a new frame. + let app = control::inspect(h.state(), &h.ctx); + assert_ne!(app["statusError"], true, "the Camera Raw preview worker failed: {}", app["status"]); // The frame that picked up the result also rebuilt the scope; let it go quiet. h.run_steps(2); } From 2eb68c78728855c37e82abe9318983ddf6d2027c Mon Sep 17 00:00:00 2001 From: fjamesprice Date: Tue, 20 Oct 2026 04:39:28 -0400 Subject: [PATCH 54/69] fix(camera-raw): a later preview frame clears a worker failure from the status bar A failed proxy develop on the worker set the status error, and nothing took it down once a later develop succeeded. The dialog now remembers the message and the preview revision it failed on, and clears the status when a newer frame is published, unless something else has reported since. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/camera_raw_ui.rs | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 6049f557fa..090c0a0b3c 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -72,6 +72,8 @@ pub struct CameraRawDialog { raw_curve: Option>>, /// The proxy develop running on a worker ([`Self::request_render`]). pending: Option>>, + /// The last worker failure the status bar shows, with the preview revision it failed on. + preview_error: Option<(String, u64)>, /// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100). undo: Vec, redo: Vec, @@ -753,6 +755,7 @@ fn open_pixels( raw_open: None, raw_curve: None, pending: None, + preview_error: None, undo: Vec::new(), redo: Vec::new(), edit_origin: None, @@ -1184,10 +1187,22 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { d.curve_rect = None; d.scope.vectorscope_rect = None; if let Err(e) = d.poll_render(ctx) { + d.preview_error = Some((e.clone(), d.preview_revision)); app.ui.status = e; app.ui.status_error = true; } d.request_render(ctx); + // A newer frame means the failure is over: take its message down, unless something else + // has reported since. + if let Some((e, revision)) = &d.preview_error + && *revision != d.preview_revision + { + if app.ui.status_error && app.ui.status == *e { + app.ui.status.clear(); + app.ui.status_error = false; + } + d.preview_error = None; + } let (full, _) = ui.allocate_exact_size(screen.size(), Sense::click()); let painter = ui.painter().clone(); painter.rect_filled(full, 0.0, t.chrome); @@ -1612,6 +1627,50 @@ mod tests { assert!(!app.ui.status_error, "{}", app.ui.status); } + /// A failed worker develop shows its error until a later frame is published, and never takes + /// down an error something else reported meanwhile. + #[test] + fn a_worker_failure_is_reported_until_the_next_frame() { + let (mut app, ctx) = worker_app(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro); + let frame = |app: &mut PhotocraftApp| { + ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())).textures_delta.clear(); + }; + let fail = |app: &mut PhotocraftApp| { + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(Err("Camera Raw preview failed".to_string())).unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.pending = Some(rx); + d.dirty = false; + }; + let edit = |app: &mut PhotocraftApp, exposure: f32| { + let d = app.camera_raw.as_mut().unwrap(); + d.params.exposure = exposure; + d.dirty = true; + }; + frame(&mut app); + fail(&mut app); + frame(&mut app); + assert!(app.ui.status_error && app.ui.status == "Camera Raw preview failed", "{}", app.ui.status); + // No new frame yet: the error stays. + frame(&mut app); + assert!(app.ui.status_error); + edit(&mut app, 0.5); + frame(&mut app); + finish_worker(app.camera_raw.as_mut().unwrap(), &ctx); + frame(&mut app); + assert!(!app.ui.status_error && app.ui.status.is_empty(), "{}", app.ui.status); + + fail(&mut app); + frame(&mut app); + app.ui.status = "something else failed".into(); + edit(&mut app, 1.0); + frame(&mut app); + finish_worker(app.camera_raw.as_mut().unwrap(), &ctx); + frame(&mut app); + assert!(app.ui.status_error && app.ui.status == "something else failed", "{}", app.ui.status); + } + #[test] fn smart_filter_double_click_edits_the_stored_camera_raw_filter_in_its_own_dialog() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); From 78cc36f24a2c78564ac2ec0a2e5f8fa9049b6fd4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:51:26 +0200 Subject: [PATCH 55/69] fix(psd): keep preserved TextIndex values when a type layer is added below them Text indices were assigned in one bottom-up pass, so a new layer took the next free number before the layers above it claimed the numbers their TySh keeps. Each of those was then renumbered onto its neighbour's Txt2 object and took its style (70 of 74 layers on the #7698 file after adding a layer at index 5). Kept numbers are now claimed first and new layers get numbers past them. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/psd_export.rs | 33 ++++++++++++++++++++------------- crates/io/tests/psd_kerning.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 64a5e7073a..dd6446148a 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1582,26 +1582,33 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c ex.used_ids = used; // Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the // number it already names — its `Txt2` object must sit at that slot for `apply_txt2` — - // and new layers get the next free number. + // and new layers get numbers past every kept one. Kept numbers are claimed first: a layer + // added below others must not take a number a layer above it keeps, or each of those + // would be renumbered onto its neighbour's object and take its style (#7773). let mut used_txt = std::collections::HashSet::new(); - let mut next_txt = 0i32; ex.text_index = Default::default(); + let mut fresh = Vec::new(); for (_, _, l) in &walk { let LayerContent::Text(t) = &l.content else { continue; }; - let kept = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)); - let index = match kept { - Some(i) if used_txt.insert(i) => i, - _ => { - while used_txt.contains(&next_txt) { - next_txt += 1; - } - used_txt.insert(next_txt); - next_txt + match t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)) { + Some(i) if used_txt.insert(i) => { + ex.text_index.insert(l.id, i); } - }; - ex.text_index.insert(l.id, index); + _ => fresh.push(l.id), + } + } + let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1); + if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 { + next_txt = 0; + } + for id in fresh { + while used_txt.contains(&next_txt) { + next_txt += 1; + } + used_txt.insert(next_txt); + ex.text_index.insert(id, next_txt); } } // Progress: one unit per layer record, then the merged composite (#3389). diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/psd_kerning.rs index b75a6542ae..99b8535f4c 100644 --- a/crates/io/tests/psd_kerning.rs +++ b/crates/io/tests/psd_kerning.rs @@ -144,6 +144,35 @@ fn duplicate_preserved_text_indices_get_distinct_txt2_objects() { } } +/// A type layer added below preserved ones takes a number past theirs: claiming the next free +/// number on the way up renumbered every layer above it onto its neighbour's `Txt2` object +/// (#7773, 70 of 74 layers on the #7698 file). +#[test] +fn new_text_layer_below_keeps_the_preserved_text_indices() { + let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + for (i, text) in ["AV", "BC"].into_iter().enumerate() { + let mut t = layer(text, &[(2, Kerning::Optical, 0.0)]); + photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); + let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None); + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, i as i32).unwrap())); + doc.layers.push(Layer::new(text, LayerContent::Text(t))); + } + let mut t = layer("new", &[(3, Kerning::Metrics, 0.0)]); + photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); + doc.layers.insert(0, Layer::new("new", LayerContent::Text(t))); + let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("k.psd", &bytes).unwrap().document; + let indices: Vec<_> = back + .layers + .iter() + .map(|l| { + let LayerContent::Text(t) = &l.content else { panic!("not text") }; + (l.name.as_str(), t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d))) + }) + .collect(); + assert_eq!(indices, [("new", Some(2)), ("AV", Some(0)), ("BC", Some(1))]); +} + /// A file-controlled `TextIndex` must not size the save: `i32::MAX` is ignored (the regenerated /// `Txt2` is sized by real text objects, never by a file's numbers) and the document still saves /// and reopens with its text. From 8fc62f55ab8023765e9299f85319262a978698f3 Mon Sep 17 00:00:00 2001 From: Anim86 Date: Thu, 15 Oct 2026 17:45:36 +0200 Subject: [PATCH 56/69] fix(tablet): support stylus barrel button and brush resize on Windows (#5905, #5364) On Windows with Windows Ink (WM_POINTER), winit drops penFlags (PEN_FLAG_BARREL) and reports stylus contact as WindowEvent::Touch, which egui-winit maps exclusively to PointerButton::Primary. Additionally, Windows Ink does not update GetAsyncKeyState(VK_RBUTTON) when pressing the pen barrel button. Consequently, Alt + Barrel Button drag was recognized as Alt + Left Drag (triggering the eyedropper and painting strokes instead of brush resize), and right-clicks with the stylus were treated as left-clicks. This commit resolves the issue by: 1. Adding a native Windows message hook (WH_GETMESSAGE) in crates/tablet/src/windows.rs calling GetPointerPenInfo to intercept PEN_FLAG_BARREL (0x00000001) and POINTER_FLAG_SECONDBUTTON. 2. Exposing ight_button_down in Services combining the pointer hook and GetAsyncKeyState(VK_RBUTTON). 3. Updating canvas.rs and paint_mouse.rs to prevent strokes_live and alt_eyedropper from hijacking the stylus gesture when the barrel button is held, and correctly routing resize events. 4. Adding tests asserting that Alt + stylus barrel drag resizes diameter horizontally and adjusts hardness vertically without triggering eyedropper or painting. --- AGENTS.md | 4 +- apps/photocraft/src/main.rs | 13 +- apps/photocraft/src/tablet.rs | 36 +++++- crates/tablet/Cargo.toml | 7 +- crates/tablet/src/lib.rs | 12 +- crates/tablet/src/windows.rs | 195 +++++++++++++++++++++++++++++ crates/ui-egui/src/brush_resize.rs | 29 +++++ crates/ui-egui/src/canvas.rs | 12 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/paint_mouse.rs | 25 ++-- 10 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 crates/tablet/src/windows.rs diff --git a/AGENTS.md b/AGENTS.md index 97b5ccdbc0..a003c661ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written crates/ geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles) psd codecs L0 standalone format crates (no workspace deps; publishable) - tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate + tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data) ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format @@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`. - **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`. -- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else. +- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else. - **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input. - **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic). - **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s. diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index b0b00a865c..11a4190dcf 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -71,8 +71,7 @@ mod services; // macOS gets one instance from LaunchServices (open-documents Apple events). #[cfg(any(not(target_os = "macos"), test))] mod single_instance; -// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener. -#[cfg(any(target_os = "macos", target_os = "linux", test))] +// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button). mod tablet; mod ui_state; mod window_frame; @@ -627,6 +626,11 @@ fn main() -> eframe::Result { let tablet_monitor = std::cell::OnceCell::new(); #[cfg(target_os = "macos")] let tablet_monitor = &tablet_monitor; + // The Windows pointer message hook; dropping it unhooks when the event loop returns. + #[cfg(target_os = "windows")] + let tablet_monitor_win = std::cell::OnceCell::new(); + #[cfg(target_os = "windows")] + let tablet_monitor_win = &tablet_monitor_win; // Read the displays' ICC profiles while the window opens (colour-managed canvas; `None` // where the platform has no reader). @@ -853,8 +857,13 @@ fn main() -> eframe::Result { if let Some(monitor) = tablet::install_macos(&app.stylus.feed) { let _ = tablet_monitor.set(monitor); } + #[cfg(target_os = "windows")] + if let Some(monitor) = tablet::install_windows() { + let _ = tablet_monitor_win.set(monitor); + } #[cfg(target_os = "linux")] tablet::spawn_x11(&app.stylus.feed, display); + app.services.right_button_down = tablet::right_button_service(); // Paths on the command line (Linux/Windows file associations, `photocraft a.psd`). app.open_paths(&files); if !unreadable_paths.is_empty() { diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index fc063d2fbc..bd9507f00b 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -1,10 +1,12 @@ -//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79). +//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and +//! barrel button on Windows (#5905). //! -//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit -//! local event monitor; XInput2 raw events on a second X connection) and this module writes each -//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's -//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards -//! `WM_POINTER` pressure as touch force. +//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit +//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes +//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's +//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure +//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel +//! button through a thread-local message hook beside winit. //! //! Wayland compositors give a pen only to clients that bind the tablet protocol //! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs @@ -19,15 +21,37 @@ use photocraft_tablet::Sample; use photocraft_ui_egui::stylus::{PenSample, StylusFeed}; /// A tablet sample as the UI's pen sample. +#[allow(dead_code)] pub fn pen_sample(s: Sample) -> PenSample { PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized() } /// Writes tablet samples into `feed` (`None` = a mouse). +#[allow(dead_code)] pub fn sink(feed: StylusFeed) -> impl Fn(Option) + Send + 'static { move |s| feed.set(s.map(pen_sample)) } +/// Install the Windows pointer message hook (main thread; keep the result until the event loop +/// returns). On failure this logs why: the pen barrel button is then missed. +#[cfg(target_os = "windows")] +pub fn install_windows() -> Option { + photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok() +} + +/// Physical right mouse button or stylus barrel button down check (Windows). +#[cfg(target_os = "windows")] +pub fn right_button_service() -> Option { + Some(Box::new(|| { + photocraft_tablet::windows::is_barrel_button_down() || winsafe::GetAsyncKeyState(winsafe::co::VK::RBUTTON) + })) +} + +#[cfg(not(target_os = "windows"))] +pub fn right_button_service() -> Option { + None +} + /// Install the AppKit tablet monitor (main thread, once winit created the shared application: /// from eframe's app creation, not before the event loop). Keep the result until the event loop /// returns. On failure this logs why: pens then paint like a mouse. diff --git a/crates/tablet/Cargo.toml b/crates/tablet/Cargo.toml index d4602ec898..8f6ad08fca 100644 --- a/crates/tablet/Cargo.toml +++ b/crates/tablet/Cargo.toml @@ -3,11 +3,12 @@ name = "photocraft-tablet" version.workspace = true edition.workspace = true license.workspace = true -description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2)" +description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2), and barrel button on Windows" # The one isolated `unsafe` helper crate (craftrules never-crash, AGENTS.md): the AppKit event -# monitor needs Objective-C interop. Only `src/macos.rs` allows `unsafe`; everything else is -# denied, and every other crate keeps the workspace's `unsafe_code = "forbid"`. +# monitor on macOS and the WH_GETMESSAGE pointer hook on Windows need platform interop. Only +# `src/macos.rs` and `src/windows.rs` allow `unsafe`; everything else is denied, and every other +# crate keeps the workspace's `unsafe_code = "forbid"`. [lints.rust] unsafe_code = "deny" diff --git a/crates/tablet/src/lib.rs b/crates/tablet/src/lib.rs index 16214be149..06a5c92bfa 100644 --- a/crates/tablet/src/lib.rs +++ b/crates/tablet/src/lib.rs @@ -4,9 +4,13 @@ //! - **macOS** ([`macos::Monitor`]): an AppKit local event monitor reads the tablet fields of //! each `NSEvent` (`pressure`, `tilt`, `rotation`, `subtype`, and `pointingDeviceType` from //! proximity events) before winit sees the event, so every pointer event the UI handles already -//! has its pen sample. AppKit's block-based monitor API needs Objective-C interop, so -//! `macos.rs` is the only module in the workspace that allows `unsafe` (craftrules -//! never-crash: an isolated, tested helper crate). +//! has its pen sample. +//! - **Windows** ([`windows::Monitor`]): a thread-local message hook inspects `WM_POINTER*` +//! messages on the UI thread's message queue to read `PEN_FLAG_BARREL` (`GetPointerPenInfo`), +//! which winit 0.30 drops by mapping pen inputs to touch events. +//! AppKit's monitor API and Windows' thread hook need OS interop, so `macos.rs` and `windows.rs` +//! allow `unsafe` (craftrules never-crash: an isolated, tested helper crate with `SAFETY:` +//! comments on every block). //! - **Linux X11** ([`x11::spawn`]): a second, pure-Rust X connection (x11rb) selects XInput2 //! raw events on the root window and maps the tablet device's valuators ("Abs Pressure", //! "Abs Tilt X/Y", "Abs Rotary Z") to samples. Raw events reach every client that asks, also @@ -33,6 +37,8 @@ pub mod xi; pub mod macos; #[cfg(target_os = "linux")] pub mod x11; +#[cfg(target_os = "windows")] +pub mod windows; /// One pen reading. Tilt is in degrees (-90..90, W3C Pointer Events convention: +x tilts to the /// right, +y towards the user), rotation in degrees 0..360 (clockwise). diff --git a/crates/tablet/src/windows.rs b/crates/tablet/src/windows.rs new file mode 100644 index 0000000000..eb5b2b28c2 --- /dev/null +++ b/crates/tablet/src/windows.rs @@ -0,0 +1,195 @@ +//! Windows pointer hook for tablet data dropped by winit (such as the pen barrel button). +//! +//! winit 0.30 drops `POINTER_PEN_INFO::penFlags` (`PEN_FLAG_BARREL`) on Windows and turns pen +//! inputs into `WindowEvent::Touch`, mapping them to `PointerButton::Primary`. Windows Ink also +//! does not update `GetAsyncKeyState(VK_RBUTTON)` for stylus barrel buttons. This thread-local +//! `WH_GETMESSAGE` hook inspects `WM_POINTER*` messages on the UI thread's message queue to +//! track the physical barrel button state. + +#![allow(unsafe_code, clippy::upper_case_acronyms)] + +use std::sync::atomic::{AtomicBool, Ordering}; + +static BARREL_DOWN: AtomicBool = AtomicBool::new(false); + +#[repr(C)] +struct POINT { + x: i32, + y: i32, +} + +#[repr(C)] +struct MSG { + hwnd: *mut std::ffi::c_void, + message: u32, + w_param: usize, + l_param: isize, + time: u32, + pt: POINT, +} + +#[repr(C)] +struct POINTER_INFO { + pointer_type: u32, + pointer_id: u32, + frame_id: u32, + pointer_flags: u32, + source_device: *mut std::ffi::c_void, + hwnd_target: *mut std::ffi::c_void, + pt_pixel_location: POINT, + pt_himetric_location: POINT, + pt_pixel_location_raw: POINT, + pt_himetric_location_raw: POINT, + dw_time: u32, + history_count: u32, + input_data: i32, + dw_key_states: u32, + performance_count: u64, + button_change_type: i32, +} + +#[repr(C)] +struct POINTER_PEN_INFO { + pointer_info: POINTER_INFO, + pen_flags: u32, + pen_mask: u32, + pressure: u32, + rotation: u32, + tilt_x: i32, + tilt_y: i32, +} + +#[link(name = "user32")] +unsafe extern "system" { + fn SetWindowsHookExW( + id_hook: i32, + lpfn: unsafe extern "system" fn(i32, usize, isize) -> isize, + h_mod: *mut std::ffi::c_void, + dw_thread_id: u32, + ) -> *mut std::ffi::c_void; + fn UnhookWindowsHookEx(hhk: *mut std::ffi::c_void) -> i32; + fn CallNextHookEx(hhk: *mut std::ffi::c_void, n_code: i32, w_param: usize, l_param: isize) -> isize; + fn GetCurrentThreadId() -> u32; + fn GetPointerPenInfo(pointer_id: u32, pen_info: *mut POINTER_PEN_INFO) -> i32; +} + +const WH_GETMESSAGE: i32 = 3; +const WM_POINTERUPDATE: u32 = 0x0245; +const WM_POINTERDOWN: u32 = 0x0246; +const WM_POINTERUP: u32 = 0x0247; +const WM_POINTERLEAVE: u32 = 0x024A; + +/// `PEN_FLAG_BARREL` (winuser.h): the barrel button of the pen is pressed. +pub const PEN_FLAG_BARREL: u32 = 0x00000001; + +/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (winuser.h): the secondary action button is pressed. +pub const POINTER_FLAG_SECONDBUTTON: u32 = 0x00000020; + +/// Pure decode of whether the stylus barrel button is pressed. +/// +/// Plain tip contact sets `POINTER_FLAG_INCONTACT` (`0x0004`) and optionally `POINTER_FLAG_FIRSTBUTTON` +/// (`0x0010`), which must never be confused with the barrel button (`POINTER_FLAG_SECONDBUTTON`, `0x0020`). +pub fn is_barrel_pressed(pen_flags: Option, pointer_message_flags: u32) -> bool { + let second_button = (pointer_message_flags & POINTER_FLAG_SECONDBUTTON) != 0; + match pen_flags { + Some(pen) => (pen & PEN_FLAG_BARREL) != 0 || second_button, + None => second_button, + } +} + +unsafe extern "system" fn get_msg_proc(code: i32, w_param: usize, l_param: isize) -> isize { + if code >= 0 && l_param != 0 { + // SAFETY: Win32 guarantees that for code >= 0, l_param points to a valid MSG struct + // dispatched to this thread's message queue. + let msg = unsafe { &*(l_param as *const MSG) }; + match msg.message { + WM_POINTERDOWN | WM_POINTERUPDATE => { + let pointer_id = (msg.w_param & 0xFFFF) as u32; + let pointer_message_flags = (msg.w_param >> 16) as u32; + // SAFETY: `pen_info` is a stack-allocated buffer initialized to zero, and `pointer_id` + // is the pointer identifier from the valid WM_POINTER message. + let pen_flags = unsafe { + let mut info: POINTER_PEN_INFO = std::mem::zeroed(); + (GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info.pen_flags) + }; + let is_barrel = is_barrel_pressed(pen_flags, pointer_message_flags); + BARREL_DOWN.store(is_barrel, Ordering::Relaxed); + } + WM_POINTERUP | WM_POINTERLEAVE => { + BARREL_DOWN.store(false, Ordering::Relaxed); + } + _ => {} + } + } + // SAFETY: CallNextHookEx passes the parameters down the hook chain. MSDN notes the first + // parameter is ignored and can be null. + unsafe { CallNextHookEx(std::ptr::null_mut(), code, w_param, l_param) } +} + +/// Keeps the thread-local pointer message hook installed; dropping it removes the hook. +/// Main-thread only (not `Send`). +pub struct Monitor { + hook: *mut std::ffi::c_void, +} + +impl Monitor { + /// Installs the thread-local pointer message hook on the UI thread. + pub fn install() -> Result { + // SAFETY: GetCurrentThreadId has no preconditions. SetWindowsHookExW installs + // a thread-specific WH_GETMESSAGE hook on the calling thread's message queue + // using our extern "system" callback. + let hook = unsafe { + let thread_id = GetCurrentThreadId(); + SetWindowsHookExW(WH_GETMESSAGE, get_msg_proc, std::ptr::null_mut(), thread_id) + }; + if hook.is_null() { + return Err(crate::Error::Platform("SetWindowsHookExW returned null".into())); + } + Ok(Self { hook }) + } +} + +impl Drop for Monitor { + fn drop(&mut self) { + // SAFETY: `self.hook` is the non-null handle returned by `SetWindowsHookExW`, + // unhooked exactly once here on the thread that created it. + unsafe { + UnhookWindowsHookEx(self.hook); + } + BARREL_DOWN.store(false, Ordering::Relaxed); + } +} + +/// Returns true if the stylus barrel button is physically pressed. +pub fn is_barrel_button_down() -> bool { + BARREL_DOWN.load(Ordering::Relaxed) +} + +#[cfg(test)] +mod tests { + use super::*; + + const POINTER_FLAG_INCONTACT: u32 = 0x0004; + const POINTER_FLAG_FIRSTBUTTON: u32 = 0x0010; + + #[test] + fn plain_pen_contact_is_not_treated_as_barrel() { + // Plain tip touch on digitizer: INCONTACT is set, first button may be set, no barrel flag. + assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT)); + assert!(!is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_FIRSTBUTTON)); + assert!(!is_barrel_pressed(None, POINTER_FLAG_INCONTACT)); + } + + #[test] + fn pen_barrel_flag_is_detected() { + assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), 0)); + assert!(is_barrel_pressed(Some(PEN_FLAG_BARREL), POINTER_FLAG_INCONTACT)); + } + + #[test] + fn pointer_message_second_button_is_detected() { + assert!(is_barrel_pressed(None, POINTER_FLAG_SECONDBUTTON)); + assert!(is_barrel_pressed(Some(0), POINTER_FLAG_SECONDBUTTON)); + assert!(is_barrel_pressed(Some(0), POINTER_FLAG_INCONTACT | POINTER_FLAG_SECONDBUTTON)); + } +} diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index dfba8e5166..7d41aaa1ba 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -535,4 +535,33 @@ mod tests { app.session.prefs.edit(|p| p.cursors.brush_preview_color = "nonsense".into()); assert_eq!(preview_tint(&app), Color32::from_rgba_unmultiplied(255, 0, 0, 110), "unreadable values keep the red"); } + + #[test] + fn stylus_barrel_button_resizes_with_alt_and_never_samples_color() { + let mut a = app(Tool::Brush); + a.services.right_button_down = Some(Box::new(|| true)); + a.sync_views(); + let mut h = canvas(a); + let size0 = h.state().session.tools.brush.size; + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, PointerButton::Primary, true, Modifiers::ALT); + let mut p = c; + for _ in 0..6 { + p += vec2(10.0, 8.0); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + let r = h.state().brush_resize.expect("resizing via stylus barrel button"); + assert!(r.secondary); + assert!(h.state().session.tools.brush.size > size0, "right grows the brush"); + assert!(h.state().session.tools.brush.hardness > 0.2, "down hardens the brush"); + assert!(!h.state().alt_sampling, "eyedropper never triggered"); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"), "nothing painted"); + button(&mut h, p, PointerButton::Primary, false, Modifiers::ALT); + h.run_steps(2); + assert!(h.state().brush_resize.is_none()); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 184e9ccce3..46188458c2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3735,7 +3735,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Live painting tools start on the press, not once the pointer passes egui's click // distance: the first dab shows at once. The drag recognised later continues that stroke, // and a click (or a long press that never moved) just ends it. + let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); + let right_down = ui.input(|i| i.pointer.secondary_down()) || physical_right; if strokes_live(tool) + && !right_down && app.drag.is_none() && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) @@ -3785,7 +3788,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }; let press_this_frame = events.iter().any(|e| matches!(e, egui::Event::PointerButton { button: b, pressed: true, .. } if *b == button)); let down_at_start = !press_this_frame && (gesture_active_before || buttons.started); - let mut positions = if freehand_tool(tool) { + let mut positions = if app.brush_resize.is_some() { + response.interact_pointer_pos().into_iter().collect() + } else if freehand_tool(tool) { pointer_moves(&events, button, down_at_start) } else if buttons.dragged { response.interact_pointer_pos().into_iter().collect() @@ -4099,7 +4104,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None, // ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too, // unless Preferences › Cursors › Other Cursors asks for the precise crosshair. - t if app.alt_sampling || (app.drag.is_none() && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => { + t if app.alt_sampling || (app.drag.is_none() && !app.services.right_button_down.as_ref().is_some_and(|f| f()) && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => { // While it samples (button held), the comparison ring replaces the cursor (#213). if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) { icon @@ -5183,7 +5188,8 @@ pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorI /// painting and sampling. fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { if matches!(ev, ToolEvent::Down { .. }) { - app.alt_sampling = alt_samples(app.active_tool(), mods); + let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); + app.alt_sampling = !physical_right && alt_samples(app.active_tool(), mods); } if !app.alt_sampling { return false; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 89f293a2cb..81c5251b5e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -454,6 +454,8 @@ pub type CapsLockFn = Box bool>; pub type WindowFrameFn = Box; /// The caption buttons' current style ([`Services::caption_style`]). pub type CaptionStyleFn = Box CaptionStyle>; +/// Physical right mouse button or stylus barrel button down (Windows; read for brush resize). +pub type RightButtonDownFn = Box bool + Send + Sync>; /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. @@ -550,6 +552,8 @@ pub struct Services { /// style and button layout). Read every frame, with the context to wake when it changes; /// `None` keeps Windows' buttons at the right end. pub caption_style: Option, + /// Physical right mouse button or pen barrel button currently down. + pub right_button_down: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only unless the shell attached a store before startup. diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index a45c016c59..6a19c9ff77 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -100,21 +100,32 @@ pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool { /// brush with Alt held (#297), erases (Erase preference) or opens the Brush Preset picker. Arms /// `secondary_erase` or `brush_resize_armed` for this frame's `Down`. pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) -> Buttons { - let (mods, right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down())); + let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); + let (mods, mut right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down())); + right_down |= physical_right; + let right_started = response.drag_started_by(PointerButton::Secondary) || (physical_right && response.drag_started()); + let right_dragged = response.dragged_by(PointerButton::Secondary) || (physical_right && response.dragged()); + let resizing = app.brush_resize.is_some_and(|r| r.secondary); + let right_stopped = response.drag_stopped_by(PointerButton::Secondary) || (resizing && response.drag_stopped()); + + if physical_right && crate::brush_resize::is_right_gesture(crate::workspace_ui::sticky_mods(app, mods)) { + app.alt_sampling = false; + } + // Alt+right-drag resizes the brush (brush_resize.rs); its events reach `tool_event` like a // left drag's, and nothing paints. A resize whose release was missed ends here. - crate::brush_resize::release_stale(app, right_down || response.drag_stopped_by(PointerButton::Secondary)); + crate::brush_resize::release_stale(app, right_down || response.drag_stopped_by(PointerButton::Secondary) || response.drag_stopped()); let resize_start = crate::brush_resize::applies(tool) && app.drag.is_none() && crate::brush_resize::is_right_gesture(crate::workspace_ui::sticky_mods(app, mods)) - && response.drag_started_by(PointerButton::Secondary); + && (right_started || (physical_right && response.dragged() && app.brush_resize.is_none())); let resizing = app.brush_resize.is_some_and(|r| r.secondary); - app.brush_resize_armed = resize_start; + app.brush_resize_armed = resize_start || (physical_right && resizing); // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307). let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods); let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu; let right_stroke = erase && app.drag.is_some(); - let right_start = erase && response.drag_started_by(PointerButton::Secondary); + let right_start = erase && right_started; let right_click = response.secondary_clicked(); if right_click && !erase @@ -129,8 +140,8 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) let right_drag = right_stroke || resizing; Buttons { started: response.drag_started_by(PointerButton::Primary) || right_start || resize_start, - dragged: response.dragged_by(PointerButton::Primary) || (right_drag && response.dragged_by(PointerButton::Secondary)), - stopped: response.drag_stopped_by(PointerButton::Primary) || (right_drag && response.drag_stopped_by(PointerButton::Secondary)), + dragged: response.dragged_by(PointerButton::Primary) || (right_drag && right_dragged), + stopped: response.drag_stopped_by(PointerButton::Primary) || (right_drag && right_stopped), clicked: response.clicked() || erase_click, } } From 304f055021f3f41cbf00c71343b5a1453e877e8a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:04:33 +0200 Subject: [PATCH 57/69] fix(psd): point a new Txt2 object's frame strike at its own frame new_object copied the template's rendered strikes, whose frame strike (/99 /F) still named the template's frame in /10. It now names the new object's frame, like the view's /0. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 13 ++++++++++++- crates/text/src/tests.rs | 9 +++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 2caddb0c6e..3221f9c80d 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -1078,9 +1078,20 @@ fn names_one_frame(object: &E) -> bool { /// 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 frame = template.path(&["1", "0"]).and_then(E::as_array).and_then(|f| f.first()).and_then(|f| f.get("0")).cloned(); + let at = i64::try_from(at).ok()?; 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()?))])])); + view.set("0", E::Array(vec![E::Dict(vec![("0".into(), E::Int(at))])])); + // The rendered strikes' frame strike (`/F`) names the template's frame too. + if let Some(E::Array(strikes)) = view.get_mut("2") { + for s in strikes.iter_mut() { + let Some(E::Array(parts)) = s.get_mut("6") else { continue }; + for p in parts.iter_mut().filter(|p| p.get("99").and_then(E::as_str) == Some("F") && p.get("10") == frame.as_ref()) { + p.set("10", E::Int(at)); + } + } + } 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)); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1015107843..95d8902e0b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1155,7 +1155,7 @@ 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 >> >>", + "\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 /6 [ << /99 /F /10 0 >> ] >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>", run(3), run(3), run(6) @@ -1180,6 +1180,11 @@ fn new_text_layer_gets_a_txt2_object_and_frame() { 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()); + assert_eq!( + object.path(&["1", "2"]).and_then(E::as_array).and_then(|s| s[0].get("6")).and_then(E::as_array).and_then(|f| f[0].get("10")), + Some(&E::Int(1)), + "the frame strike names its own frame" + ); 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]); @@ -1205,7 +1210,7 @@ fn new_text_layer_gets_a_txt2_object_and_frame() { // 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 = prev.iter().map(|&b| b as char).collect::().replace("/2 [ << /99 /PC", &format!("/9 [ {filler}] /2 [ << /99 /PC")); 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]; From 472a8d6f29845f6b7dc7187ec8c942477627ae95 Mon Sep 17 00:00:00 2001 From: ha1des Date: Tue, 20 Oct 2026 14:18:00 +0200 Subject: [PATCH 58/69] Preserve near-black ICC colors on the GPU canvas --- contributors/people.toml | 3 + crates/cms/README.md | 7 + crates/cms/src/gpu_matrix_shaper.rs | 131 ++++++++++ crates/cms/src/lib.rs | 3 + crates/cms/src/transform.rs | 31 ++- crates/cms/tests/gpu_matrix_shaper.rs | 197 +++++++++++++++ crates/engine/src/color_cmds.rs | 53 ++++ crates/engine/src/display_color.rs | 2 +- crates/ui-egui/src/canvas.rs | 13 +- crates/ui-egui/src/gpu_canvas.rs | 129 +++++++++- crates/ui-egui/tests/display_shadows.rs | 305 ++++++++++++++++++++++++ docs/architecture.md | 7 +- 12 files changed, 860 insertions(+), 21 deletions(-) create mode 100644 crates/cms/src/gpu_matrix_shaper.rs create mode 100644 crates/cms/tests/gpu_matrix_shaper.rs create mode 100644 crates/ui-egui/tests/display_shadows.rs diff --git a/contributors/people.toml b/contributors/people.toml index bc3533777b..e7f6ccbb17 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -181,3 +181,6 @@ sync_github_name = true [people.00xJS] sync_github_name = true + +[people.ha1des] +sync_github_name = true diff --git a/crates/cms/README.md b/crates/cms/README.md index c48d5366fd..70b4ab3c9c 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou * `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain. * `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning). * `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture. +* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for + display shaders. Float tables retain near-black detail; unsupported chains return `None` + for the 3D LUT fallback. No GPU dependency is required by this crate. * `transform::cached(src, dst, opts)`: process-wide transform cache. * `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding. * `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of @@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon (native only). +* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus + offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated + separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB + 33³ table cannot resolve steep inverse gamma curves near black, even with float texels. * Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero, negative or non-finite white is refused with `CmsError::Invalid`); BPC uses diff --git a/crates/cms/src/gpu_matrix_shaper.rs b/crates/cms/src/gpu_matrix_shaper.rs new file mode 100644 index 0000000000..09631bf98d --- /dev/null +++ b/crates/cms/src/gpu_matrix_shaper.rs @@ -0,0 +1,131 @@ +//! Separable RGB display transforms without a coarse 3D interpolation near black. + +/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display +/// transform. Values are floats independent of the document's pixel depth. +/// +/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply +/// [`Self::matrix_rows`] to that result, then sample the output table at +/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel +/// separately between its two adjacent table entries. These nonuniform output samples +/// retain shadow detail where a pure gamma curve's inverse has an infinite slope. +/// +/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels. +/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable. +/// This representation covers SDR display output; input and output are clamped to `[0, 1]`. +/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the +/// [`crate::Lut3d`] fallback. +#[derive(Clone, Debug, PartialEq)] +pub struct GpuMatrixShaper { + matrix: [[f32; 4]; 3], + input: Vec<[f32; 4]>, + output: Vec<[f32; 4]>, +} + +impl GpuMatrixShaper { + /// The input table has one more entry than this number of equal intervals. + pub const INPUT_INTERVALS: usize = 4096; + /// Output entries are spaced uniformly in the fourth root of the linear value. + pub const OUTPUT_ENTRIES: usize = 4096; + + /// Row-major matrix: each row contains three RGB coefficients followed by its offset. + pub fn matrix_rows(&self) -> [[f32; 4]; 3] { + self.matrix + } + + /// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1. + pub fn input_table(&self) -> &[[f32; 4]] { + &self.input + } + + /// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1. + pub fn output_table(&self) -> &[[f32; 4]] { + &self.output + } + + pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec], output: Option<&[Vec]>) -> Option { + if !matrix.iter().flatten().all(|v| v.is_finite()) { + return None; + } + let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?; + let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?; + let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max)); + for row in matrix { + let magnitude = + row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::() + f64::from(row[3]).abs(); + if magnitude > f64::from(f32::MAX) { + return None; + } + } + Some(Self { matrix, input, output }) + } +} + +fn pack_table(tables: Option<&[Vec]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option> { + if let Some(tables) = tables + && (tables.len() != 3 || tables.iter().any(|t| t.len() != entries)) + { + return None; + } + let mut packed: Vec<[f32; 4]> = Vec::new(); + // Both lengths are fixed constants, but a failed allocation still means the caller + // should retain its existing fallback rather than abort while opening a document. + packed.try_reserve_exact(entries).ok()?; + for i in 0..entries { + let rgb = match tables { + Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?], + None => [identity(i as f32 / (entries - 1) as f32); 3], + }; + if !rgb.iter().all(|v| v.is_finite()) { + return None; + } + if let Some(previous) = packed.last() + && rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite()) + { + return None; + } + packed.push([rgb[0], rgb[1], rgb[2], 1.0]); + } + Some(packed) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + + #[test] + fn rejects_invalid_tables_and_nonfinite_values() { + let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3]; + let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3]; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none()); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none()); + let mut invalid = inputs.clone(); + invalid[0].pop(); + assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none()); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut invalid = inputs.clone(); + invalid[1][123] = v; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none()); + let mut invalid = outputs.clone(); + invalid[2][321] = v; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none()); + let mut invalid = IDENTITY; + invalid[1][3] = v; + assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none()); + } + } + + #[test] + fn rejects_finite_data_that_overflows_shader_arithmetic() { + let mut matrix = IDENTITY; + matrix[0] = [f32::MAX; 4]; + assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none()); + let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3]; + inputs[0][100] = -f32::MAX; + inputs[0][101] = f32::MAX; + assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none()); + } +} diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs index f93b00372b..259d51bf77 100644 --- a/crates/cms/src/lib.rs +++ b/crates/cms/src/lib.rs @@ -8,6 +8,7 @@ //! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible, //! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile. //! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas. +//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms. //! //! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, //! CIE 15, and Adobe's published black point compensation paper. This crate depends on no @@ -19,6 +20,7 @@ pub mod builtin; pub mod clut; pub mod curve; pub mod gamut; +mod gpu_matrix_shaper; pub mod lut3d; pub mod lutfile; pub mod math; @@ -32,6 +34,7 @@ pub use builtin::Builtin; pub use clut::Clut; pub use curve::Curve; pub use gamut::GamutCheck; +pub use gpu_matrix_shaper::GpuMatrixShaper; pub use lut3d::Lut3d; pub use profile::{ColorSpace, Pcs, Profile, ProfileClass}; pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached}; diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index d262956a78..04d11247f4 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -9,7 +9,7 @@ use crate::curve::Curve; use crate::math; use crate::pipeline::{Pipeline, Stage}; use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass}; -use crate::{CmsError, Intent}; +use crate::{CmsError, GpuMatrixShaper, Intent}; /// Sample storage of a byte buffer for [`Transform::convert_bytes_many`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -39,9 +39,9 @@ impl Default for TransformOptions { /// Grid sizes of the device-link tables built for integer (and fast float) evaluation. pub const GRID_3D: usize = 33; pub const GRID_4D: usize = 17; -const TABLE_1D: usize = 4096; +const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS; /// Resolution of output shaper tables (indexed by `x^(1/4)`). -const OUT_TABLE: usize = 4096; +const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES; /// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ. const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287]; @@ -226,6 +226,31 @@ impl Transform { &self.pipeline } + /// A separable RGB transform for accurate GPU display conversion, when available. + /// + /// Reuses the input/matrix/output acceleration of the integer conversion path instead + /// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other + /// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers + /// can keep their existing display-LUT fallback. This is an SDR representation; see + /// [`GpuMatrixShaper`] for the precise sampling and clamping contract. + pub fn gpu_matrix_shaper(&self) -> Option { + if self.inputs != 3 || self.outputs != 3 { + return None; + } + let Core::Shaper { curves, matrix, .. } = &self.core else { return None }; + let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + if let Some((nr, nc, coefficients, offsets)) = matrix { + if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 { + return None; + } + for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) { + let [r, g, b] = coefficients; + *row = [*r, *g, *b, *offset]; + } + } + GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice())) + } + /// Exact evaluation of one colour (`inputs` → `outputs` normalised values). #[inline] pub fn eval(&self, input: &[f32], out: &mut [f32]) { diff --git a/crates/cms/tests/gpu_matrix_shaper.rs b/crates/cms/tests/gpu_matrix_shaper.rs new file mode 100644 index 0000000000..20f05e8aa1 --- /dev/null +++ b/crates/cms/tests/gpu_matrix_shaper.rs @@ -0,0 +1,197 @@ +//! Independent ICC matrix/TRC reference for display shadows. +//! +//! Fixtures are generated here from the published sRGB equation and a synthetic monitor, +//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3 +//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math: +//! https://www.color.org/specification/ICC1v43_2010-12.pdf + +use photocraft_cms::pipeline::{Pipeline, Stage}; +use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions}; + +const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]]; +// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile. +const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]]; +const GAMMA: f64 = 2.2; +const SOURCE_ENTRIES: usize = 1024; +const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0; + +fn fixed16(x: f64) -> f64 { + (x * 65536.0).round() / 65536.0 +} + +fn srgb_sample(i: usize) -> f64 { + let x = i as f64 / (SOURCE_ENTRIES - 1) as f64; + let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) }; + (linear * 65535.0).round() / 65535.0 +} + +fn synthetic_profiles() -> (Profile, Profile) { + let mut source = Builtin::Srgb.profile().clone(); + source.description = "Synthetic sampled sRGB for display regression".into(); + source.matrix = Some(SOURCE_MATRIX); + source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect()))); + let source = source.with_encoded_bytes(); + let mut monitor = Builtin::Srgb.profile().clone(); + monitor.description = "Synthetic gamma 2.2 display for regression".into(); + monitor.matrix = Some(MONITOR_MATRIX); + monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] })); + let monitor = monitor.with_encoded_bytes(); + // Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values. + (Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap()) +} + +fn synthetic_transform(bpc: bool) -> Transform { + let (source, monitor) = synthetic_profiles(); + Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap() +} + +fn reference(rgb: [f32; 3]) -> [f64; 3] { + let linear = rgb.map(|x| { + let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64; + let i = (p as usize).min(SOURCE_ENTRIES - 2); + srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64) + }); + let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::()); + // Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production + // matrix inverse/multiplication implementation. + let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]); + for pivot in 0..3 { + let divisor = a[pivot][pivot]; + for value in &mut a[pivot][pivot..] { + *value /= divisor; + } + let normalized = a[pivot]; + for (r, row) in a.iter_mut().enumerate() { + if r != pivot { + let factor = row[pivot]; + for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) { + *value -= factor * coefficient; + } + } + } + } + std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA))) +} + +/// CPU interpretation of the public GPU data layout; expected colours come only from +/// `reference`, so this tests the sampled representation, not Transform against itself. +fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] { + fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 { + let i = (coordinate as usize).min(table.len() - 2); + let f = coordinate - i as f32; + table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f + } + let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32)); + let rows = gpu.matrix_rows(); + std::array::from_fn(|c| { + let row = rows[c]; + let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3]; + let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32; + interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0) + }) +} + +fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) { + let actual = sample_gpu(gpu, rgb); + let expected = reference(rgb); + for c in 0..3 { + assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}"); + } +} + +#[test] +fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() { + for bpc in [false, true] { + let transform = synthetic_transform(bpc); + let gpu = transform.gpu_matrix_shaper().unwrap(); + assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1); + assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES); + assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0)); + for v in 0..=255 { + check_reference(&gpu, [v as f32 / 255.0; 3]); + } + // Includes coloured shadows within the old LUT's very first 0..8/255 cell. + for r in 0..=16 { + for g in 0..=16 { + for b in 0..=16 { + check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]); + } + } + } + for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] { + check_reference(&gpu, rgb); + } + } +} + +#[test] +fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() { + let transform = synthetic_transform(false); + let gpu = transform.gpu_matrix_shaper().unwrap(); + let coarse = Lut3d::from_transform(&transform, 33); + let rgb = [2.0 / 255.0; 3]; + let expected = reference(rgb); + let old = coarse.sample(rgb); + // Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This + // guards the cause of the original bug rather than a particular observed screenshot. + assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}"); + check_reference(&gpu, rgb); +} + +#[test] +fn gpu_display_representation_accepts_u8_u16_and_float_samples() { + let transform = synthetic_transform(false); + let gpu = transform.gpu_matrix_shaper().unwrap(); + for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] { + check_reference(&gpu, [v as f32 / 255.0; 3]); + } + for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] { + check_reference(&gpu, [v as f32 / 65535.0; 3]); + } + for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] { + check_reference(&gpu, rgb); + } + assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]); + // White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16. + check_reference(&gpu, [1.0; 3]); +} + +#[test] +fn absent_curves_and_matrix_use_identity_without_losing_offsets() { + let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap(); + for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] { + for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) { + assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6); + } + } + let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] }; + let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap(); + let input = [0.1, 0.2, 0.3]; + let output = sample_gpu(&transform, input); + for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) { + assert!((actual - expected).abs() < 1e-6, "{output:?}"); + } +} + +#[test] +fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() { + let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input)); + let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default()); + assert!(clut.gpu_matrix_shaper().is_none()); + let srgb = Builtin::Srgb.profile(); + let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap(); + assert!(lab.gpu_matrix_shaper().is_none()); + let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap(); + assert!(gray.gpu_matrix_shaper().is_none()); + let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap(); + assert!(proof.gpu_matrix_shaper().is_none()); +} + +#[test] +fn malformed_matrix_storage_is_not_exposed_to_the_gpu() { + for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] { + let transform = + Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default()); + assert!(transform.gpu_matrix_shaper().is_none()); + } +} diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index d3a6d36931..4f3435cc7a 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -459,6 +459,21 @@ impl ColorState { self.canvas_lut_with(doc, size, false, display) } + /// Accurate GPU representation of RGB matrix/TRC display transforms. Gamut warnings, + /// proof simulations beyond profile conversion, and transforms containing a CLUT retain + /// the existing 3D LUT path. The shader applies the 32-bit preview before this transform, + /// just as it does before the display LUT. + pub fn gpu_canvas_matrix_shaper_for(&self, doc: &Document, display: Option) -> Result> { + let proof = self.proof(doc.id); + if proof.gamut_warning || (proof.enabled && proof.setup.kind != crate::proof_sim::ProofKind::Profile) { + return Ok(None); + } + if !proof.enabled && self.canvas_display_for(doc, display)?.transform.is_none() { + return Ok(None); + } + Ok(self.display_transform_for(doc, display)?.gpu_matrix_shaper()) + } + fn canvas_lut_with(&self, doc: &Document, size: usize, hdr: bool, on: Option) -> Result>> { let size = size.max(2); let pv = self.proof(doc.id); @@ -1338,6 +1353,44 @@ mod tests { assert_eq!(sh.color.mode, ColorMode::Cmyk); } + #[test] + fn gpu_matrix_shaper_keeps_special_proof_simulations_on_the_lut_path() { + let mut s = session("rgb", 8); + let d = doc(&s).clone(); + // These valid synthetic RGB profiles make the ordinary profile-proof pipeline + // separable. Thus the test cannot pass just because a typical proof happens to + // contain unsupported stages; the simulation kind must be excluded explicitly. + let mut proof = Builtin::LinearSrgb.profile().clone(); + proof.description = "Synthetic identity-matrix proof".into(); + proof.matrix = Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + proof.trc = Some([photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity, photocraft_cms::Curve::Identity]); + let proof = proof.with_encoded_bytes(); + let mut monitor = proof.clone(); + monitor.description = "Synthetic identity-matrix gamma monitor".into(); + monitor.trc = Some(std::array::from_fn(|_| photocraft_cms::Curve::Gamma(2.2))); + let monitor = monitor.with_encoded_bytes(); + s.color.set_displays(Ok(vec![crate::display_color::Display { + id: 1, + name: "Synthetic monitor".into(), + frame: [0.0, 0.0, 800.0, 600.0], + profile_name: None, + icc: Some(monitor.to_bytes()), + }])); + let pv = s.color.proof_mut(d.id); + pv.setup.profile = Arc::new(Profile::parse(&proof.to_bytes()).unwrap()); + pv.enabled = true; + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "ordinary profile conversion is separable"); + for command in ["view.proofSetup.legacyMacintoshRgb", "view.proofSetup.colorBlindnessProtanopia", "view.proofSetup.colorBlindnessDeuteranopia"] { + // These commands retain the previously selected custom profile. + s.execute(command, json!({})).unwrap(); + assert!(s.color.display_transform_for(&d, Some(1)).unwrap().gpu_matrix_shaper().is_some(), "fixture must exercise the shortcut"); + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_none(), "{command} must retain its simulation"); + assert!(s.color.gpu_canvas_lut_for(&d, 9, Some(1)).unwrap().is_some(), "{command} still has its LUT fallback"); + } + s.execute("view.proofColors", json!({"on": false})).unwrap(); + assert!(s.color.gpu_canvas_matrix_shaper_for(&d, Some(1)).unwrap().is_some(), "disabled simulations do not block ordinary display conversion"); + } + #[test] fn proofing_state_and_gamut_warning() { let mut s = session("rgb", 8); diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs index 1e4aafc909..49b36f9031 100644 --- a/crates/engine/src/display_color.rs +++ b/crates/engine/src/display_color.rs @@ -1,5 +1,5 @@ //! Colour-managed canvas display: the document's composite shown through document profile → -//! monitor profile, on the GPU canvas (folded into the display 3D LUT) and the CPU canvas +//! monitor profile, on the GPU canvas (matrix/shaper or display 3D LUT) and the CPU canvas //! (an 8-bit transform of the composite). //! //! * The composite is in [`composite_profile`] (the document profile for RGB, its gray curve as diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 184e9ccce3..99cf13fa02 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3013,7 +3013,7 @@ const DISPLAY_LUT: usize = 33; /// Keep the GPU display LUT under `key` (the document's texture or a preview of it) in step with /// `doc`'s colour management: document → monitor profile and View › Proof Colors / Gamut /// Warning (the 32-bit preview is applied by the canvas shader, see [`hdr_preview`]). Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on -/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning). +/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning, 3 matrix/shaper). fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key: u64, display: Option) -> u8 { let Some(gpu) = app.gpu.clone() else { return 0 }; let output = display.unwrap_or(0); @@ -3024,6 +3024,13 @@ fn sync_display_lut(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, key { return mode; } + // Keep the curve/matrix/curve stages separate: interpolating a finished display + // transform on a 33³ lattice crushes shadows when the monitor curve is steep near zero. + if let Ok(Some(shaper)) = app.session.color.gpu_canvas_matrix_shaper_for(doc, display) { + gpu.set_display_matrix_shaper(key, output, &shaper); + gpu.cache_display_lut_signature(key, output, sig, 3); + return 3; + } let gamut = app.session.color.proof(doc.id).gamut_warning; let mode = match app.session.color.gpu_canvas_lut_for(doc, DISPLAY_LUT, display) { Ok(Some(bytes)) => { @@ -6863,7 +6870,7 @@ mod tests { let key = doc.id.0; // sRGB on the sRGB display needs no LUT; on the P3 display it does. assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0); - assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1); + assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3); let gpu = app.gpu.clone().unwrap(); let (s1, s4) = (gpu.display_lut_signature(key, 1).unwrap(), gpu.display_lut_signature(key, 4).unwrap()); assert_ne!(s1.0, s4.0); @@ -6871,7 +6878,7 @@ mod tests { // Drawing one window leaves the other's LUT alone. for _ in 0..3 { assert_eq!(sync_display_lut(&mut app, &doc, key, Some(1)), 0); - assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 1); + assert_eq!(sync_display_lut(&mut app, &doc, key, Some(4)), 3); } assert_eq!((gpu.display_lut_signature(key, 1), gpu.display_lut_signature(key, 4)), (Some(s1), Some(s4))); assert!(gpu.has_display_lut(key, 4)); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 7ec4c192b2..616407365a 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -79,7 +79,7 @@ pub struct ViewParams { /// Distinguishes views painted in the same frame (main canvas, extra windows). pub view_key: u64, /// Display transform: 0 none, 1 the document's display LUT (document → monitor profile, - /// Proof Colors), 2 LUT plus the gamut warning (see [`GpuCanvas::set_display_lut`]). + /// Proof Colors), 2 LUT plus the gamut warning, 3 matrix/shaper (see [`GpuCanvas::set_display_lut`]). pub display: u8, /// The display the view is on (its LUT is per document and display, #569; 0 = unknown). pub output: u32, @@ -798,12 +798,25 @@ impl GpuCanvas { log::warn!("set_display_lut: {} bytes for a {size}³ LUT; ignored", bytes.len()); return; } - let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes); + let bg = lut_bind_group(device, queue, &res.lut_bgl, size, bytes, None); res.luts.insert((doc, output), bg); } } } + /// Set a separately sampled curve/matrix/curve display transform. CMS validates this + /// representation and provides bounded tables; no profile interpretation lives in the UI. + pub fn set_display_matrix_shaper(&self, doc: u64, output: u32, shaper: &photocraft_cms::GpuMatrixShaper) { + if !self.health.is_ok() { + return; + } + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + let bg = lut_bind_group(&self.rs.device, &self.rs.queue, &res.lut_bgl, 2, &identity_lut_bytes(), Some(shaper)); + res.display_lut_signatures.remove(&(doc, output)); + res.luts.insert((doc, output), bg); + } + /// Read back level 0 of document `key`'s canvas texture: its format, size and the stored /// (premultiplied) texels as `f32` RGBA, row-major. Waits for the GPU; for tests, benchmarks /// and debugging. @@ -1595,8 +1608,16 @@ impl Default for CanvasStyle { } } -/// A `size`³ RGBA8 3D texture bound for the canvas shader. -fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGroupLayout, size: u32, bytes: &[u8]) -> wgpu::BindGroup { +/// A display transform: either the existing 3D LUT or separate full-float shapers and +/// matrix. Pack curves into short rows to stay within WebGL2's texture-size limits. +fn lut_bind_group( + device: &wgpu::Device, + queue: &wgpu::Queue, + bgl: &wgpu::BindGroupLayout, + size: u32, + bytes: &[u8], + shaper: Option<&photocraft_cms::GpuMatrixShaper>, +) -> wgpu::BindGroup { let extent = wgpu::Extent3d { width: size, height: size, depth_or_array_layers: size }; let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("pc_display_lut"), @@ -1615,10 +1636,48 @@ fn lut_bind_group(device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGr extent, ); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let (width, height, curves, matrix) = if let Some(s) = shaper { + let width = 1024usize; + let count = s.input_table().len() + s.output_table().len(); + let height = count.div_ceil(width); + let mut curves = Vec::with_capacity(width * height * 16); + for value in s.input_table().iter().chain(s.output_table()).flatten() { + curves.extend_from_slice(&value.to_le_bytes()); + } + curves.resize(width * height * 16, 0); + (width as u32, height as u32, curves, s.matrix_rows()) + } else { + (1, 1, vec![0; 16], [[0.0; 4]; 3]) + }; + let extent = wgpu::Extent3d { width, height, depth_or_array_layers: 1 }; + let curves_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_display_shapers"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + // textureLoad + explicit interpolation needs no optional float-filtering feature. + format: wgpu::TextureFormat::Rgba32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &curves_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &curves, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 16), rows_per_image: Some(height) }, + extent, + ); + let curves_view = curves_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let matrix_bytes: Vec = matrix.iter().flatten().flat_map(|v| v.to_le_bytes()).collect(); + let matrix_buffer = init_buffer(device, "pc_display_matrix", &matrix_bytes, wgpu::BufferUsages::UNIFORM); device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_display_lut"), layout: bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }], + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&curves_view) }, + wgpu::BindGroupEntry { binding: 2, resource: matrix_buffer.as_entire_binding() }, + ], }) } @@ -1731,7 +1790,20 @@ impl Resources { }, count: None, }; - let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_display_lut"), entries: &[lut_entry] }); + let curves_entry = wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }; + let lut_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_display_lut"), + entries: &[lut_entry, curves_entry, uniform_entry(2, 48, wgpu::ShaderStages::FRAGMENT)], + }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_canvas"), bind_group_layouts: &[Some(&view_bgl), Some(&tile_bgl), Some(&lut_bgl)], @@ -1802,7 +1874,7 @@ impl Resources { }); let encode_pipeline = pipeline(device, "pc_encode", &encode_layout, &encode_module, ("vs", "fs"), FORMAT, None); let encode_pipeline_high = high.then(|| pipeline(device, "pc_encode_16f", &encode_layout, &encode_module, ("vs", "fs"), FORMAT_HIGH, None)); - let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes()); + let identity_lut = lut_bind_group(device, queue, &lut_bgl, 2, &identity_lut_bytes(), None); Self { lut_bgl, luts: HashMap::new(), @@ -2236,7 +2308,7 @@ struct View { a: vec4, // screen_w, screen_h, scale (device px per doc px), pixels_per_point b: vec4, // doc origin x, y (device px), doc w, h (doc px) c: vec4, // filter mode, lod, grid alpha, checker square (device px) - d: vec4, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut), output linear + d: vec4, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut, 3 matrix/shaper), output linear e: vec4, // checker light rgb, gamut warning opacity f: vec4, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off) g: vec4, // gamut warning rgb, 32-bit preview 1 / gamma @@ -2249,6 +2321,36 @@ struct Tile { r: vec4 }; // x, y, w, h in doc px @group(1) @binding(0) var tex: texture_2d; @group(1) @binding(1) var tile: Tile; @group(2) @binding(0) var lut: texture_3d; +@group(2) @binding(1) var display_curves: texture_2d; +struct DisplayMatrix { r: vec4, g: vec4, b: vec4 }; +@group(2) @binding(2) var display_matrix: DisplayMatrix; + +fn curve_entry(index: u32) -> vec3 { + let width = textureDimensions(display_curves).x; + return textureLoad(display_curves, vec2(i32(index % width), i32(index / width)), 0).rgb; +} + +fn display_curve(c: vec3, offset: u32, intervals: f32) -> vec3 { + let p = clamp(c, vec3(0.0), vec3(1.0)) * intervals; + let lo = vec3(floor(p)); + let hi = min(lo + vec3(1u), vec3(u32(intervals))); + let f = fract(p); + return vec3( + mix(curve_entry(offset + lo.r).r, curve_entry(offset + hi.r).r, f.r), + mix(curve_entry(offset + lo.g).g, curve_entry(offset + hi.g).g, f.g), + mix(curve_entry(offset + lo.b).b, curve_entry(offset + hi.b).b, f.b) + ); +} + +fn matrix_display(c: vec3) -> vec3 { + // Layout matches cms::GpuMatrixShaper: 4097 input entries, then 4096 inverse-curve + // entries indexed by the fourth root. This preserves near-black precision without + // interpolating across clipped corners of a coarse RGB cube. + let v = vec4(display_curve(c, 0u, 4096.0), 1.0); + let linear = vec3(dot(display_matrix.r, v), dot(display_matrix.g, v), dot(display_matrix.b, v)); + let u = sqrt(sqrt(clamp(linear, vec3(0.0), vec3(1.0)))); + return display_curve(u, 4097u, 4095.0); +} struct VOut { @builtin(position) pos: vec4 }; @@ -2393,10 +2495,15 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { if (view.d.z > 0.5 && col.a > 0.0) { // Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags // out-of-gamut colours). - let n = f32(textureDimensions(lut).x); let c = clamp(col.rgb / col.a, vec3(0.0), vec3(1.0)); - let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0); - let shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5); + var shown: vec3; + if (view.d.z > 2.5) { + shown = matrix_display(c); + } else { + let n = f32(textureDimensions(lut).x); + let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0); + shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5); + } col = vec4(shown * col.a, col.a); } var rgb = col.rgb + checker(d) * (1.0 - col.a); diff --git a/crates/ui-egui/tests/display_shadows.rs b/crates/ui-egui/tests/display_shadows.rs new file mode 100644 index 0000000000..2ff7f4edeb --- /dev/null +++ b/crates/ui-egui/tests/display_shadows.rs @@ -0,0 +1,305 @@ +//! The on-screen GPU conversion must preserve the ICC matrix/TRC result near black. +//! +//! No photo, hardware profile or application's output is an oracle here: the generated +//! profiles and expected values implement ICC.1:2010 Annex F.3's matrix/TRC equations. +//! A uniform 33³ display LUT used to make input sRGB 1–7 noticeably too dark. + +use std::sync::Arc; + +use photocraft_cms::{Builtin, Profile, curve::Curve}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_engine::display_color::Display; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use photocraft_ui_egui::{PhotocraftApp, canvas::ViewXform, gpu_canvas}; + +// D50-adapted sRGB and Display P3 colourants. ICC stores them as signed 15.16 values. +const SOURCE_MATRIX: [[f64; 3]; 3] = + [[0.436_074_7, 0.385_064_9, 0.143_080_4], [0.222_504_5, 0.716_878_6, 0.060_616_9], [0.013_932_2, 0.097_104_5, 0.714_173_3]]; +const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.515_102, 0.291_965, 0.157_153], [0.241_182, 0.692_236, 0.066_581_9], [-0.001_049_41, 0.041_881_8, 0.784_378]]; +const PATCH: u32 = 56; +const COLS: usize = 8; +const RGB: [[u8; 3]; 32] = [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + [3, 3, 3], + [4, 4, 4], + [5, 5, 5], + [7, 7, 7], + [8, 8, 8], + [9, 9, 9], + [10, 10, 10], + [11, 11, 11], + [12, 12, 12], + [16, 16, 16], + [32, 32, 32], + [128, 128, 128], + [255, 255, 255], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [2, 1, 3], + [7, 2, 1], + [1, 7, 2], + [2, 1, 7], + [3, 5, 2], + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [24, 72, 153], + [153, 72, 24], + [72, 153, 24], + [254, 254, 254], + [64, 96, 192], +]; + +fn fixed(v: f64) -> f64 { + (v * 65536.0).round() / 65536.0 +} + +/// Literal source ICC `curv` entries, with ICC's unsigned-16 encoding. +fn source_samples() -> Vec { + (0..1024) + .map(|i| { + let x = i as f64 / 1023.0; + let y = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) }; + (y * 65535.0).round() / 65535.0 + }) + .collect() +} + +fn profiles() -> (Arc>, Arc>) { + let mut source = Builtin::Srgb.profile().clone(); + source.description = "Synthetic sampled sRGB".into(); + source.matrix = Some(SOURCE_MATRIX.map(|row| row.map(fixed))); + source.trc = Some(std::array::from_fn(|_| Curve::Table(source_samples().into_iter().map(|v| v as f32).collect()))); + let mut monitor = Builtin::DisplayP3.profile().clone(); + monitor.description = "Synthetic gamma 2.2 monitor".into(); + monitor.matrix = Some(MONITOR_MATRIX.map(|row| row.map(fixed))); + monitor.trc = Some(std::array::from_fn(|_| Curve::Gamma(fixed(2.2)))); + (source.with_encoded_bytes().to_bytes(), monitor.with_encoded_bytes().to_bytes()) +} + +/// Solve the monitor matrix without using any photocraft-cms transform/math/curve evaluator. +fn solve(mut m: [[f64; 3]; 3], mut xyz: [f64; 3]) -> [f64; 3] { + for column in 0..3 { + let pivot = (column..3).max_by(|&a, &b| m[a][column].abs().total_cmp(&m[b][column].abs())).unwrap(); + m.swap(column, pivot); + xyz.swap(column, pivot); + let divisor = m[column][column]; + for v in &mut m[column] { + *v /= divisor; + } + xyz[column] /= divisor; + let normalized = m[column]; + for row in 0..3 { + if row != column { + let factor = m[row][column]; + for (value, coefficient) in m[row].iter_mut().zip(normalized) { + *value -= factor * coefficient; + } + xyz[row] -= factor * xyz[column]; + } + } + } + xyz +} + +fn reference(rgb: [u8; 3]) -> [u8; 3] { + let table = source_samples(); + let linear = rgb.map(|v| { + let pos = f64::from(v) / 255.0 * 1023.0; + let lo = pos.floor() as usize; + let hi = (lo + 1).min(1023); + table[lo] + (table[hi] - table[lo]) * (pos - lo as f64) + }); + let xyz = SOURCE_MATRIX.map(|row| (0..3).map(|c| fixed(row[c]) * linear[c]).sum()); + solve(MONITOR_MATRIX.map(|row| row.map(fixed)), xyz).map(|v| (v.clamp(0.0, 1.0).powf(1.0 / fixed(2.2)) * 255.0).round() as u8) +} + +fn chart(depth: SampleType, icc: Arc>) -> Document { + let mut doc = Document::new("ICC shadow patches", Size::new(COLS as u32 * PATCH, RGB.len() as u32 / COLS as u32 * PATCH), ColorMode::Rgb, depth); + doc.icc_profile = Some(icc); + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + for (i, rgb) in RGB.iter().enumerate() { + let x = i as u32 % COLS as u32 * PATCH; + let y = i as u32 / COLS as u32 * PATCH; + surface.fill_rect( + Rect::new(x as i32, y as i32, (x + PATCH) as i32, (y + PATCH) as i32), + &[f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0, 1.0], + ); + } + doc.layers.push(Layer::new("Patches", LayerContent::Raster(surface))); + doc +} + +#[test] +fn gpu_display_shadows_match_independent_icc_reference_at_all_depths() { + let _gpu = photocraft_testkit::gpu_lock(); + // Catch only device discovery: a shader or application failure must fail the regression. + let Ok(rs) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(gpu_canvas::wgpu_setup(), Default::default()))) + else { + eprintln!("skipping: no GPU adapter"); + return; + }; + if !gpu_canvas::supports_f16_canvas(&rs.adapter) || std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() == Ok("0") { + eprintln!("skipping: adapter can't render/filter Rgba16Float"); + return; + } + let (source, monitor) = profiles(); + // Verify the synthetic fixture was encoded as intended; no production evaluator is the oracle. + let parsed = Profile::parse(&source).expect("source fixture"); + assert_eq!(parsed.matrix, Some(SOURCE_MATRIX.map(|row| row.map(fixed)))); + let renderer = egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs); + let mut harness = + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).renderer(renderer).build_eframe( + move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(cc.wgpu_render_state.as_ref().expect("render state").clone()); + app.session.color.set_displays(Ok(vec![Display { + id: 1, + name: "Synthetic display".into(), + frame: [0.0, 0.0, 1000.0, 700.0], + profile_name: None, + icc: Some(monitor), + }])); + app + }, + ); + let mut failures = Vec::new(); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + harness.state_mut().session.open_document(chart(depth, source.clone()), None); + harness.state_mut().sync_views(); + harness.run_steps(6); + assert!(harness.state().gpu_active(), "the regression must exercise the GPU canvas"); + let xform = ViewXform::active(harness.state()).expect("canvas mapping"); + let image = harness.render().expect("render GPU canvas"); + // Optional local diagnostic artifact: entirely synthetic, never a user's document. + if let Some(dir) = std::env::var_os("PHOTOCRAFT_DISPLAY_TEST_OUTPUT") { + let path = std::path::PathBuf::from(dir).join(format!("display-shadows-{depth:?}.png")); + image.save(&path).expect("save requested synthetic chart"); + eprintln!("synthetic chart: {}", path.display()); + } + for (i, rgb) in RGB.iter().enumerate() { + let x = (i % COLS) as f32 * PATCH as f32 + PATCH as f32 / 2.0; + let y = (i / COLS) as f32 * PATCH as f32 + PATCH as f32 / 2.0; + let point = xform.to_screen(x, y); + let got = image.get_pixel(point.x.round() as u32, point.y.round() as u32).0; + let want = reference(*rgb); + if (0..3).any(|c| got[c].abs_diff(want[c]) > 1) { + failures.push(format!("{depth:?} input {rgb:?}: GPU {got:?}, ICC {want:?}")); + } + } + } + assert!(failures.is_empty(), "GPU display differs by more than one RGB8 code:\n{}", failures.join("\n")); +} +/// Release-only manual evidence, not a noisy CI performance threshold. Both paths render +/// the same 24 MP texture at 100%; timings include identical framebuffer allocation/readback +/// and wait for GPU completion. Profile/texture uploads are outside the measured frames. +#[test] +#[ignore = "manual 24 MP GPU timing; run with --release --ignored --nocapture"] +fn compare_display_paths_24mp() { + use photocraft_cms::{Intent, Lut3d, Transform}; + use photocraft_ui_egui::gpu_canvas::{GpuCanvas, ViewParams}; + use std::time::Instant; + + let _gpu = photocraft_testkit::gpu_lock(); + let rs = egui_kittest::wgpu::create_render_state(gpu_canvas::wgpu_setup(), Default::default()); + let info = rs.adapter.get_info(); + let gpu = GpuCanvas::new(&rs); + let (source, monitor) = profiles(); + let source = Profile::parse(&source).expect("synthetic source"); + let monitor = Profile::parse(&monitor).expect("synthetic monitor"); + let transform = Transform::new(&source, &monitor, Intent::RelativeColorimetric, true).expect("display transform"); + let lut = Lut3d::from_transform(&transform, 33).to_rgba8(); + let shaper = transform.gpu_matrix_shaper().expect("matrix/shaper"); + + let mut prep_lut_ms = Vec::new(); + let mut prep_shaper_ms = Vec::new(); + for _ in 0..5 { + let start = Instant::now(); + std::hint::black_box(Lut3d::from_transform(&transform, 33).to_rgba8()); + prep_lut_ms.push(start.elapsed().as_secs_f64() * 1000.0); + let start = Instant::now(); + std::hint::black_box(transform.gpu_matrix_shaper().expect("matrix/shaper")); + prep_shaper_ms.push(start.elapsed().as_secs_f64() * 1000.0); + } + + const SIZE: [u32; 2] = [6000, 4000]; + let rgba: Vec = (0..SIZE[0] as usize * SIZE[1] as usize) + .flat_map(|i| { + let rgb = RGB[(i / 64 + i / SIZE[0] as usize / 64) % RGB.len()]; + [rgb[0], rgb[1], rgb[2], 255] + }) + .collect(); + gpu.upload_full(1, SIZE, &rgba); + drop(rgba); + gpu.set_display_lut(1, 1, 33, Some(&lut)); + gpu.set_display_matrix_shaper(1, 2, &shaper); + assert!(gpu.has(1, SIZE), "24 MP texture uploaded"); + let renderer = egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs); + let params = ViewParams { + doc: 1, + doc_size: SIZE, + zoom: 1.0, + center: [SIZE[0] as f32 / 2.0, SIZE[1] as f32 / 2.0], + rotation: 0.0, + shadow: false, + pixel_grid: false, + view_key: 0, + display: 1, + output: 1, + hdr: None, + offset: [0.0, 0.0], + }; + let mut harness = + egui_kittest::Harness::builder().with_size(egui::vec2(SIZE[0] as f32, SIZE[1] as f32)).with_pixels_per_point(1.0).renderer(renderer).build_ui_state( + |ui, params| { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(SIZE[0] as f32, SIZE[1] as f32)); + GpuCanvas::paint(&ui.painter().with_clip_rect(rect), rect, *params); + }, + params, + ); + let mut render_lut_ms = Vec::new(); + let mut render_shaper_ms = Vec::new(); + // Alternation reduces order/temperature bias; round zero is the warm-up for each mode. + for round in 0..6 { + for (display, output) in [(1, 1), (3, 2)] { + harness.state_mut().display = display; + harness.state_mut().output = output; + let start = Instant::now(); + harness.run_steps(1); + // WgpuTestRenderer waits for submission and mapped readback before returning. + let image = harness.render().expect("render 24 MP frame"); + std::hint::black_box(&image); + let elapsed = start.elapsed().as_secs_f64() * 1000.0; + assert_eq!([image.width(), image.height()], SIZE); + if round != 0 { + if display == 1 { + render_lut_ms.push(elapsed); + } else { + render_shaper_ms.push(elapsed); + } + } + } + } + for (label, mut samples) in [ + ("LUT preparation", prep_lut_ms), + ("matrix/shaper preparation", prep_shaper_ms), + ("LUT 24 MP render + readback", render_lut_ms), + ("matrix/shaper 24 MP render + readback", render_shaper_ms), + ] { + samples.sort_by(f64::total_cmp); + eprintln!("{label}: median {:.3} ms, max {:.3} ms; samples {samples:?}", samples[2], samples[4]); + } + eprintln!( + "adapter: {} ({:?}); release={}; 6000x4000 RGBA8, 100% zoom, 1 warm-up + 5 alternating measured frames per path", + info.name, + info.backend, + !cfg!(debug_assertions) + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index 614cabfb05..79c674bf23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # PhotoCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (workspace size and test counts recounted; current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (accurate GPU matrix/TRC display conversion; original design plan retained) · **Target:** Adobe Photoshop 2026 ## Current state (2026-10-10) @@ -18,7 +18,7 @@ What exists on `main` today. The numbered sections below are the original design UI, CLI, JSON control channel and MCP server all dispatch by command id. 628 Photoshop menu items map to them (`crates/ui-egui/src/menu_catalog.rs`). - **Rendering:** CPU compositor (`compose`, the oracle) and wgpu compositor (`gpu`, ≤ 1/255 of the - CPU); colour through our own ICC engine (`cms`), display transform folded into a GPU 3D LUT. + CPU); colour through our own ICC engine (`cms`), GPU display conversion through matrix/shaper stages or a 3D LUT fallback. - **Files:** own PSD/PSB crate (`psd`), flat codecs (`codecs`), camera raw (`raw`), HEIF (`heif`), Affinity (`affinity`), document mapping (`io`), native `.pcraft` (`format`, atomic saves). Coverage: [`file-format-parity.md`](file-format-parity.md). @@ -349,7 +349,7 @@ Both backends consume the same plan. This is the only place that encodes Photosh ### 7.3 Color - Blending happens in document space by default, which is Photoshop-compatible. A per-document "linear light blending" option is also available. -- **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas the transform, plus Proof Colors / Gamut Warning / 32-bit preview, is baked into a 33³ 3D LUT the canvas shader's final pass applies; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports). +- **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas RGB matrix/TRC transforms retain separate full-float input curves, matrix/offsets and inverse output curves (sampled densely near black); this avoids the shadow darkening of a uniform RGB cube (#7958). Other transforms and Gamut Warning retain the 33³ display LUT, and the shader applies 32-bit preview before either conversion; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports). - **Monitor profile:** Edit › Color Settings › Monitor Profile: `auto`, a built-in RGB profile or an `.icc` path. In `auto` each window uses the profile of the display it is on (#569): the desktop app reads every display's id, name, frame and ICC profile at launch and again when the app comes back to the front, when any window is on an unknown or resized display (at most every 30 s; a trigger inside that window is deferred) and when Edit › Color Settings opens (`ui-egui/src/monitor_status.rs`). macOS reads AppKit `NSScreen` through `osascript` (no FFI); X11 reads RandR outputs and the default colord profile matched by exact `XRANDR_name`; native Wayland and Xwayland windows from a Wayland session stay on sRGB because eframe/winit cannot safely set the app surface's Wayland image description. The macOS helper is stopped after 10 s. There is deliberately no periodic re-read (#569 decision: a read costs about 0.2 s of CPU), so a profile reassigned while PhotoCraft stays in front with no window moving is picked up at the next return to the front or Color Settings. X11 selects the root window's RandR output by winit's current-monitor name and other windows by their overlapping RandR frame; macOS follows `NSWindow.screen`. Elsewhere `auto` is sRGB. The GPU canvas keeps one texture per document and a display LUT per (document, display); CPU canvas textures are per (document, display). `ColorState::monitor_status_for` says what is applied per display: `auto`, `manual`, or `fallback` to sRGB with the reason (missing, unreadable, non-RGB or unusable as a destination), and whether the reading is an earlier one kept after a failed re-read; `edit.colorSettings` (`monitorStatus`, `displays`), Help › System Info and the Color Settings dialog show it, and a fallback in `auto` posts a notice. Letting macOS colour-match the canvas instead is #581 (an architecture decision). - **HDR/EDR output** (an `rgba16float` surface with an extended-range colorspace) is a later-phase feature. The interfaces already carry `f32` pixels. @@ -589,6 +589,7 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | GPU display matrix/shaper path preserves near-black ICC values; complex transforms retain the 3D LUT fallback (#7958) | | 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged | | 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` | | 2026-09-30 | major | Design plan v1 | From d53101c9a148beb39df68f3489488ed6eabc2f85 Mon Sep 17 00:00:00 2001 From: Derek Gliwa <2328690+dgliwa@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:53:55 -0500 Subject: [PATCH 59/69] Brushes: keep the 512 MiB .abr cap on 32-bit targets On wasm32 a near-1 GiB .abr arrives as base64 and is copied several times before decoding; with a 1 GiB sample budget the import could pass 4 GiB and abort the tab instead of failing cleanly. Pick the decoder budget by pointer width (like MAX_DECODED_BYTES) and derive the engine's file cap from it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/preset_import_cmds.rs | 9 ++++++--- crates/io/examples/gen_abr_library.rs | 2 +- crates/psd/src/abr.rs | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index dfff9857de..5e3e2cda95 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -28,7 +28,9 @@ 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; +/// Tied to the decoder's sample budget (1 GiB, 512 MiB on 32-bit targets) so it never refuses +/// what this accepts. +pub const MAX_ABR_FILE_BYTES: u64 = photocraft_psd::abr::MAX_TOTAL_BYTES as u64; /// 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)> { @@ -597,8 +599,9 @@ mod tests { #[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); + // 1 GiB on 64-bit; 32-bit (wasm) keeps 512 MiB so a big file fails cleanly instead of aborting. + const WANT: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 }; + const _: () = assert!(MAX_ABR_FILE_BYTES == WANT && MAX_ABR_FILE_BYTES >= MAX_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"); diff --git a/crates/io/examples/gen_abr_library.rs b/crates/io/examples/gen_abr_library.rs index a72b5ed995..cbb2ad303c 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 1 GiB decoded cap. +//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's decoded cap (1 GiB; 512 MiB on 32-bit). #![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 025aad294f..0a0a41ffbf 100644 --- a/crates/psd/src/abr.rs +++ b/crates/psd/src/abr.rs @@ -43,8 +43,11 @@ use crate::patterns::{PatternBudget, PsdPattern, parse_pattern_block_with, write 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 = 1 << 30; +/// Cap on decoded sample bytes over the whole file. 32-bit targets (wasm) keep 512 MiB: the file, +/// its base64 copies and the decoded tips must all fit a 4 GiB address space, and running out +/// there aborts instead of returning an error. +pub const MAX_TOTAL_BYTES: usize = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 512 << 20 }; +const _: () = assert!(cfg!(target_pointer_width = "64") || MAX_TOTAL_BYTES == 512 << 20); /// A sampled tip: one grayscale plane where the maximum value is full paint. #[derive(Debug, Clone, PartialEq)] From 760109e23cd4f996aed591e60c88fffd639668bb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:57:10 +0200 Subject: [PATCH 60/69] fix(psd): give a new Txt2 object its own style and a number past the file's objects A new object kept the template's style runs, and PhotoCraft's reader takes font features from Txt2, so a new type layer came back with the template layer's features (salt, ss01). Its style runs are now generated like the stub's; only the template's structure is copied. New type layers were numbered past the kept TextIndex values only, so after deleting the layer with the highest index a new layer landed on its object and took its style. They are now numbered past the file's Txt2 objects too. txt2_extras_survive_a_resave_of_unchanged_text now gives its layer the TySh index a file's layer has; without one the layer is new and no longer lands on object 0. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/psd_export.rs | 11 ++++--- crates/io/tests/psd_kerning.rs | 58 ++++++++++++++++++++++++++++++++++ crates/text/src/psd.rs | 10 +++++- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index dd6446148a..4350ffd06a 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1582,9 +1582,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c ex.used_ids = used; // Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the // number it already names — its `Txt2` object must sit at that slot for `apply_txt2` — - // and new layers get numbers past every kept one. Kept numbers are claimed first: a layer - // added below others must not take a number a layer above it keeps, or each of those - // would be renumbered onto its neighbour's object and take its style (#7773). + // and new layers get numbers past every kept one and every object in the file's `Txt2`. + // Kept numbers are claimed first: a layer added below others must not take a number a + // layer above it keeps, or each of those would be renumbered onto its neighbour's object + // and take its style (#7773); nor may it land on a deleted layer's object. let mut used_txt = std::collections::HashSet::new(); ex.text_index = Default::default(); let mut fresh = Vec::new(); @@ -1599,7 +1600,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c _ => fresh.push(l.id), } } - let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1); + let objects = + doc.metadata.psd_global_blocks.iter().find(|(_, key, _)| key == b"Txt2").map_or(0, |(_, _, d)| photocraft_text::psd::txt2_object_count(d)); + let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1).max(i32::try_from(objects).unwrap_or(i32::MAX)); if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 { next_txt = 0; } diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/psd_kerning.rs index 99b8535f4c..9c5e3dd8cd 100644 --- a/crates/io/tests/psd_kerning.rs +++ b/crates/io/tests/psd_kerning.rs @@ -173,6 +173,61 @@ fn new_text_layer_below_keeps_the_preserved_text_indices() { assert_eq!(indices, [("new", Some(2)), ("AV", Some(0)), ("BC", Some(1))]); } +/// A Photoshop-shaped `Txt2` block: one object per `(text, style keys)` pair, each with its view +/// naming its own frame. +fn photoshop_txt2(objects: &[(&str, &str)]) -> Vec { + let bom = "\u{fe}\u{ff}"; + let frame = "<< /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> "; + let mut frames = String::new(); + let mut body = String::new(); + for (i, (text, style)) in objects.iter().enumerate() { + let utf16: String = format!("{text}\r").chars().flat_map(|c| ['\0', c]).collect(); + let len = text.len() + 1; + frames.push_str(frame); + body.push_str(&format!( + "<< /0 << /0 ({bom}{utf16}) /5 << /0 [ << /0 << /0 << /0 ({bom}) /5 << /7 1.2 >> >> >> /1 {len} >> ] >> /6 << /0 [ << /0 << /0 << /0 ({bom}) /6 << /0 0 /11 1 {style} >> >> >> /1 {len} >> ] >> >> /1 << /0 [ << /0 {i} >> ] /1 << /0 [ << /0 << /1 1 >> /1 {len} >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 {i} >> ] >> ] >> >> " + )); + } + format!("\n\n/98 << /0 7 >> /0 << /8 << /0 [ {frames}] >> >> /1 << /0 << /0 1 >> /1 [ {body}] /2 << /0 7 >> >>").chars().map(|c| c as u32 as u8).collect() +} + +/// The preserved type layers "AV" (`TextIndex` 0) and "BC" (1) of a Photoshop file whose `Txt2` +/// holds `objects`, plus a new type layer "Hi" on top, saved and reopened: "Hi"'s index and the +/// font features it comes back with. +fn add_layer_beside(objects: &[(&str, &str)]) -> (Option, Vec) { + let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + for (i, text) in ["AV", "BC"].into_iter().enumerate() { + let mut t = layer(text, &[(2, Kerning::Metrics, 0.0)]); + photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); + let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None); + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, i as i32).unwrap())); + doc.layers.push(Layer::new(text, LayerContent::Text(t))); + } + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))]; + let mut t = layer("Hi", &[(2, Kerning::Metrics, 0.0)]); + photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); + doc.layers.push(Layer::new("Hi", LayerContent::Text(t))); + let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("k.psd", &bytes).unwrap().document; + let LayerContent::Text(t) = &back.layers[2].content else { panic!("not text") }; + let features = t.char_runs().iter().flat_map(|r| r.style.features.iter().map(|f| f.tag.clone())).collect(); + (t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)), features) +} + +/// A new type layer takes the structure of one of the file's objects but not its style: the +/// template's style runs carried its font features (here `salt` and `ss01`) into the new layer. +#[test] +fn new_text_layer_takes_no_style_from_the_txt2_template() { + assert_eq!(add_layer_beside(&[("AV", ""), ("BC", "/28 true /85 1")]), (Some(2), vec![])); +} + +/// A new type layer is numbered past the file's `Txt2` objects, not only past the kept indices: +/// the object of a deleted type layer ("Gone", index 2) is not reused with its style. +#[test] +fn new_text_layer_skips_a_deleted_layers_txt2_object() { + assert_eq!(add_layer_beside(&[("AV", ""), ("BC", ""), ("Gone", "/28 true")]), (Some(3), vec![])); +} + /// A file-controlled `TextIndex` must not size the save: `i32::MAX` is ignored (the regenerated /// `Txt2` is sized by real text objects, never by a file's numbers) and the document still saves /// and reopens with its text. @@ -215,6 +270,9 @@ fn txt2_extras_survive_a_resave_of_unchanged_text() { let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); let mut r = t.clone(); photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format()); + // The file's layer: its `TySh` names object 0. + let raw = photocraft_text::psd::build_tysh(&r, doc.resolution_dpi, None); + r.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap())); doc.layers.push(Layer::new("kept", LayerContent::Text(r))); doc.metadata.psd_global_blocks.push((*b"8BIM", *b"Txt2", std::sync::Arc::new(prev.clone()))); let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 3221f9c80d..828a50a0a2 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -1002,7 +1002,8 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec // 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. 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(); + // A new object keeps the template's structure, not its style: its style runs are generated. + let reuse = kept.is_some() || edited.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)); @@ -1250,6 +1251,13 @@ pub fn text_index(tysh: &[u8]) -> Option { } } +/// How many text objects the document `Txt2` block `data` holds (`/1 /1`), named by a layer or +/// not; 0 when it doesn't parse. PSD export numbers new type layers past them, so a new layer +/// never lands on a deleted layer's object. +pub fn txt2_object_count(data: &[u8]) -> usize { + parse_txt2(data).and_then(|t| t.path(&["1", "1"]).and_then(E::as_array).map(<[E]>::len)).unwrap_or(0) +} + /// Rewrites a `TySh`'s `TextIndex`: the layer's text-object number in the document's `Txt2` /// block ([`build_txt2`] writes them in this order). `None` when `tysh` doesn't parse. pub fn set_text_index(tysh: &[u8], index: i32) -> Option> { From 20c9103148e3b7a6a2dd67ef4529655cce0c7e64 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:48:40 +0200 Subject: [PATCH 61/69] fix(psd): write complete Txt2 style runs from the layer, number only file indices as kept Photoshop reads the character style from Txt2 and quit on the bare run (`/0 0 /11 mode`) that new objects got. New and edited objects now get one style run per character run, built on one of the object's own runs with every key kept, and font, size, faux styles, leading, scales, tracking, fill colour, OpenType keys and auto-kern mode set from the layer. A font the block's font list lacks is appended to it. Edited objects therefore no longer keep the old text's features by position. build_tysh no longer writes TextIndex 0 for a layer without one: the export took it as a kept number, so a Type tool layer landed on object 0 (a deleted layer's, or the one of a layer above it). set_text_index puts a missing TextIndex right before EngineData, where Photoshop writes it. pad_frames pads to at most MAX_TEXT_INDEX + 1 frames. edited_text_keeps_the_files_txt2_object (#7220) now expects style runs that follow the layer's runs and size. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/psd_export.rs | 4 +- crates/io/tests/psd_kerning.rs | 95 ++++++++++++++++++---- crates/text/src/psd.rs | 140 ++++++++++++++++++++++++++++----- crates/text/src/tests.rs | 20 ++++- 4 files changed, 220 insertions(+), 39 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4350ffd06a..419f9156f7 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1740,8 +1740,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c // writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the // block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the // one mode EngineData can't carry; #1348). - let txt2 = - (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)).then(|| photocraft_text::psd::build_txt2(&text_layers, previous.as_deref())); + let txt2 = (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)) + .then(|| photocraft_text::psd::build_txt2_at(&text_layers, previous.as_deref(), ex.dpi)); for (sig, key, data) in preserved { if txt2.is_some() && key == *b"Txt2" { continue; diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/psd_kerning.rs index 9c5e3dd8cd..6eeebc43b4 100644 --- a/crates/io/tests/psd_kerning.rs +++ b/crates/io/tests/psd_kerning.rs @@ -185,47 +185,108 @@ fn photoshop_txt2(objects: &[(&str, &str)]) -> Vec { let len = text.len() + 1; frames.push_str(frame); body.push_str(&format!( - "<< /0 << /0 ({bom}{utf16}) /5 << /0 [ << /0 << /0 << /0 ({bom}) /5 << /7 1.2 >> >> >> /1 {len} >> ] >> /6 << /0 [ << /0 << /0 << /0 ({bom}) /6 << /0 0 /11 1 {style} >> >> >> /1 {len} >> ] >> >> /1 << /0 [ << /0 {i} >> ] /1 << /0 [ << /0 << /1 1 >> /1 {len} >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 {i} >> ] >> ] >> >> " + "<< /0 << /0 ({bom}{utf16}) /5 << /0 [ << /0 << /0 << /0 ({bom}) /5 << /7 1.2 >> >> >> /1 {len} >> ] >> /6 << /0 [ << /0 << /0 << /0 ({bom}) /6 << /0 0 /1 12.0 /11 1 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 0.0 ] >> >> {style} >> >> >> /1 {len} >> ] >> >> /1 << /0 [ << /0 {i} >> ] /1 << /0 [ << /0 << /1 1 >> /1 {len} >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 {i} >> ] >> ] >> >> " )); } - format!("\n\n/98 << /0 7 >> /0 << /8 << /0 [ {frames}] >> >> /1 << /0 << /0 1 >> /1 [ {body}] /2 << /0 7 >> >>").chars().map(|c| c as u32 as u8).collect() + let font: String = "MyriadPro-Regular".chars().flat_map(|c| ['\0', c]).collect(); + format!("\n\n/98 << /0 7 >> /0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 ({bom}{font}) /2 1 >> >> >> ] >> /8 << /0 [ {frames}] >> >> /1 << /0 << /0 1 >> /1 [ {body}] /2 << /0 7 >> >>").chars().map(|c| c as u32 as u8).collect() } -/// The preserved type layers "AV" (`TextIndex` 0) and "BC" (1) of a Photoshop file whose `Txt2` -/// holds `objects`, plus a new type layer "Hi" on top, saved and reopened: "Hi"'s index and the -/// font features it comes back with. -fn add_layer_beside(objects: &[(&str, &str)]) -> (Option, Vec) { +/// A Photoshop file whose `Txt2` holds `objects` and whose type layers are `kept` (text and +/// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one +/// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's +/// index and font features (but `calt`, on by default and written on as Photoshop does), and +/// "Hi"'s style run in the saved `Txt2`. +fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec<(String, Option, Vec)>, E) { let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); - for (i, text) in ["AV", "BC"].into_iter().enumerate() { - let mut t = layer(text, &[(2, Kerning::Metrics, 0.0)]); + for (text, index) in kept { + let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]); photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None); - t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, i as i32).unwrap())); - doc.layers.push(Layer::new(text, LayerContent::Text(t))); + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap())); + doc.layers.push(Layer::new(*text, LayerContent::Text(t))); } doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))]; let mut t = layer("Hi", &[(2, Kerning::Metrics, 0.0)]); + t.runs[0].style.size_pt = 30.0; photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); - doc.layers.push(Layer::new("Hi", LayerContent::Text(t))); + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None))); + doc.layers.insert(at, Layer::new("Hi", LayerContent::Text(t))); let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap(); let back = photocraft_io::import("k.psd", &bytes).unwrap().document; - let LayerContent::Text(t) = &back.layers[2].content else { panic!("not text") }; - let features = t.char_runs().iter().flat_map(|r| r.style.features.iter().map(|f| f.tag.clone())).collect(); - (t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)), features) + let layers: Vec<_> = back + .layers + .iter() + .map(|l| { + let LayerContent::Text(t) = &l.content else { panic!("not text") }; + let index = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)); + (l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect()) + }) + .collect(); + let hi = layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap() as usize; + let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone(); + (layers, run) +} + +/// The layer `name` in `add_type_layer`'s result. +fn named<'a>(layers: &'a [(String, Option, Vec)], name: &str) -> &'a (String, Option, Vec) { + layers.iter().find(|l| l.0 == name).unwrap() } /// A new type layer takes the structure of one of the file's objects but not its style: the /// template's style runs carried its font features (here `salt` and `ss01`) into the new layer. +/// Its style run is the template's complete one (Photoshop quit on a run holding only the +/// auto-kern mode, and reads the character style from it) with the layer's own size. #[test] fn new_text_layer_takes_no_style_from_the_txt2_template() { - assert_eq!(add_layer_beside(&[("AV", ""), ("BC", "/28 true /85 1")]), (Some(2), vec![])); + let (layers, run) = add_type_layer(&[("AV", ""), ("BC", "/28 true /85 1")], &[("AV", 0), ("BC", 1)], 2); + assert_eq!(named(&layers, "Hi").1..=named(&layers, "Hi").1, Some(2)..=Some(2)); + assert!(named(&layers, "Hi").2.is_empty(), "{layers:?}"); + let style = run.path(&["0", "0", "6"]).unwrap(); + assert_eq!(style.get("1").and_then(E::as_f64), Some(30.0), "the layer's own size"); + assert_eq!(style.get("28"), Some(&E::Bool(false))); + assert_eq!(style.path(&["53", "0", "0"]), Some(&E::Int(1)), "the layer's fill colour, RGB"); + assert_eq!(style.get("11"), Some(&E::Int(1))); } /// A new type layer is numbered past the file's `Txt2` objects, not only past the kept indices: -/// the object of a deleted type layer ("Gone", index 2) is not reused with its style. +/// a deleted type layer's object ("Gone") is not reused with its style, whether it held the +/// highest index or index 0 (the Type tool's layer used to bring a `TextIndex` 0 of its own). #[test] fn new_text_layer_skips_a_deleted_layers_txt2_object() { - assert_eq!(add_layer_beside(&[("AV", ""), ("BC", ""), ("Gone", "/28 true")]), (Some(3), vec![])); + let (layers, _) = add_type_layer(&[("AV", ""), ("BC", ""), ("Gone", "/28 true")], &[("AV", 0), ("BC", 1)], 2); + assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(3), true)); + let (layers, _) = add_type_layer(&[("Gone", "/28 true"), ("AV", ""), ("BC", "")], &[("AV", 1), ("BC", 2)], 2); + assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(3), true)); +} + +/// A type layer added below the one holding index 0 neither takes that index nor its style. +#[test] +fn new_text_layer_at_the_bottom_keeps_index_0_where_it_was() { + let (layers, _) = add_type_layer(&[("AV", "/28 true"), ("BC", "")], &[("AV", 0), ("BC", 1)], 0); + assert_eq!(named(&layers, "AV").1, Some(0)); + assert_eq!(named(&layers, "AV").2, ["salt"]); + assert_eq!((named(&layers, "Hi").1, named(&layers, "Hi").2.is_empty()), (Some(2), true)); +} + +/// An edited object takes the layer's style, not the old text's: "Hello world" with Stylistic +/// Alternates on "Hello " edited to "world" without them comes back without `salt`. +#[test] +fn edited_text_layer_takes_its_own_style_runs() { + let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut t = layer("world", &[(5, Kerning::Metrics, 0.0)]); + photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); + let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None); + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap())); + doc.layers.push(Layer::new("edited", LayerContent::Text(t))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(&[("Hello world", "/28 true")])))]; + let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("k.psd", &bytes).unwrap().document; + let LayerContent::Text(t) = &back.layers[0].content else { panic!("not text") }; + assert_eq!(t.text, "world"); + assert!(t.char_runs().iter().all(|r| r.style.features.iter().all(|f| f.tag == "calt")), "the old text's salt came back"); } /// A file-controlled `TextIndex` must not size the save: `i32::MAX` is ignored (the regenerated diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 828a50a0a2..6d8fd8f454 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -820,21 +820,21 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [ ("Fractions", "frac"), ]; -pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E { - let c = &s.color; - let values = match c.mode { - ColorMode::Cmyk => vec![real(c.alpha), real(c.c[0]), real(c.c[1]), real(c.c[2]), real(c.c[3])], - ColorMode::Grayscale => vec![real(c.alpha), real(c.c[0])], +/// A fill colour as EngineData and `Txt2` store it: the colour type (0 gray, 1 RGB, 2 CMYK) and +/// its values, alpha first. +fn fill_color(c: &Color) -> (i64, Vec) { + match c.mode { + ColorMode::Cmyk => (2, vec![real(c.alpha), real(c.c[0]), real(c.c[1]), real(c.c[2]), real(c.c[3])]), + ColorMode::Grayscale => (0, vec![real(c.alpha), real(c.c[0])]), _ => { let [r, g, b] = c.to_rgb(); - vec![real(c.alpha), real(r), real(g), real(b)] + (1, vec![real(c.alpha), real(r), real(g), real(b)]) } - }; - let color_type = match c.mode { - ColorMode::Cmyk => 2, - ColorMode::Grayscale => 0, - _ => 1, - }; + } +} + +pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E { + let (color_type, values) = fill_color(&s.color); let mut opentype: Vec<(String, E)> = OPENTYPE_KEYS .iter() .filter(|(_, tag)| s.features.iter().any(|f| f.tag == *tag && f.value > 0)) @@ -938,6 +938,88 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> { out } +/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs, +/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set +/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which +/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking +/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode +/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit +/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary. +fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Option> { + if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) { + return None; + } + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let runs = layer.char_runs(); + let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); + let mut spans: Vec<(usize, i64)> = Vec::new(); + let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len)); + for (b, ch) in ed_text.char_indices() { + while b >= run_end && ri + 1 < runs.len() { + ri += 1; + run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len)); + } + let n = ch.len_utf16() as i64; + match spans.last_mut() { + Some(last) if last.0 == ri => last.1 += n, + _ => spans.push((ri, n)), + } + } + let default = CharStyle::default(); + let mut out = Vec::with_capacity(spans.len()); + for (ri, n) in spans { + let s = runs.get(ri).map_or(&default, |r| &r.style); + let font = txt2_font(fonts, &postscript_for(s)); + let mut run = base.clone(); + let style = run.get_mut("0")?.get_mut("0")?.get_mut("6")?; + if let Some(font) = font { + style.set("0", E::Int(font)); + } + style.set("1", real(s.size_pt * k)); + style.set("2", E::Bool(s.faux_bold)); + style.set("3", E::Bool(s.faux_italic)); + style.set("4", E::Bool(s.leading_pt.is_none())); + style.set("5", real(s.leading_pt.unwrap_or(0.0) * k)); + style.set("6", real(s.horizontal_scale)); + style.set("7", real(s.vertical_scale)); + style.set("8", E::Int(s.tracking.round() as i64)); + if let Some(paint @ E::Dict(_)) = style.get_mut("53").and_then(|p| p.get_mut("0")) { + let (kind, values) = fill_color(&s.color); + paint.set("0", E::Int(kind)); + paint.set("1", E::Array(values)); + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + for (key, tag) in TXT2_FEATURES { + style.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt"))); + } + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + style.set(TXT2_STYLISTIC_SETS, E::Int(sets)); + style.set("11", E::Int(txt2_mode(s))); + run.set("1", E::Int(n)); + out.push(run); + } + Some(out) +} + +/// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont +/// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry) +/// when missing. `None` for an empty list: there is no entry to shape a new one on. +fn txt2_font(fonts: &mut Vec, name: &str) -> Option { + let named = |f: &E| f.path(&["0", "0", "0"]).and_then(E::as_str) == Some(name); + if let Some(i) = fonts.iter().position(named) { + return i64::try_from(i).ok(); + } + let mut entry = fonts.first()?.clone(); + let font = entry.get_mut("0")?.get_mut("0")?; + let kind = font.get("2").cloned(); + *font = E::Dict(vec![("0".into(), E::String(name.to_string()))]); + if let Some(kind) = kind { + font.set("2", kind); + } + fonts.push(entry); + i64::try_from(fonts.len() - 1).ok() +} + /// The highest text-object number honoured here (`TextIndex`, and the slot array [`build_txt2`] /// sizes). PSD text objects are numbered densely from 0; a crafted file can name object /// `i32::MAX`, and sizing the array by it would make every save allocate gigabytes. @@ -972,9 +1054,17 @@ pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool { /// 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). pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec { + build_txt2_at(objects, previous, 72.0) +} + +/// [`build_txt2`] for a document of `dpi` (the style runs of new and edited objects carry sizes +/// in the document's points, as EngineData does). +pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi: f32) -> 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(); + let mut fonts: Vec = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default(); + let fonts_before = fonts.len(); // 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); @@ -1002,12 +1092,19 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec // 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. let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten(); - // A new object keeps the template's structure, not its style: its style runs are generated. let reuse = kept.is_some() || edited.is_some(); + let unchanged = kept.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); + // A new or edited object takes the layer's own style: Photoshop reads the character style + // from these runs, so the template's (or the old text's) would show, and come back on + // reopen. They are built on one of the object's own runs, every key Photoshop wrote kept. + let styled = (!unchanged && !is_stub(&object)) + .then(|| model.path(&["6", "0"]).and_then(E::as_array).and_then(<[E]>::first).cloned()) + .flatten() + .and_then(|base| txt2_style_runs(layer, &base, &mut fonts, dpi)); 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(|| { + let runs = styled.or(file_runs).unwrap_or_else(|| { modes .iter() .map(|&(mode, len)| { @@ -1035,6 +1132,11 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec // `/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()); + if fonts.len() != fonts_before + && let Some(list @ E::Dict(_)) = outer.get_mut("0").and_then(|v| v.get_mut("1")) + { + list.set("0", E::Array(fonts)); + } 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))])), @@ -1115,6 +1217,7 @@ fn is_stub(object: &E) -> bool { /// 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 }; + let count = count.min(MAX_TEXT_INDEX as usize + 1); 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)]); @@ -1264,7 +1367,11 @@ pub fn set_text_index(tysh: &[u8], index: i32) -> Option> { let mut t = parse_tysh(tysh)?; match t.text.items.iter_mut().find(|(k, _)| k.is("TextIndex")) { Some(e) => e.1 = D::Integer(index), - None => t.text.items.push((Id::new("TextIndex"), D::Integer(index))), + // Where Photoshop writes it: right before `EngineData`. + None => { + let at = t.text.items.iter().position(|(k, _)| k.is("EngineData")).unwrap_or(t.text.items.len()); + t.text.items.insert(at, (Id::new("TextIndex"), D::Integer(index))); + } } Some(write_tysh(&t)) } @@ -1495,9 +1602,6 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec }; set(&mut text, "bounds", rect("bounds")); set(&mut text, "boundingBox", rect("boundingBox")); - if text.get("TextIndex").is_none() { - set(&mut text, "TextIndex", D::Integer(0)); - } set(&mut text, "EngineData", D::RawData(ed::write(&e))); let warp = match (&layer.warp, old.as_ref().and_then(|o| o.warp.clone())) { (Some(w), _) => Some( diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 95d8902e0b..5c18460b03 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1122,7 +1122,7 @@ fn edited_text_keeps_the_files_txt2_object() { 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])] + [("Bevex\nCD", vec![6, 3], vec![9]), ("AB\nCD\nEFG", vec![3, 3, 4], vec![10]), ("X", vec![2], vec![2]), ("AB\nCZ", vec![3, 3], vec![6])] { let t = runs_of(text, &[(text.len(), CharStyle::default())]); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); @@ -1136,13 +1136,16 @@ fn edited_text_keeps_the_files_txt2_object() { 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"); + // Photoshop reads the character style from these runs: they follow the layer's own runs + // (one here), built on the file's first run. 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"); + assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(f64::from(CharStyle::default().size_pt)), "{text:?}: the layer's font size"); + assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: the file's run keeps its other keys"); } } } @@ -1218,6 +1221,19 @@ fn new_text_layer_gets_a_txt2_object_and_frame() { assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2); } +/// The frame padding is bounded like the slots a layer can name: a crafted block of many empty +/// objects and no frames must not make the save write a frame for each of them. +#[test] +fn txt2_frame_padding_is_bounded() { + use crate::engine_data::Value as E; + let objects = "<< >> ".repeat(70_000); + let prev = format!("\n\n/98 << /0 7 >> /0 << /8 << /0 [ ] >> >> /1 << /1 [ {objects}] >> "); + let t = runs_of("AV", &[(2, CharStyle::default())]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(prev.as_bytes()))).unwrap(); + let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(); + assert_eq!(frames, crate::psd::MAX_TEXT_INDEX as usize + 1); +} + /// 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 e86e6910ae58d828cdc7b50dbb64dcb02662d50c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:07:39 +0200 Subject: [PATCH 62/69] fix(psd): bound the base style run a save copies per run The base run comes from the file and is cloned once per character run, so it now has to fit the template node budget, and each font is looked up once per name. Also names the reopened-layer tuple in psd_kerning for clippy::type_complexity. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/tests/psd_kerning.rs | 7 +++++-- crates/text/src/psd.rs | 8 +++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/psd_kerning.rs index 6eeebc43b4..ad492fce25 100644 --- a/crates/io/tests/psd_kerning.rs +++ b/crates/io/tests/psd_kerning.rs @@ -192,12 +192,15 @@ fn photoshop_txt2(objects: &[(&str, &str)]) -> Vec { format!("\n\n/98 << /0 7 >> /0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 ({bom}{font}) /2 1 >> >> >> ] >> /8 << /0 [ {frames}] >> >> /1 << /0 << /0 1 >> /1 [ {body}] /2 << /0 7 >> >>").chars().map(|c| c as u32 as u8).collect() } +/// A reopened type layer: its name, `TextIndex` and font features. +type Reopened = (String, Option, Vec); + /// A Photoshop file whose `Txt2` holds `objects` and whose type layers are `kept` (text and /// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one /// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's /// index and font features (but `calt`, on by default and written on as Photoshop does), and /// "Hi"'s style run in the saved `Txt2`. -fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec<(String, Option, Vec)>, E) { +fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec, E) { let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); for (text, index) in kept { let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]); @@ -231,7 +234,7 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> } /// The layer `name` in `add_type_layer`'s result. -fn named<'a>(layers: &'a [(String, Option, Vec)], name: &str) -> &'a (String, Option, Vec) { +fn named<'a>(layers: &'a [Reopened], name: &str) -> &'a Reopened { layers.iter().find(|l| l.0 == name).unwrap() } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 6d8fd8f454..aaa901eb2d 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -944,9 +944,10 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> { /// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking /// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode /// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit -/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary. +/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is +/// larger than a template may be (it is copied once per run). fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Option> { - if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) { + if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { return None; } let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; @@ -967,9 +968,10 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> } let default = CharStyle::default(); let mut out = Vec::with_capacity(spans.len()); + let mut looked_up = std::collections::HashMap::new(); for (ri, n) in spans { let s = runs.get(ri).map_or(&default, |r| &r.style); - let font = txt2_font(fonts, &postscript_for(s)); + let font = *looked_up.entry(postscript_for(s)).or_insert_with_key(|name| txt2_font(fonts, name)); let mut run = base.clone(); let style = run.get_mut("0")?.get_mut("0")?.get_mut("6")?; if let Some(font) = font { From 161d707c04c896f0a35265cc22ee388ff657b3f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:41:57 +0000 Subject: [PATCH 63/69] Integration: run retouch blur rows serially on wasm, for #5499 photocraft-algo declares rayon only for non-wasm targets; an unconditional rayon import broke the wasm build. Same cfg split as blur.rs. --- crates/algo/src/retouch.rs | 68 +++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index e7d33453d4..00388fe399 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -7,7 +7,6 @@ //! "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. @@ -130,35 +129,43 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option, (ty, dst): (usize, &mut [f32])| { + 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; } } } - // 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; } - }, - ); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + tmp.par_chunks_mut(stride).enumerate().for_each_init(scratch, horizontal); + } + #[cfg(target_arch = "wasm32")] + { + let mut row = scratch(); + tmp.chunks_mut(stride).enumerate().for_each(|item| horizontal(&mut row, item)); + } let mut out = vec![0.0f32; ow * oh * ch]; - out.par_chunks_mut(stride).enumerate().for_each(|(oy, dst)| { + let vertical = |(oy, dst): (usize, &mut [f32])| { 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; @@ -174,7 +181,16 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option Date: Tue, 20 Oct 2026 18:43:41 +0000 Subject: [PATCH 64/69] Integration: barrel check reads only the pen, not the global right button, for #6195 GetAsyncKeyState(VK_RBUTTON) reads the physical button system-wide: with swapped buttons a primary click read as right-down, and a right button held over another window counted too. --- apps/photocraft/src/tablet.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index bd9507f00b..19ba28996c 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -39,12 +39,11 @@ pub fn install_windows() -> Option { photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok() } -/// Physical right mouse button or stylus barrel button down check (Windows). +/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which +/// reports the logical secondary button (swapped buttons included) for the app's own window only. #[cfg(target_os = "windows")] pub fn right_button_service() -> Option { - Some(Box::new(|| { - photocraft_tablet::windows::is_barrel_button_down() || winsafe::GetAsyncKeyState(winsafe::co::VK::RBUTTON) - })) + Some(Box::new(photocraft_tablet::windows::is_barrel_button_down)) } #[cfg(not(target_os = "windows"))] From b86f5257247573d8ef355d31239c5deefc07d8d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:43:56 +0000 Subject: [PATCH 65/69] Integration: drop the clipboard CI job and the GPU evidence test, for #5587 The ci.yml job is a CI-policy change outside the fix; the ignored evidence test only wrote PR screenshots. --- .github/workflows/ci.yml | 34 -------------------------- crates/ui-egui/src/lib.rs | 50 --------------------------------------- 2 files changed, 84 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35db66d9ef..ba8243353d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,40 +289,6 @@ jobs: cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests -E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)' - # 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: timeout-minutes: 30 # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb507334f7..8bf2be90d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3059,56 +3059,6 @@ 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 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(read.lock().unwrap().clone()) - } - })), - ..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}; From 6265d67de9e60889a292078e755bf018ee6f3331 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:44:44 +0000 Subject: [PATCH 66/69] Integration: naming templates and more read-only formats use same_format, for #5857 With a naming template, Format Same still took the raw extension (IMG_0001_001.dng: unknown file format). HEIC, SVG, Cineon, PCX and XPM open but have no writer, so they save as PSD too; DDS has a writer and keeps its format. --- crates/engine/src/file_cmds.rs | 6 ++++- crates/engine/src/file_cmds/batch_naming.rs | 3 +-- crates/engine/src/file_cmds/tests.rs | 26 +++++++++++++-------- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 5909d40730..b563b302c5 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1149,7 +1149,8 @@ pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, sa /// 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"]; + const IMPORT_ONLY: &[&str] = + &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"]; 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 } } @@ -1160,6 +1161,9 @@ 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"); + assert_eq!(same_format("/in/IMG_0001.HEIC"), "psd"); + assert_eq!(same_format("/in/logo.svg"), "psd"); + assert_eq!(same_format("/in/scan.cin"), "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"); diff --git a/crates/engine/src/file_cmds/batch_naming.rs b/crates/engine/src/file_cmds/batch_naming.rs index e5dcc4a6e6..8e14bcf428 100644 --- a/crates/engine/src/file_cmds/batch_naming.rs +++ b/crates/engine/src/file_cmds/batch_naming.rs @@ -87,8 +87,7 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V if base.is_empty() || base.ends_with(['.', ' ']) { return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space")); } - 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" { super::same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; let name = format!("{base}.{ext}"); if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters")); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 6ef1ecce6a..9b5ec43a78 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -74,16 +74,22 @@ fn batch_same_format_saves_camera_raws_as_psd() { 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)); + // Also with a file naming template, which picks the output extension separately. + for (out, naming) in [("out", None), ("named", Some("{name}_{serial}"))] { + let out = join(&dir, out); + let mut p = json!({"steps": [["image.adjustments.invert", {}]], "input": dir, "output": out, "format": "same"}); + if let Some(naming) = naming { + p["naming"] = json!(naming); + } + let r = Session::new().execute("file.automate.batch", p).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{naming:?}: {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].contains("/IMG_0001") && files[0].ends_with(".psd") && files[1].contains("/b") && files[1].ends_with(".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] From 1f6c7fff60c94741c5c77be4ac959bb886b4b1c8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:45:06 +0000 Subject: [PATCH 67/69] Integration: keep the folder pickers for Render Video and Video Frames to Layers, for #3223 view_cmds runs before menus.rs, so wrapping these ids replaced main's folder pickers with a text-field dialog and a file picker; file_dialog's render_video test covers the folder route. --- crates/ui-egui/src/view_cmds.rs | 8 +------- crates/ui-egui/src/view_cmds/tests.rs | 9 +-------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 189c2ea9b0..1cb51a6eab 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -615,8 +615,6 @@ fn wraps(id: &str) -> bool { | "layer.videoLayers.newVideoLayerFromFile" | "layer.videoLayers.replaceFootage" | "layer.videoLayers.interpretFootage" - | "file.import.videoFramesToLayers" - | "file.export.renderVideo" ) } @@ -1256,7 +1254,7 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) app.with_document(doc, |app| app.run("layer.smartObjects.convertToLinked", json!({"layer": layer.0, "path": path}))) })) } - "layer.videoLayers.newVideoLayerFromFile" | "file.import.videoFramesToLayers" | "layer.videoLayers.replaceFootage" => { + "layer.videoLayers.newVideoLayerFromFile" | "layer.videoLayers.replaceFootage" => { let doc = match app.active_doc_id() { Ok(doc) => doc, Err(e) => return Some(Err(e)), @@ -1279,10 +1277,6 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) 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 f8e2f4116a..8496ad076d 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -508,7 +508,7 @@ 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"] { + for id in ["layer.videoLayers.newVideoLayerFromFile", "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; @@ -519,13 +519,6 @@ fn video_menu_commands_open_pickers_and_dialogs() { 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); } /// One frame of the shortcut handler with a plain Esc press. From f679b4cf1f16c26b1fe1a5d3e053bd2c3a774583 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:47:17 +0000 Subject: [PATCH 68/69] Integration: rustfmt the merged tree, for #6195 and #5857 cargo fmt --check failed on #6195's module order and canvas.rs guard, and on the IMPORT_ONLY line. --- crates/engine/src/file_cmds.rs | 3 +-- crates/tablet/src/lib.rs | 4 ++-- crates/ui-egui/src/canvas.rs | 6 +++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index b563b302c5..3cec3e4492 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1149,8 +1149,7 @@ pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, sa /// 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", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"]; + const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"]; 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 } } diff --git a/crates/tablet/src/lib.rs b/crates/tablet/src/lib.rs index 06a5c92bfa..78c17613ed 100644 --- a/crates/tablet/src/lib.rs +++ b/crates/tablet/src/lib.rs @@ -35,10 +35,10 @@ pub mod xi; #[cfg(target_os = "macos")] pub mod macos; -#[cfg(target_os = "linux")] -pub mod x11; #[cfg(target_os = "windows")] pub mod windows; +#[cfg(target_os = "linux")] +pub mod x11; /// One pen reading. Tilt is in degrees (-90..90, W3C Pointer Events convention: +x tilts to the /// right, +y towards the user), rotation in degrees 0..360 (clockwise). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 328b9f75e5..d2e535f3f6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4111,7 +4111,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None, // ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too, // unless Preferences › Cursors › Other Cursors asks for the precise crosshair. - t if app.alt_sampling || (app.drag.is_none() && !app.services.right_button_down.as_ref().is_some_and(|f| f()) && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => { + t if app.alt_sampling + || (app.drag.is_none() + && !app.services.right_button_down.as_ref().is_some_and(|f| f()) + && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => + { // While it samples (button held), the comparison ring replaces the cursor (#213). if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) { icon From 9ac1d5adc6540c63a78fcff1ba7bc83d74facb3f Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:05:34 +0000 Subject: [PATCH 69/69] Integration: regenerate docs/scorecard.md for the batch #7813's performance.gpu_adapter is read from the prefs file before the window opens (gpu_startup::read_gpu_adapter), which the prefs audit's heuristic doesn't see, so it counts as unread. --- docs/scorecard.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 7d9f0c952f..42ae824b4d 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 | 25 | 59 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 20 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 169 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 170 (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 | @@ -180,9 +180,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**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. +**Settings that do nothing: 26 of 170** (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`. +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.gpu_adapter`, `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 | |---|---|---|---|---|