From 5d3557281ea87108970f397794c4f3c332fb0da9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 13:02:10 +0200 Subject: [PATCH 01/63] Dropdowns: popups as tall as their items, up to the window (#5495) egui's ComboBox capped every list at the size its popup takes on the frame it opens (default_area_size, 400 pt), so the Layers panel's 27 blend modes scrolled even in tall windows. widgets::combo_list draws the button and its list itself (keeping egui's ids): the list is as tall as its options up to the visible window and scrolls only past that. It opens below the button at its left edge, or moves up over it to fit, never beside it, so it stays put while wheel steps change the value and the button's width (#1747). All five former .height(420) dropdowns use it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/adjust_editors.rs | 55 ++-- .../ui-egui/src/blend_dropdown_wheel_tests.rs | 11 +- crates/ui-egui/src/crop_presets.rs | 2 +- crates/ui-egui/src/type_tool.rs | 20 +- crates/ui-egui/src/widgets.rs | 305 ++++++++++++++---- 5 files changed, 282 insertions(+), 111 deletions(-) diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 2678cc65f2..605c4b8c58 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -1384,38 +1384,33 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { label(ui, tl!("Preset:")); let mut picked = None; let (press, close) = crate::press_menu::PressCombo::before(ui, cx.mem.with("gm-preset")); - let combo = egui::ComboBox::from_id_salt(cx.mem.with("gm-preset")) - .selected_text(tl!("Custom")) - .width(ui.available_width().min(190.0)) - .height(420.0) - .icon(widgets::chevron_icon) - .close_behavior(close) - .show_ui(ui, |ui| { - let gear = crate::icons::button(ui, "settings", 24.0, false, tl!("Gradient Preset Options")); - egui::containers::menu::SubMenu::new().show(ui, &gear, |ui| { - if ui.button(tl!("Import .GRD")).clicked() { - e.import_gradients = true; - ui.close(); - } - }); - ui.separator(); - for g in &cx.gradients { - ui.label(RichText::new(&g.name).color(t.text_faint)); - for p in &g.items { - ui.horizontal(|ui| { - // A map has no transparency: colour stops only, shown opaque. - let rgba: Vec<(f32, [f32; 4])> = p.resolve(fg, bg).into_iter().map(|(at, c)| (at, [c[0], c[1], c[2], 1.0])).collect(); - let (r, swatch) = ui.allocate_exact_size(vec2(48.0, 16.0), Sense::click()); - crate::preset_panels::paint_gradient(ui, r, &rgba); - // Both, not short-circuited: the name is drawn either way. - let name = ui.selectable_label(false, &p.name); - if press.chosen(ui, &swatch) | press.chosen(ui, &name) { - picked = Some(rgba.iter().map(|(at, c)| (*at, [c[0], c[1], c[2]])).collect::>()); - } - }); - } + let width = ui.available_width().min(190.0); + let combo = widgets::combo_list(ui, cx.mem.with("gm-preset"), tl!("Custom"), width, close, |ui| { + let gear = crate::icons::button(ui, "settings", 24.0, false, tl!("Gradient Preset Options")); + egui::containers::menu::SubMenu::new().show(ui, &gear, |ui| { + if ui.button(tl!("Import .GRD")).clicked() { + e.import_gradients = true; + ui.close(); } }); + ui.separator(); + for g in &cx.gradients { + ui.label(RichText::new(&g.name).color(t.text_faint)); + for p in &g.items { + ui.horizontal(|ui| { + // A map has no transparency: colour stops only, shown opaque. + let rgba: Vec<(f32, [f32; 4])> = p.resolve(fg, bg).into_iter().map(|(at, c)| (at, [c[0], c[1], c[2], 1.0])).collect(); + let (r, swatch) = ui.allocate_exact_size(vec2(48.0, 16.0), Sense::click()); + crate::preset_panels::paint_gradient(ui, r, &rgba); + // Both, not short-circuited: the name is drawn either way. + let name = ui.selectable_label(false, &p.name); + if press.chosen(ui, &swatch) | press.chosen(ui, &name) { + picked = Some(rgba.iter().map(|(at, c)| (*at, [c[0], c[1], c[2]])).collect::>()); + } + }); + } + } + }); press.after(&combo.response); if let Some(p) = picked { stops = p; diff --git a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs index c6b9186f86..31b28adbda 100644 --- a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs +++ b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs @@ -165,16 +165,17 @@ fn wheel_steps_in_the_open_list_keep_the_chosen_mode_in_view() { let mut h = harness(pixel_layer); let at = dropdown(&h).center(); click(&mut h, at); - // Unscrolled, the list shows its first entry at the top and is at most 420 pt tall. - let (top, darken) = (entry(&h, "Normal").top(), entry(&h, "Darken").center()); + // The open list, unscrolled, shows its first entry at the top. + let list = h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))).unwrap(); + let darken = entry(&h, "Darken").center(); for _ in 0..22 { wheel(&mut h, darken, -1.0); } h.run_steps(30); assert_eq!(blend(&h), BlendMode::Divide); - let in_view = |r: Rect| r.top() >= top - 0.5 && r.bottom() <= top + 420.0 + 0.5; + let in_view = |r: Rect| list.contains_rect(r); let chosen = entry(&h, "Divide"); - assert!(in_view(chosen), "{chosen:?} in view below {top}"); + assert!(in_view(chosen), "{chosen:?} in view in {list:?}"); // The wheel over the button scrolls the open list along too. for _ in 0..21 { wheel(&mut h, at, 1.0); @@ -182,7 +183,7 @@ fn wheel_steps_in_the_open_list_keep_the_chosen_mode_in_view() { h.run_steps(30); assert_eq!(blend(&h), BlendMode::Dissolve); let chosen = entry(&h, "Dissolve"); - assert!(in_view(chosen), "{chosen:?} in view below {top}"); + assert!(in_view(chosen), "{chosen:?} in view in {list:?}"); } /// The mode the canvas previews on the active layer, if any (#970). diff --git a/crates/ui-egui/src/crop_presets.rs b/crates/ui-egui/src/crop_presets.rs index e049fdfa8c..33966178c0 100644 --- a/crates/ui-egui/src/crop_presets.rs +++ b/crates/ui-egui/src/crop_presets.rs @@ -142,7 +142,7 @@ pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let current_user = users.iter().rev().find(|(_, f)| matches(o, f)).map(|(n, _)| n.clone()); let can_new = savable(o).is_some(); let mut chosen: Option = None; - egui::ComboBox::from_id_salt("crop-ratio").selected_text(label).width(140.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| { + crate::widgets::combo_list(ui, "crop-ratio", label, 140.0, egui::PopupCloseBehavior::CloseOnClick, |ui| { let (before, after): (Vec<_>, Vec<_>) = builtin.iter().partition(|(k, _)| k != crop_size::FRONT); for (k, l) in &before { if ui.selectable_label(current_user.is_none() && *k == o.crop_ratio, tl!(l)).clicked() { diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f00fb57157..286b05e8c1 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1884,20 +1884,14 @@ fn kerning_field(ui: &mut egui::Ui, shown: &str, width: f32) -> Option(ui: &mut Ui, id: &str, current: &mut T, o let mut reveal = wheel && ui.data_mut(|d| d.remove_temp::(reveal_id)).unwrap_or(false); // The list opens on the press: press, drag onto an option and release to choose it (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, id); - let response = - egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); - // A typed letter jumps to the next option starting with it, ↩ closes the list (#1481). - let labels: Vec<&str> = options.iter().map(|(_, l)| tl!(l)).collect(); - let shown = options.iter().position(|(v, _)| v == current); - if let Some((v, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { + let response = combo_list(ui, id, label, width, close, |ui| { + fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); + // A typed letter jumps to the next option starting with it, ↩ closes the list (#1481). + let labels: Vec<&str> = options.iter().map(|(_, l)| tl!(l)).collect(); + let shown = options.iter().position(|(v, _)| v == current); + if let Some((v, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { + *current = v.clone(); + chosen.push(v.clone()); + reveal = true; + keyed = true; + } + // Over the open list the wheel steps the value too, instead of scrolling the list. + over_list = wheel && ui.rect_contains_pointer(ui.clip_rect()); + if over_list { + let stepped = wheel_steps(ui, wheel_id, true, current, options); + wheeled = !stepped.is_empty(); + reveal |= wheeled; + chosen.extend(stepped); + } + // The row nearest the pointer, by its distance to each row. + let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); + let mut nearest: Option<(f32, &T)> = None; + for (v, l) in options { + let item = ui.selectable_label(v == current, tl!(l)); + if reveal && v == current { + item.scroll_to_me(None); + } + if item.hovered() { + hovered = Some(v.clone()); + } + if let Some(p) = pointer { + let d = (item.rect.top() - p.y).max(p.y - item.rect.bottom()).max(0.0); + if nearest.is_none_or(|(n, _)| d < n) { + nearest = Some((d, v)); + } + } + if press.chosen(ui, &item) { *current = v.clone(); chosen.push(v.clone()); - reveal = true; - keyed = true; } - // Over the open list the wheel steps the value too, instead of scrolling the list. - over_list = wheel && ui.rect_contains_pointer(ui.clip_rect()); - if over_list { - let stepped = wheel_steps(ui, wheel_id, true, current, options); - wheeled = !stepped.is_empty(); - reveal |= wheeled; - chosen.extend(stepped); - } - // The row nearest the pointer, by its distance to each row. - let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); - let mut nearest: Option<(f32, &T)> = None; - for (v, l) in options { - let item = ui.selectable_label(v == current, tl!(l)); - if reveal && v == current { - item.scroll_to_me(None); - } - if item.hovered() { - hovered = Some(v.clone()); - } - if let Some(p) = pointer { - let d = (item.rect.top() - p.y).max(p.y - item.rect.bottom()).max(0.0); - if nearest.is_none_or(|(n, _)| d < n) { - nearest = Some((d, v)); - } - } - if press.chosen(ui, &item) { - *current = v.clone(); - chosen.push(v.clone()); - } - } - // Between two rows (item spacing) the pointer hovers neither; it is still over the list, - // so keep previewing the nearest row instead of flashing the current value back (#2553). - if hovered.is_none() { - hovered = nearest.map(|(_, v)| v.clone()); - } - }); + } + // Between two rows (item spacing) the pointer hovers neither; it is still over the list, + // so keep previewing the nearest row instead of flashing the current value back (#2553). + if hovered.is_none() { + hovered = nearest.map(|(_, v)| v.clone()); + } + }); press.after(&response.response); if combo_box_arrow_keys(ui, &response.response, current, options) { chosen.push(current.clone()); @@ -1473,14 +1472,117 @@ fn combo_box_arrow_keys(ui: &mut Ui, response: &Response, /// off the bottom. egui moves a popup up to keep it in the window, so a menu opened low on the /// screen first shifts up and only scrolls when it is taller than the window. pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - // The popup frame's margin and stroke, and a small gap to the window's edges. - let frame = ui.spacing().menu_margin.sum().y + 2.0 + 2.0 * MENU_EDGE; - let room = (crate::work_area::visible_rect(ui.ctx()).height() - frame).max(MENU_MIN_HEIGHT); + let room = popup_max_height(ui.ctx(), ui.spacing()); // A popup's Ui is only as tall as the popup was last frame (400 pt on the first), so ask for // the whole room: the area still shrinks to its rows when they need less. egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner } +/// The most a popup's rows may take: the part of the window that can be seen (clear of a taskbar a +/// too-tall window runs under, #315), less the popup frame's margin and stroke and a small gap to +/// the window's edges. egui moves a popup up to keep it in the window, so a popup this tall fits. +pub fn popup_max_height(ctx: &egui::Context, spacing: &egui::style::Spacing) -> f32 { + let frame = spacing.menu_margin.sum().y + 2.0 + 2.0 * MENU_EDGE; + (crate::work_area::visible_rect(ctx).height() - frame).max(MENU_MIN_HEIGHT) +} + +/// A dropdown: a button showing `selected_text` (at least `width` wide, wider for a longer text) +/// whose list (`add_contents`) opens below it on a click. The list is as tall as its options up to +/// the part of the window that can be seen, scrolling only past that (#5495): Photoshop's dropdowns +/// open at full height. egui's `ComboBox` capped every list at the size its popup takes on the +/// frame it opens (`default_area_size`, 400 pt), so the Layers panel's 27 blend modes scrolled. +/// +/// The list's left edge is the button's, and a list that doesn't fit below the button moves up +/// over it to stay in the window, never beside it: so it stays put while its value (and with it +/// the button's width) changes, as the wheel steps it over the open list (#1747). The ids are +/// egui's `ComboBox` ones, so `ComboBox::is_open` and [`crate::press_menu::PressCombo`] apply. +pub fn combo_list( + ui: &mut Ui, + id_salt: impl egui::AsIdSalt, + selected_text: impl Into, + width: f32, + close: egui::PopupCloseBehavior, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> egui::InnerResponse> { + let button_id = ui.make_persistent_id(egui::IdSalt::new(id_salt)); + let popup_id = button_id.with("popup"); + let selected_text = selected_text.into(); + ui.horizontal(|ui| { + let ctx = ui.ctx().clone(); + let button = combo_button(ui, button_id, &selected_text, width, egui::Popup::is_id_open(&ctx, popup_id)); + let enabled = ui.is_enabled(); + button.widget_info(|| { + let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox); + info.enabled = enabled; + info.current_text_value = Some(selected_text.text().to_owned()); + info + }); + let room = popup_max_height(&ctx, ui.spacing()); + // Below the button, or as far up as the list's height (from the last frame) needs. + let visible = crate::work_area::visible_rect(&ctx).shrink(MENU_EDGE); + let height = egui::AreaState::load(&ctx, popup_id).and_then(|s| s.size).map_or(0.0, |s| s.y); + let top = button.rect.bottom().min(visible.bottom() - height).max(visible.top()); + let left = button.rect.left(); + let inner = egui::Popup::menu(&button) + .id(popup_id) + .anchor(Rect::from_x_y_ranges(left..=left, top..=top)) + .align(egui::RectAlign::BOTTOM_START) + .align_alternatives(&[]) + .width(button.rect.width()) + .close_behavior(close) + .style(egui::style::StyleModifier::default()) + .show(|ui| { + ui.set_min_width(ui.available_width()); + // A popup's Ui is only as tall as the popup was last frame (400 pt on the first), + // so ask for the whole room: the area still shrinks to its rows when they need less. + egui::ScrollArea::vertical() + .max_height(room) + .min_scrolled_height(room) + .show(ui, |ui| { + // Options don't wrap in a list as narrow as its button: they widen it. + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + add_contents(ui) + }) + .inner + }) + .map(|r| r.inner); + egui::InnerResponse::new(inner, button) + }) + .inner +} + +/// The button of a [`combo_list`], drawn as egui's `ComboBox` draws its own. +fn combo_button(ui: &mut Ui, id: egui::Id, text: &egui::WidgetText, width: f32, open: bool) -> Response { + let background = ui.painter().add(egui::Shape::Noop); + let margin = ui.spacing().button_padding; + let min_height = ui.spacing().interact_size.y; + let mut outer = ui.available_rect_before_wrap(); + outer.set_height(outer.height().max(min_height)); + let mut content = ui.new_child(egui::UiBuilder::new().max_rect(outer.shrink2(margin))); + let icon_spacing = content.spacing().icon_spacing; + let icon_size = Vec2::splat(content.spacing().icon_width); + let wrap_mode = content.wrap_mode(); + let wrap_width = if wrap_mode == egui::TextWrapMode::Extend { f32::INFINITY } else { content.available_width() - icon_spacing - icon_size.x }; + let galley = text.clone().into_galley(&content, Some(wrap_mode), wrap_width, egui::TextStyle::Button); + let size = vec2((galley.size().x + icon_spacing + icon_size.x).max(width - 2.0 * margin.x), galley.size().y.max(icon_size.y)); + let (_, rect) = content.allocate_space(size); + let mut outer = content.min_rect().expand2(margin); + outer.set_height(outer.height().max(min_height)); + let response = ui.interact(outer, id, Sense::click()); + if ui.is_rect_visible(outer) { + let visuals = if open { &ui.visuals().widgets.open } else { ui.style().interact(&response) }; + let shape = + egui::epaint::RectShape::new(outer.expand(visuals.expansion), visuals.corner_radius, visuals.weak_bg_fill, visuals.bg_stroke, StrokeKind::Inside); + ui.painter().set(background, shape); + let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect); + chevron_icon(ui, icon_rect.expand(visuals.expansion), visuals, open); + let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect); + ui.painter().galley(text_rect.min, galley, visuals.text_color()); + } + ui.advance_cursor_after_rect(outer); + response +} + /// Gap kept between a context menu and the window's edges, and the shortest it gets. const MENU_EDGE: f32 = 4.0; const MENU_MIN_HEIGHT: f32 = 120.0; @@ -1519,23 +1621,22 @@ pub fn dropdown_with_tooltips(ui: &mut Ui, id: &str, curre let label = options.iter().find(|(v, _, _)| v == current).map(|(_, l, _)| tl!(l)).unwrap_or("—"); let mut changed = false; let (press, close) = crate::press_menu::PressCombo::before(ui, id); - let response = - egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l, _)| tl!(l))); - let labels: Vec<&str> = options.iter().map(|(_, l, _)| tl!(l)).collect(); - let shown = options.iter().position(|(v, _, _)| v == current); - if let Some((v, _, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { + let response = combo_list(ui, id, label, width, close, |ui| { + fit_widest_option(ui, options.iter().map(|(_, l, _)| tl!(l))); + let labels: Vec<&str> = options.iter().map(|(_, l, _)| tl!(l)).collect(); + let shown = options.iter().position(|(v, _, _)| v == current); + if let Some((v, _, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { + *current = v.clone(); + changed = true; + } + for (v, l, tip) in options { + let item = ui.selectable_label(v == current, tl!(l)).on_hover_text(tl!(tip)); + if press.chosen(ui, &item) { *current = v.clone(); changed = true; } - for (v, l, tip) in options { - let item = ui.selectable_label(v == current, tl!(l)).on_hover_text(tl!(tip)); - if press.chosen(ui, &item) { - *current = v.clone(); - changed = true; - } - } - }); + } + }); press.after(&response.response); if let Some((_, _, tip)) = options.iter().find(|(v, _, _)| v == current) { let _ = response.response.on_hover_text(tl!(tip)); @@ -2237,6 +2338,86 @@ mod tests { } } + /// A window `height` tall with the Layers panel's blend mode dropdown (all 27 modes, wheel + /// steps them) `down` from its top, opened. + fn open_blend_modes(height: f32, down: f32) -> Harness<'static, photocraft_color::BlendMode> { + use photocraft_color::BlendMode; + let mut h = Harness::builder().with_size(egui::vec2(400.0, height)).build_ui_state( + move |ui, mode: &mut BlendMode| { + ui.add_space(down); + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + super::dropdown_wheel_hovered(ui, "blend-mode", mode, &opts, 120.0); + }, + BlendMode::Normal, + ); + crate::theme::apply(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(4); + h + } + + /// The open list's rect. + fn open_list(h: &Harness<'_, T>) -> egui::Rect { + let list = h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))); + let Some(list) = list else { panic!("the list is open") }; + list + } + + /// Each mode's row in the open list: its label's last node, as the box shows the chosen one's. + fn mode_rows(h: &Harness<'_, T>) -> Vec { + use photocraft_color::BlendMode; + BlendMode::LAYER_MODES.iter().map(|m| h.get_all_by_label(m.label()).last().map(|n| n.rect()).unwrap_or(egui::Rect::NOTHING)).collect() + } + + /// Photoshop opens the Layers panel's blend modes at full height when the window has room, no + /// scroll bar (#5495): not capped at a fixed height. A short window still scrolls the list, + /// which stays inside the window. + #[test] + fn dropdown_list_is_as_tall_as_its_options_up_to_the_window() { + let h = open_blend_modes(1000.0, 0.0); + let (list, rows) = (open_list(&h), mode_rows(&h)); + for (i, row) in rows.iter().enumerate() { + assert!(list.contains_rect(*row), "row {i} {row:?} shows in the list {list:?}"); + } + let h = open_blend_modes(300.0, 0.0); + let (list, rows) = (open_list(&h), mode_rows(&h)); + assert!(list.top() >= 0.0 && list.bottom() <= 300.0, "the list {list:?} stays in the window"); + assert!(list.height() > 200.0, "the list {list:?} uses the window's height"); + assert!(rows.last().is_some_and(|r| !list.contains_rect(*r)), "the last rows scroll: {rows:?}"); + } + + /// The open list opens at the button's left edge, below it or moved up over it to fit, and + /// stays put while the wheel steps through every mode over it (#1747), although the button + /// widens for the longer names (#5495). + #[test] + fn dropdown_list_stays_put_while_the_wheel_steps_its_value() { + use photocraft_color::BlendMode; + // A tall window with room below the button, and a 900 pt one with the button half way down. + for (height, down) in [(1000.0, 0.0), (900.0, 420.0)] { + let mut h = open_blend_modes(height, down); + let button = h.get_by_role(egui::accesskit::Role::ComboBox).rect(); + let list = open_list(&h); + assert!(list.top() >= 0.0 && list.bottom() <= height, "{height}: the list {list:?} is in the window"); + assert!((list.left() - button.left()).abs() < 8.0, "{height}: the list {list:?} starts at the button {button:?}"); + let rows = mode_rows(&h); + assert!(rows.iter().all(|r| list.contains_rect(*r)), "{height}: all modes show in {list:?}"); + let over = list.center(); + for _ in 1..BlendMode::LAYER_MODES.len() { + h.hover_at(over); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(3); + assert_eq!(open_list(&h), list, "{height}: the list stays put at {:?}", h.state()); + } + assert_eq!(*h.state(), BlendMode::Luminosity, "{height}: the wheel stepped through every mode"); + } + } + const MODES: [(usize, &str); 4] = [(0, "Normal"), (1, "Dissolve"), (2, "Darken"), (3, "Multiply")]; /// A wheel dropdown (or a plain one) of [`MODES`], whose state is the chosen index. From 097027edb0186f0321b2fe5eb29c4dca3640efb6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 14:06:18 +0200 Subject: [PATCH 02/63] Layers: separators between the Blend Mode groups (#5532) Photoshop's blend mode lists draw a line between their six groups (Normal, Dissolve | darken | lighten | contrast | difference | colour). The Layers panel's Blend Mode list and the options bar's Mode lists (Brush/Pencil, Gradient) now do too. The lines are only drawn: options, order, wheel, arrow keys, type-ahead and hover preview are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/blend_dropdown_groups_tests.rs | 64 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels.rs | 21 +++++- crates/ui-egui/src/widgets.rs | 53 +++++++++++++-- 4 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/blend_dropdown_groups_tests.rs diff --git a/crates/ui-egui/src/blend_dropdown_groups_tests.rs b/crates/ui-egui/src/blend_dropdown_groups_tests.rs new file mode 100644 index 0000000000..de3d1d7d93 --- /dev/null +++ b/crates/ui-egui/src/blend_dropdown_groups_tests.rs @@ -0,0 +1,64 @@ +//! #5532: the Layers panel's Blend Mode list draws a line between Photoshop's six groups of modes: +//! Normal, Dissolve | Darken … Darker Color | Lighten … Lighter Color | Overlay … Hard Mix | +//! Difference … Divide | Hue … Luminosity. + +use egui::accesskit::Role; +use egui::{Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::BlendMode; +use serde_json::json; + +use crate::PhotocraftApp; + +/// The app with a new document and a pixel layer above the Background, active. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }); + h.run_steps(8); + h +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) { + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.run_steps(1); + } + h.run_steps(2); +} + +#[test] +fn the_layers_blend_mode_list_has_a_line_between_each_group() { + let mut h = harness(); + // The Layers panel's dropdown: the lowest one (the options bar and the title bar have some too). + let button = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click(&mut h, button.center()); + let list = h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))).unwrap(); + // Each mode's row in the open list. + let rows: Vec = BlendMode::LAYER_MODES + .iter() + .map(|m| h.get_all_by_label(m.label()).map(|n| n.rect()).rfind(|r| list.contains_rect(*r)).unwrap_or_else(|| panic!("{m:?} is in the list"))) + .collect(); + let gaps: Vec = rows.windows(2).map(|w| w[1].top() - w[0].bottom()).collect(); + let row_gap = gaps.iter().copied().fold(f32::INFINITY, f32::min); + let lines: Vec<(BlendMode, BlendMode)> = + gaps.iter().enumerate().filter(|(_, g)| **g > row_gap + 2.0).map(|(i, _)| (BlendMode::LAYER_MODES[i], BlendMode::LAYER_MODES[i + 1])).collect(); + assert_eq!( + lines, + [ + (BlendMode::Dissolve, BlendMode::Darken), + (BlendMode::DarkerColor, BlendMode::Lighten), + (BlendMode::LighterColor, BlendMode::Overlay), + (BlendMode::HardMix, BlendMode::Difference), + (BlendMode::Divide, BlendMode::Hue), + ], + "row gaps {gaps:?}" + ); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38118955c3..56f7796f9f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2563,6 +2563,9 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod blend_dropdown_groups_tests; + #[cfg(test)] mod options_bar_mode_wheel_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 733f923c8a..0d84710549 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1263,7 +1263,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); // The wheel over it steps the mode, as over the Layers panel's (#2061). - widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + widgets::dropdown_wheel_grouped( + ui, + "gradient-blend-mode", + &mut app.ui.tool_options.gradient_blend_mode, + &opts, + 96.0, + |m| starts_blend_group(*m), + ); opt_label(ui, tl!("Opacity")); let r = widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); // Live while scrubbed, one undo step per scrub (#5012). @@ -2149,6 +2156,13 @@ fn blend_options(groups: bool, current: BlendMode) -> Vec<(BlendMode, &'static s std::iter::once(BlendMode::PassThrough).filter(|_| groups).chain(BlendMode::LAYER_MODES).chain(own).map(|m| (m, m.label())).collect() } +/// Whether blend mode `m` starts one of the groups Photoshop's blend mode lists draw a line above: +/// Normal, Dissolve (and Pass Through, Clear) | darken | lighten | contrast | difference | colour +/// (#5532). The first mode of a list never gets a line. +pub(crate) fn starts_blend_group(m: BlendMode) -> bool { + matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) +} + /// Scroll the Layers panel while holding a layer drag over its top/bottom edge or past them. /// /// Returns the *content* displacement in points for this frame, so positive moves the @@ -2240,7 +2254,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") }; let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); - let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width); + let (chosen, hovered) = + widgets::dropdown_wheel_grouped(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width, |m| starts_blend_group(*m)); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); @@ -3748,7 +3763,7 @@ fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::B let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); opts.insert(at, (None, "Clear")); let mut mode = if *clear || held { None } else { Some(b.mode) }; - if !widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0.is_empty() { + if !widgets::dropdown_wheel_grouped(ui, id, &mut mode, &opts, 96.0, |m| m.is_some_and(starts_blend_group)).0.is_empty() { *clear = mode.is_none(); if let Some(m) = mode { b.mode = m; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e642dfc3e6..bfaab548e7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1295,7 +1295,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &mut T, op /// [`dropdown`], also returning the option under the pointer in its open list (live previews). pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option) { - let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false); + let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false, &|_| false); (!chosen.is_empty(), hovered) } @@ -1306,10 +1306,32 @@ pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &m /// in order (one per notch; the last is `current`) and the option under the pointer in the open /// list, none while the pointer rests where the wheel last stepped. pub fn dropdown_wheel_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (Vec, Option) { - dropdown_with(ui, id, current, options, width, true) + dropdown_with(ui, id, current, options, width, true, &|_| false) } -fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool) -> (Vec, Option) { +/// [`dropdown_wheel_hovered`] whose open list draws a line above each option (but the first) that +/// `starts_group`: Photoshop's blend mode lists group their modes (#5532). The lines are only +/// drawn: the options, their order and how the wheel, the keys and the pointer pick them stay. +pub fn dropdown_wheel_grouped( + ui: &mut Ui, + id: &str, + current: &mut T, + options: &[(T, &str)], + width: f32, + starts_group: impl Fn(&T) -> bool, +) -> (Vec, Option) { + dropdown_with(ui, id, current, options, width, true, &starts_group) +} + +fn dropdown_with( + ui: &mut Ui, + id: &str, + current: &mut T, + options: &[(T, &str)], + width: f32, + wheel: bool, + starts_group: &dyn Fn(&T) -> bool, +) -> (Vec, Option) { let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| tl!(l)).unwrap_or("—"); let (mut chosen, mut hovered) = (Vec::new(), None); // The wheel's notches add up over the button and the open list alike. @@ -1344,7 +1366,10 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o // The row nearest the pointer, by its distance to each row. let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); let mut nearest: Option<(f32, &T)> = None; - for (v, l) in options { + for (i, (v, l)) in options.iter().enumerate() { + if i > 0 && starts_group(v) { + list_separator(ui); + } let item = ui.selectable_label(v == current, tl!(l)); if reveal && v == current { item.scroll_to_me(None); @@ -1388,6 +1413,15 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o (chosen, hovered) } +/// A line between two groups of an open dropdown list's options, as wide as the list. Like egui's +/// separator it takes no width while egui sizes the list, so it never widens it. +fn list_separator(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let width = if ui.is_sizing_pass() { 0.0 } else { ui.available_width() }; + let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); + ui.painter().hline(rect.x_range(), rect.center().y, Stroke::new(1.0, t.separator)); +} + /// Makes an open dropdown list as wide as its widest option is when hovered, from the start. egui /// takes a plain row's frame stroke off its padding but draws a hovered row with the stroke, so a /// row grows by two stroke widths under the pointer, and the list never shrinks back: scrolling @@ -2387,6 +2421,17 @@ mod tests { assert!(rows.last().is_some_and(|r| !list.contains_rect(*r)), "the last rows scroll: {rows:?}"); } + /// A dropdown not given groups draws no lines between its options: the rows are evenly spaced + /// (#5532, only blend mode lists group theirs). + #[test] + fn dropdown_list_without_groups_has_no_separators() { + let h = open_blend_modes(1000.0, 0.0); + let rows = mode_rows(&h); + let gaps: Vec = rows.windows(2).map(|w| w[1].top() - w[0].bottom()).collect(); + let (lo, hi) = gaps.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), g| (lo.min(*g), hi.max(*g))); + assert!(hi - lo < 1.0, "even row gaps: {gaps:?}"); + } + /// The open list opens at the button's left edge, below it or moved up over it to fit, and /// stays put while the wheel steps through every mode over it (#1747), although the button /// widens for the longer names (#5495). From 0a83e254899fc943f268ae7ef3106d0134968c84 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 12:48:20 +0200 Subject: [PATCH 03/63] WIP: History snapshots: follow-ups to #3567 (#6087) Co-Authored-By: Claude Opus 5.5 (1M context) From 15753a69a136f6426a18e47431005c40f00f4d04 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 13:43:18 +0200 Subject: [PATCH 04/63] History snapshots: follow-ups to #3567 (#6087) - New Snapshot dialog: snapshot_dialog::open returns None while a Free Transform is open, so the Alt-click (and any other caller) no longer opens it under the transform box. - A cleared Name in the dialog now takes the next default snapshot name instead of failing; the engine still rejects an empty name from agents. - Snapshot thumbnails remember the snapshot document they show: a document reopened with the same id, or a refreshed first snapshot, gets a fresh thumbnail, and closing a document drops its thumbnails (sync_views). Pruning deleted snapshots runs once per panel draw, not per row. - Session::refresh_type_layers also re-renders the type layers of the first snapshot (the document as opened), so returning to it keeps the served font. - Housekeeping: snapshot_rows doc comment, the dialog's select-all uses field_tab::select_all, and the New Snapshot block no longer splits the Free Transform comment from its code. cargo fmt also reformats two test lines in rotate_view.rs and wheel_nav.rs that came in with main. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/lib.rs | 55 ++++++++++++++-------- crates/engine/src/type_cmds.rs | 35 ++++++++++++++ crates/ui-egui/src/lib.rs | 25 ++++++---- crates/ui-egui/src/panels.rs | 40 ++++++++++++++-- crates/ui-egui/src/rotate_view.rs | 5 +- crates/ui-egui/src/snapshot_dialog.rs | 66 ++++++++++++++++++++++++--- crates/ui-egui/src/wheel_nav.rs | 3 +- 7 files changed, 186 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 33c6e6a5a4..8b2c969283 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -231,6 +231,20 @@ pub struct DocState { next_snapshot: u64, } +/// `doc` with its type layers among `ids` re-rendered (`Session::refresh_type_layers`), or `None` +/// when none of them is a type layer of `doc`. +fn refresh_text_layers(doc: &Arc, ids: &[LayerId]) -> Option { + let mut out = (**doc).clone(); + let mut changed = false; + for id in ids { + if let Some(photocraft_doc::Layer { content: photocraft_doc::LayerContent::Text(t), .. }) = out.layer_mut(*id) { + type_cmds::refresh(doc, t); + changed = true; + } + } + changed.then_some(out) +} + impl DocState { pub fn new(doc: Document, path: Option) -> Self { let active_layer = doc.top_layer(); @@ -795,30 +809,35 @@ impl Session { } } for doc_id in docs { - let mine = layers.iter().filter(|(d, _)| *d == doc_id); + let mine: Vec = layers.iter().filter(|(d, _)| *d == doc_id).map(|(_, id)| *id).collect(); if self.job_on(doc_id).is_some() { - busy.extend(mine); + busy.extend(mine.iter().map(|id| (doc_id, *id))); continue; } let Some(st) = self.docs.iter_mut().find(|st| st.doc.id == doc_id) else { continue }; - let snapshot = st.doc.clone(); - let mut doc = (*snapshot).clone(); - let mut changed = false; - for (_, id) in mine { - if let Some(photocraft_doc::Layer { content: photocraft_doc::LayerContent::Text(t), .. }) = doc.layer_mut(*id) { - type_cmds::refresh(&snapshot, t); - changed = true; - } + // The first snapshot is the document as opened (#2966, #6087): it was taken before the + // font arrived, so its type layers get the same refresh, or returning to it brings the + // fallback-font pixels back. While it still is the open document, it shares the + // refreshed one. + let first_is_doc = st.snapshots.iter().any(|s| s.id == 0 && Arc::ptr_eq(&s.doc, &st.doc)); + if !first_is_doc + && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) + && let Some(doc) = refresh_text_layers(&first.doc, &mine) + { + first.doc = Arc::new(doc); } - if changed { - let clean = st.saved_revision == st.revision; - st.doc = Arc::new(doc); - st.revision += 1; - if clean { - st.saved_revision = st.revision; - } - st.last_damage = None; + let Some(doc) = refresh_text_layers(&st.doc, &mine) else { continue }; + let doc = Arc::new(doc); + if first_is_doc && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) { + first.doc = doc.clone(); } + let clean = st.saved_revision == st.revision; + st.doc = doc; + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + st.last_damage = None; } busy } diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 4bf193b679..5277470693 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -1195,6 +1195,41 @@ mod tests { assert!(s.active().unwrap().doc.layer(id).is_none()); } + /// The first History snapshot is the document as opened, taken before a served font arrived: + /// the refresh re-renders its type layers too, so returning to it keeps the real font (#6087). + #[test] + fn refreshing_type_layers_refreshes_the_first_snapshot() { + let mut s = session(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + let mut stale = (*s.active().unwrap().doc).clone(); + let Some(Layer { content: LayerContent::Text(t), .. }) = stale.layer_mut(id) else { panic!("not a type layer") }; + t.cache = None; + let snap_cache = |s: &Session| match &s.active().unwrap().snapshots[0].doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.cache.is_some(), + other => panic!("{}", other.kind_name()), + }; + // Opened with the fallback font: the first snapshot is the open document itself. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + assert!(!snap_cache(&s)); + assert!(s.refresh_type_layers(&[(doc_id, id)]).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some()); + assert!(snap_cache(&s), "the first snapshot is refreshed with the document"); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "and still shares it"); + // Edited since it opened: the snapshot is refreshed on its own, the edit stays. + s.add_document(stale, None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + let layers = s.active().unwrap().doc.layers.len(); + assert!(s.refresh_type_layers(&[(doc_id, id), (doc_id, LayerId(9999))]).is_empty()); + assert!(snap_cache(&s)); + assert!(text_layer(&s, id.0).cache.is_some()); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers); + assert_eq!(st.snapshots[0].doc.layers.len(), layers - 1, "the snapshot keeps the document as opened"); + } + #[test] fn create_renders_and_is_undoable() { let mut s = session(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c9f4605839..05727e3908 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -621,9 +621,10 @@ pub struct PhotocraftApp { pub fps: f32, last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, - /// History snapshot thumbnails by (document, snapshot id): a snapshot never changes, so each - /// is made once (`snapshot_thumb`, #2966). - snapshot_thumbs: HashMap<(DocId, u64), egui::TextureHandle>, + /// History snapshot thumbnails by (document, snapshot id), each with the snapshot document it + /// shows (`snapshot_thumb`, #2966): made once, and again only when that is a different one (a + /// preserved-ID reopen, or the first snapshot refreshed for a served font, #6087). + snapshot_thumbs: HashMap<(DocId, u64), (std::sync::Weak, egui::TextureHandle)>, /// Snapshots whose live layer/mask keys were last used to prune thumbnail handles. thumb_documents: Vec<(DocId, std::sync::Weak)>, /// Content bounds cached per (key, revision): scanning a 36 MP layer every frame cost ~77 ms. @@ -1162,6 +1163,7 @@ impl PhotocraftApp { } self.canvases.retain(|(doc, _), _| ids.contains(doc)); self.navigator_textures.retain(|doc, _| ids.contains(doc)); + self.snapshot_thumbs.retain(|(doc, _), _| ids.contains(doc)); self.view_docs = ids; self.prune_thumbs(); self.sync_mask_targets(); @@ -2069,11 +2071,18 @@ impl PhotocraftApp { self.store_thumb(ctx, key, rev, img) } - /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, - /// centred in a square. Thumbnails of `doc`'s snapshots not in `live` are dropped. - pub fn snapshot_thumb(&mut self, ctx: &egui::Context, doc: DocId, snap: &photocraft_engine::history_cmds::Snapshot, live: &[u64]) -> egui::TextureId { + /// Drops the thumbnails of `doc`'s snapshots not in `live` (deleted ones). Those of closed + /// documents go in `sync_views`. + pub fn retain_snapshot_thumbs(&mut self, doc: DocId, live: &[u64]) { self.snapshot_thumbs.retain(|(d, id), _| *d != doc || live.contains(id)); - if let Some(tex) = self.snapshot_thumbs.get(&(doc, snap.id)) { + } + + /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, + /// centred in a square. + pub fn snapshot_thumb(&mut self, ctx: &egui::Context, doc: DocId, snap: &photocraft_engine::history_cmds::Snapshot) -> egui::TextureId { + if let Some((shown, tex)) = self.snapshot_thumbs.get(&(doc, snap.id)) + && shown.ptr_eq(&std::sync::Arc::downgrade(&snap.doc)) + { return tex.id(); } let comp = photocraft_compose::thumbnail(&snap.doc, 56); @@ -2088,7 +2097,7 @@ impl PhotocraftApp { } let tex = ctx.load_texture(format!("snapshot-{}", snap.id), egui::ColorImage::new([side, side], px), egui::TextureOptions::LINEAR); let id = tex.id(); - self.snapshot_thumbs.insert((doc, snap.id), tex); + self.snapshot_thumbs.insert((doc, snap.id), (std::sync::Arc::downgrade(&snap.doc), tex)); id } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index cc33be095f..3cb2e271d6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3153,11 +3153,11 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); } - // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under - // its box would leave it transforming pixels that changed. if snapshot_dialog { crate::snapshot_dialog::open(app); } + // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under + // its box would leave it transforming pixels that changed. // Selecting a snapshot changes the document, so it waits for an open transform like the rows. let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); if let Some((cmd, p)) = footer_cmd.map(|c| (c, json!({}))).filter(|_| app.ui.transform.is_none()).or(snap_cmd) @@ -3176,8 +3176,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } -/// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and -/// name: a click returns to the snapshot (a new state), a double-click or right-click › Rename… +/// The History panel's snapshot rows (#2966), at the top of the panel (the first is the document +/// as opened), each with its thumbnail and name: a click returns to the snapshot (a new state), a double-click or right-click › Rename… /// edits the name in place (Enter or a click elsewhere keeps it, Escape cancels), and /// right-click › Delete removes it. Returns the command a row asks for. fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static str, Value)> { @@ -3185,6 +3185,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static let st = app.session.active()?; let (doc_id, snaps) = (st.doc.id, st.snapshots.clone()); let live: Vec = snaps.iter().map(|s| s.id).collect(); + app.retain_snapshot_thumbs(doc_id, &live); // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. let rename_id = egui::Id::new(("history-snapshot-rename", doc_id)); let mut renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)).flatten(); @@ -3198,7 +3199,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static } let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0)); widgets::checker(ui.painter(), thumb, 4.0); - let tex = app.snapshot_thumb(&ctx, doc_id, snap, &live); + let tex = app.snapshot_thumb(&ctx, doc_id, snap); ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside); match renaming.as_mut().filter(|(id, _)| *id == snap.id) { @@ -4762,6 +4763,35 @@ mod snapshot_row_tests { assert!(h.state().ui.dialogs.is_empty()); } + /// Snapshot thumbnails belong to the snapshot's document (#6087): closing a document drops + /// them, and a document reopened with the same id (a `.pcraft` keeps it) gets fresh ones + /// rather than the closed document's, even before the views are synced. + #[test] + fn snapshot_thumbnails_follow_their_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let doc = (*app.session.active().unwrap().doc).clone(); + let doc_id = doc.id; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(2); + let thumb = |h: &Harness<'static, PhotocraftApp>| h.state().snapshot_thumbs.get(&(doc_id, 0)).map(|(_, t)| t.id()); + let first = thumb(&h).expect("the first snapshot's thumbnail"); + // Closed: its thumbnails go with it. + h.state_mut().session.close(0); + h.state_mut().sync_views(); + assert!(h.state().snapshot_thumbs.keys().all(|(d, _)| *d != doc_id), "closing drops them"); + h.state_mut().session.add_document(doc.clone(), None); + h.run_steps(2); + let reopened = thumb(&h).expect("a thumbnail for the reopened document"); + assert_ne!(reopened, first); + // Closed and reopened between two frames, with no sync in between: still a fresh one. + h.state_mut().session.close(0); + h.state_mut().session.add_document(doc, None); + assert_eq!(h.state().session.active().unwrap().doc.id, doc_id, "same id"); + h.run_steps(2); + assert_ne!(thumb(&h).unwrap(), reopened); + } + /// Renaming belongs to one document: snapshot ids restart in each, so switching tabs must not /// carry the edit over to the other document's snapshot with the same id. #[test] diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index fa22ac9862..5e173da5dc 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -322,10 +322,7 @@ mod tests { let ctx = egui::Context::default(); let mut enabled = None; let mut disabled = None; - let raw = egui::RawInput { - events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], - ..Default::default() - }; + let raw = egui::RawInput { events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], ..Default::default() }; let mut output = ctx.run_ui(raw, |ui| { enabled = trackpad_delta(ui.ctx(), true); disabled = trackpad_delta(ui.ctx(), false); diff --git a/crates/ui-egui/src/snapshot_dialog.rs b/crates/ui-egui/src/snapshot_dialog.rs index 3ee7553b84..0dab99fb79 100644 --- a/crates/ui-egui/src/snapshot_dialog.rs +++ b/crates/ui-egui/src/snapshot_dialog.rs @@ -17,7 +17,12 @@ pub fn owns(fields: &Map) -> bool { } /// Open the dialog on the active document, suggesting the name the camera button would give. +/// Not while a Free Transform is open: it owns the document until committed or cancelled, so the +/// camera button does nothing then either (#6087). pub fn open(app: &mut PhotocraftApp) -> Option { + if app.ui.transform.is_some() { + return None; + } let name = photocraft_engine::history_cmds::next_snapshot_name(app.session.active()?); let mut f = Map::new(); f.insert(MARK.into(), json!(true)); @@ -28,8 +33,17 @@ pub fn open(app: &mut PhotocraftApp) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) } +/// Takes the snapshot. A cleared Name gets the next default name, as the camera button would give +/// (#6087); agents calling the command with an empty name still get an error. pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - app.run(COMMAND, json!({"name": f.get("name"), "from": f.get("from")})) + let mut p = Map::new(); + if let Some(name) = f.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) { + p.insert("name".into(), json!(name)); + } + if let Some(from) = f.get("from") { + p.insert("from".into(), from.clone()); + } + app.run(COMMAND, Value::Object(p)) } fn label(ui: &mut egui::Ui, text: &str) { @@ -47,11 +61,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // Opens on the name, selected, so typing replaces it (Photoshop). if !ui.is_sizing_pass() && !f.contains_key("__named") { r.request_focus(); - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), r.id); - } + crate::field_tab::select_all(ui.ctx(), r.id, &name); f.insert("__named".into(), json!(true)); } f.insert("name".into(), json!(name)); @@ -69,3 +79,47 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } }); } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::PhotocraftApp; + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app + } + + /// An open Free Transform owns the document: New Snapshot does not open under it, whoever + /// asks (#6087). + #[test] + fn does_not_open_during_free_transform() { + let mut app = app(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + assert!(app.ui.transform.is_some()); + assert_eq!(super::open(&mut app), None); + assert!(app.ui.dialogs.is_empty()); + crate::transform_tool::cancel(&mut app); + assert!(super::open(&mut app).is_some()); + } + + /// A cleared Name takes the default name on OK, as the camera button would (#6087); a typed + /// one is trimmed. The command itself still refuses an empty name. + #[test] + fn a_cleared_name_gets_the_default_name() { + let mut app = app(); + for (typed, want) in [("", "Snapshot 1"), (" ", "Snapshot 2"), (" Mine ", "Mine")] { + let id = super::open(&mut app).unwrap(); + let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap(); + d.fields.insert("name".into(), json!(typed)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().snapshots.last().unwrap().name, want); + } + assert!(app.run(super::COMMAND, json!({"name": ""})).is_err(), "agents still get the error"); + } +} diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 4523fbc647..c65ad1fe2c 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -381,8 +381,7 @@ mod tests { assert!(matches!(legacy.events[0], Event::Zoom(_))); // Native magnification takes over before the synthetic gesture times out. - let mut native = - RawInput { time: Some(1.1), events: vec![Event::Zoom(1.1), wheel(Vec2::new(0.0, 0.25), CTRL)], ..Default::default() }; + let mut native = RawInput { time: Some(1.1), events: vec![Event::Zoom(1.1), wheel(Vec2::new(0.0, 0.25), CTRL)], ..Default::default() }; fold_legacy_pinch(&ctx, &mut native); assert!(matches!(native.events[0], Event::Zoom(f) if (f - 1.1).abs() < 1e-5)); assert!(matches!(native.events[1], Event::MouseWheel { .. })); From 99b5d92725ab60cc5b4b4dbe3c840b8c0724664e Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 19 Oct 2026 13:54:43 +0200 Subject: [PATCH 05/63] Crop: the options-bar swap button turns the frame instead of shrinking it (#7738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a ratio set, the ⇄ button only swapped the ratio fields; apply_ratio then fitted the swapped ratio inside the old, unturned frame and shrank it on every click. The button now turns the frame about its centre first, as X does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 35 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 5 +++++ 2 files changed, 40 insertions(+) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index f1df63ff14..74b803dd7a 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -641,6 +641,14 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] { [c[0] - hh, c[1] - hw, c[0] + hh, c[1] + hw] } +/// The options bar's ⇄ with a ratio set: the frame turns with the ratio about its centre, as X +/// does, so fitting the swapped ratio afterwards doesn't shrink it (#7738). +pub(crate) fn turn_frame(app: &mut PhotocraftApp) { + if let Some(r) = app.ui.crop_rect.filter(|_| takes_keys(app) && preset_ratio(app).is_some()) { + keyed(app, swapped(r)); + } +} + /// The options-bar ratio `key` with its sides swapped (what the bar's ⇄ button does), `None` when /// no ratio is set; "original" becomes the document's size turned on its side. pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option { @@ -1970,6 +1978,33 @@ mod tests { assert!(!swap_orientation(&mut app)); } + /// #7738: with a ratio set, the options bar's ⇄ turns the frame with the ratio, as X does, + /// instead of fitting the swapped ratio inside the old frame; a second click brings it back. + #[test] + fn the_swap_button_turns_the_ratio_frame() { + use egui_kittest::kittest::Queryable; + let mut a = app(SampleType::U8); + a.ui.tool_options.crop_ratio = "16:9".into(); + a.ui.crop_rect = Some([60.0, 27.5, 140.0, 72.5]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + a, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run_steps(4); + let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); + } + /// #1919: W × H beside the pointer while the frame is drawn or resized (its own size when /// turned), not while it moves or turns or between gestures. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a1b07f2b7..5c5fe57004 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1276,6 +1276,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919). crate::crop_presets::dropdown(app, ui); let o = &mut app.ui.tool_options; + let mut turn = false; if o.crop_ratio == crate::crop_size::WHR { crate::crop_size::fields(o, ui); } else { @@ -1288,6 +1289,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ch = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals).changed(); if swap { std::mem::swap(&mut rw, &mut rh); + turn = true; } if cw || ch || swap { crate::crop_size::set_custom_ratio_fields(o, rw, rh); @@ -1315,6 +1317,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { &[(false, tl!("Background (Default)")), (true, tl!("Content-Aware Fill"))], 150.0, ); + if turn { + crate::crop_ui::turn_frame(app); + } if crate::crop_ui::preset_ratio(app) != before_ratio { crate::crop_ui::apply_ratio(app); } From e465970a3a1679e4d0d148c529f730f0919c07ff Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 20 Oct 2026 16:58:24 +0200 Subject: [PATCH 06/63] Crop: the default-mode swap test expects the swap button to turn the ratio frame, as X does (#7738) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/crop_default_mode.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/crop_default_mode.rs b/crates/ui-egui/tests/crop_default_mode.rs index 58d9709e99..88916b94e1 100644 --- a/crates/ui-egui/tests/crop_default_mode.rs +++ b/crates/ui-egui/tests/crop_default_mode.rs @@ -138,18 +138,17 @@ fn the_swap_button_and_x_swap_the_ratio_about_the_centre() { let before = mid(screen_box(&h)); h.get_by_label("Swaps height and width").click(); h.run_steps(3); + // Both turn the frame instead of fitting the turned ratio inside it (#7057, #7738). let button = h.state().ui.crop_rect.unwrap(); assert_eq!(h.state().ui.tool_options.crop_ratio, "2:3"); - assert!(((button[2] - button[0]) / (button[3] - button[1]) - 2.0 / 3.0).abs() < 1e-10); - assert!(button[0] >= original[0] && button[2] <= original[2] && button[1] >= original[1] && button[3] <= original[3]); + assert_eq!(button, [70.0, -20.0, 230.0, 220.0]); assert!(mid(screen_box(&h)).distance(before) < 0.01); h.state_mut().ui.crop_rect = Some(original); h.state_mut().ui.tool_options.crop_ratio = "3:2".into(); h.run_steps(3); h.key_press(Key::X); h.run_steps(3); - // X turns the frame instead of fitting the turned ratio inside it (#7057). - assert_eq!(h.state().ui.crop_rect, Some([70.0, -20.0, 230.0, 220.0])); + assert_eq!(h.state().ui.crop_rect, Some(button)); assert!(mid(screen_box(&h)).distance(before) < 0.01); } From 6cd724a10ffbad47417d694ae5fe606d96af0374 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 12:30:47 +0200 Subject: [PATCH 07/63] Address CodeRabbit review: swapping a square default crop frame leaves it untouched The options-bar swap and X no longer mark an unchanged square frame as edited, so leaving the tool drops it instead of cropping. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 61 ++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 712ae46e6a..a2bbfa44f5 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -644,7 +644,11 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] { /// The options bar's ⇄ with a ratio set: the frame turns with the ratio about its centre, as X /// does, so fitting the swapped ratio afterwards doesn't shrink it (#7738). pub(crate) fn turn_frame(app: &mut PhotocraftApp) { - if let Some(r) = app.ui.crop_rect.filter(|_| takes_keys(app) && preset_ratio(app).is_some()) { + // A square frame doesn't change, so an untouched default frame stays one (and isn't + // committed on leaving the tool). + if let Some(r) = app.ui.crop_rect.filter(|_| takes_keys(app) && preset_ratio(app).is_some()) + && swapped(r) != r + { keyed(app, swapped(r)); } } @@ -668,8 +672,11 @@ pub fn swap_orientation(app: &mut PhotocraftApp) -> bool { { app.ui.tool_options.crop_ratio = k; } - // The frame turns first: fitting the swapped ratio inside the old frame would shrink it. - keyed(app, swapped(r)); + // The frame turns first: fitting the swapped ratio inside the old frame would shrink it. A + // square frame doesn't change, so an untouched default frame stays one. + if swapped(r) != r { + keyed(app, swapped(r)); + } if preset_ratio(app).is_some() { apply_ratio(app); } else { @@ -2010,10 +2017,46 @@ mod tests { /// instead of fitting the swapped ratio inside the old frame; a second click brings it back. #[test] fn the_swap_button_turns_the_ratio_frame() { + use egui_kittest::kittest::Queryable; + let a = app(SampleType::U8); + let mut h = swap_button_harness(a, "16:9", [60.0, 27.5, 140.0, 72.5]); + let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); + } + + /// A square default frame doesn't turn, so ⇄ leaves it untouched: not edited, and leaving the + /// tool drops it instead of cropping. + #[test] + fn the_swap_button_leaves_a_square_default_frame_untouched() { use egui_kittest::kittest::Queryable; let mut a = app(SampleType::U8); - a.ui.tool_options.crop_ratio = "16:9".into(); - a.ui.crop_rect = Some([60.0, 27.5, 140.0, 72.5]); + a.crop.default_frame = true; + a.crop.editing = false; + let mut h = swap_button_harness(a, "1:1", [50.0, 0.0, 150.0, 100.0]); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert_eq!(h.state().ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(h.state().crop.default_frame && !h.state().crop.editing, "still the untouched default frame"); + // X, the same swap from the keyboard. + let mut a = app(SampleType::U8); + a.crop.default_frame = true; + a.crop.editing = false; + a.crop.frame_for = a.session.active_index().zip(a.session.active()).map(|(i, st)| (i, st.doc.size.width, st.doc.size.height)); + a.ui.tool_options.crop_ratio = "1:1".into(); + a.ui.crop_rect = Some([50.0, 0.0, 150.0, 100.0]); + assert!(swap_orientation(&mut a)); + assert_eq!(a.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(a.crop.default_frame && !a.crop.editing, "X keeps it untouched too"); + } + + fn swap_button_harness(mut a: PhotocraftApp, ratio: &str, rect: [f64; 4]) -> egui_kittest::Harness<'static, PhotocraftApp> { + a.ui.tool_options.crop_ratio = ratio.into(); + a.ui.crop_rect = Some(rect); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { @@ -2024,13 +2067,7 @@ mod tests { ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); h.run_steps(4); - let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); - h.get_by_label("Swaps height and width").click(); - h.run_steps(2); - assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); - h.get_by_label("Swaps height and width").click(); - h.run_steps(2); - assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); + h } /// #1919: W × H beside the pointer while the frame is drawn or resized (its own size when From a218cf17b5d3542a534a6398132ffe64da25b64f Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 17:10:19 +0200 Subject: [PATCH 08/63] Dropdowns: open the list beside its button, never over it (#5495) A full-height list that didn't fit below its button moved up over it, so the release of the click that opened it chose the row under the pointer (the Layers panel's Blend Mode box picked Hard Light). As in Photoshop the list now opens below the button, or above it when the whole list doesn't fit below and there is more room above, capped to that side's room (the rest scrolls). A release back on the box that opened the list chooses nothing, also where a too-short window still makes a list cover it. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/blend_dropdown_wheel_tests.rs | 29 ++++ crates/ui-egui/src/press_menu.rs | 13 +- crates/ui-egui/src/widgets.rs | 149 +++++++++++++----- 3 files changed, 148 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs index 31b28adbda..5e00133164 100644 --- a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs +++ b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs @@ -212,3 +212,32 @@ fn a_wheel_step_ends_the_hover_preview_until_the_pointer_moves() { h.run_steps(3); assert_eq!(previewed(&mut h), Some(BlendMode::Multiply), "a moved pointer previews again"); } + +/// The open list's rect (the one foreground area: no other popup is open). +fn open_list(h: &Harness<'_, PhotocraftApp>) -> Option { + h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))) +} + +/// A plain click on the Layers panel's Blend Mode box, released where it was pressed (after one +/// frame, or after a few as a person's click takes), opens the list beside the box and chooses +/// nothing (#5495 review): the full-height list doesn't fit below the box low in the panel, and +/// it must not open over the box, where the release would pick the row under the pointer. +#[test] +fn a_click_on_the_blend_mode_box_opens_the_list_beside_it_and_changes_nothing() { + for held in [6, 1] { + let mut h = harness(pixel_layer); + let undo = steps(&h); + let button = dropdown(&h); + let at = button.center(); + h.hover_at(at); + h.run_steps(1); + for (pressed, frames) in [(true, held), (false, 4)] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.run_steps(frames); + } + assert_eq!((blend(&h), steps(&h)), (BlendMode::Normal, undo), "held {held}: the click chose nothing"); + let Some(list) = open_list(&h) else { panic!("held {held}: the list is open") }; + assert!(!list.intersects(button.shrink(1.0)), "held {held}: the list {list:?} leaves the box {button:?} free"); + assert!(list.top() >= 0.0 && list.bottom() <= 900.0, "held {held}: the list {list:?} is in the window"); + } +} diff --git a/crates/ui-egui/src/press_menu.rs b/crates/ui-egui/src/press_menu.rs index 7110e833b4..ec0be0439e 100644 --- a/crates/ui-egui/src/press_menu.rs +++ b/crates/ui-egui/src/press_menu.rs @@ -130,7 +130,13 @@ impl PressCombo { /// Was option `item` (in the open list `ui`) chosen this frame: clicked, or the press-drag /// gesture that opened the list released on it? The list closes when it was. pub fn chosen(&self, ui: &Ui, item: &Response) -> bool { - let released_on = item.enabled() && item.contains_pointer() && ui.input(|i| i.pointer.primary_released()) && self.owns_gesture(ui.ctx()); + // A release back on the box chooses nothing, also where a list covers the box. + let on_box = |ctx: &egui::Context| { + let at = ctx.input(|i| i.pointer.interact_pos()); + ctx.data(|d| d.get_temp::(gesture_id().with(self.popup))).zip(at).is_some_and(|(b, p)| b.contains(p)) + }; + let released_on = + item.enabled() && item.contains_pointer() && ui.input(|i| i.pointer.primary_released()) && self.owns_gesture(ui.ctx()) && !on_box(ui.ctx()); if released_on { ui.close(); } @@ -145,7 +151,10 @@ impl PressCombo { if pressed && !Popup::is_id_open(ctx, self.popup) { // Opened now, the list counts as open this frame and egui shows it from the next. Popup::open_id(ctx, self.popup); - ctx.data_mut(|d| d.insert_temp(gesture_id(), self.popup)); + ctx.data_mut(|d| { + d.insert_temp(gesture_id(), self.popup); + d.insert_temp(gesture_id().with(self.popup), button.rect); + }); ctx.request_repaint(); } else if self.owns_gesture(ctx) && ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { ctx.data_mut(|d| d.remove::(gesture_id())); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 57fd0db5fc..48bd5c7617 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1635,30 +1635,27 @@ fn combo_box_arrow_keys(ui: &mut Ui, response: &Response, /// off the bottom. egui moves a popup up to keep it in the window, so a menu opened low on the /// screen first shifts up and only scrolls when it is taller than the window. pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - let room = popup_max_height(ui.ctx(), ui.spacing()); + // The popup frame's margin and stroke, and a small gap to the window's edges. + let frame = ui.spacing().menu_margin.sum().y + 2.0 + 2.0 * MENU_EDGE; + let room = (crate::work_area::visible_rect(ui.ctx()).height() - frame).max(MENU_MIN_HEIGHT); // A popup's Ui is only as tall as the popup was last frame (400 pt on the first), so ask for // the whole room: the area still shrinks to its rows when they need less. egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner } -/// The most a popup's rows may take: the part of the window that can be seen (clear of a taskbar a -/// too-tall window runs under, #315), less the popup frame's margin and stroke and a small gap to -/// the window's edges. egui moves a popup up to keep it in the window, so a popup this tall fits. -pub fn popup_max_height(ctx: &egui::Context, spacing: &egui::style::Spacing) -> f32 { - let frame = spacing.menu_margin.sum().y + 2.0 + 2.0 * MENU_EDGE; - (crate::work_area::visible_rect(ctx).height() - frame).max(MENU_MIN_HEIGHT) -} - /// A dropdown: a button showing `selected_text` (at least `width` wide, wider for a longer text) /// whose list (`add_contents`) opens below it on a click. The list is as tall as its options up to -/// the part of the window that can be seen, scrolling only past that (#5495): Photoshop's dropdowns +/// the room the window has on that side, scrolling only past that (#5495): Photoshop's dropdowns /// open at full height. egui's `ComboBox` capped every list at the size its popup takes on the /// frame it opens (`default_area_size`, 400 pt), so the Layers panel's 27 blend modes scrolled. /// -/// The list's left edge is the button's, and a list that doesn't fit below the button moves up -/// over it to stay in the window, never beside it: so it stays put while its value (and with it -/// the button's width) changes, as the wheel steps it over the open list (#1747). The ids are -/// egui's `ComboBox` ones, so `ComboBox::is_open` and [`crate::press_menu::PressCombo`] apply. +/// The list never covers its button, as in Photoshop: it opens below the button, or above it when +/// the whole list doesn't fit below and there is more room above, and scrolls when it doesn't fit +/// on that side either. Over the button, the release of the click that opened it would choose the +/// row under the pointer. Its left edge is the button's and its side only depends on its rows' +/// height, so it stays put while its value (and with it the button's width) changes, as the wheel +/// steps it over the open list (#1747). The ids are egui's `ComboBox` ones, so +/// `ComboBox::is_open` and [`crate::press_menu::PressCombo`] apply. pub fn combo_list( ui: &mut Ui, id_salt: impl egui::AsIdSalt, @@ -1680,16 +1677,21 @@ pub fn combo_list( info.current_text_value = Some(selected_text.text().to_owned()); info }); - let room = popup_max_height(&ctx, ui.spacing()); - // Below the button, or as far up as the list's height (from the last frame) needs. + // The room below and above the button, less the popup frame's margin and stroke. The + // rows' full height is measured on the (invisible) pass that sizes the list as it opens. let visible = crate::work_area::visible_rect(&ctx).shrink(MENU_EDGE); - let height = egui::AreaState::load(&ctx, popup_id).and_then(|s| s.size).map_or(0.0, |s| s.y); - let top = button.rect.bottom().min(visible.bottom() - height).max(visible.top()); - let left = button.rect.left(); + let frame = ui.spacing().menu_margin.sum().y + 2.0; + let below = visible.bottom() - button.rect.bottom() - frame; + let above = button.rect.top() - visible.top() - frame; + let rows_id = popup_id.with("rows"); + let rows = ctx.data(|d| d.get_temp::(rows_id)); + let up = rows.is_some_and(|rows| rows > below) && above > below; + let (align, room) = if up { (egui::RectAlign::TOP_START, above) } else { (egui::RectAlign::BOTTOM_START, below) }; + let room = room.max(MENU_MIN_HEIGHT); let inner = egui::Popup::menu(&button) .id(popup_id) - .anchor(Rect::from_x_y_ranges(left..=left, top..=top)) - .align(egui::RectAlign::BOTTOM_START) + .anchor(button.rect) + .align(align) .align_alternatives(&[]) .width(button.rect.width()) .close_behavior(close) @@ -1698,15 +1700,14 @@ pub fn combo_list( ui.set_min_width(ui.available_width()); // A popup's Ui is only as tall as the popup was last frame (400 pt on the first), // so ask for the whole room: the area still shrinks to its rows when they need less. - egui::ScrollArea::vertical() - .max_height(room) - .min_scrolled_height(room) - .show(ui, |ui| { - // Options don't wrap in a list as narrow as its button: they widen it. - ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - add_contents(ui) - }) - .inner + let scroll = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, |ui| { + // Options don't wrap in a list as narrow as its button: they widen it. + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + add_contents(ui) + }); + let rows = scroll.content_size.y; + ui.ctx().data_mut(|d| d.insert_temp(rows_id, rows)); + scroll.inner }) .map(|r| r.inner); egui::InnerResponse::new(inner, button) @@ -2655,21 +2656,32 @@ mod tests { assert!(rows.last().is_some_and(|r| !list.contains_rect(*r)), "the last rows scroll: {rows:?}"); } - /// The open list opens at the button's left edge, below it or moved up over it to fit, and - /// stays put while the wheel steps through every mode over it (#1747), although the button - /// widens for the longer names (#5495). + /// The open list opens at the button's left edge, below the button or above it, never over it + /// (#5495 review: a click released in place would choose the row under the pointer): below + /// when the whole list fits there, else on the side with more room, scrolling when it doesn't + /// fit there either. It stays put while the wheel steps through every mode over it (#1747), + /// although the button widens for the longer names. #[test] - fn dropdown_list_stays_put_while_the_wheel_steps_its_value() { + fn dropdown_list_opens_beside_its_button_and_stays_put_while_the_wheel_steps_its_value() { use photocraft_color::BlendMode; - // A tall window with room below the button, and a 900 pt one with the button half way down. - for (height, down) in [(1000.0, 0.0), (900.0, 420.0)] { + // (window height, button's distance from the top, list below it, all modes in sight) + let cases = [ + (1000.0, 0.0, true, true), // room below + (900.0, 420.0, true, false), // room on neither side, more below + (900.0, 520.0, false, false), // room on neither side, more above + (1000.0, 900.0, false, true), // room above only, near the bottom edge + (300.0, 150.0, false, false), // a window shorter than the list + ]; + for (height, down, below, all) in cases { let mut h = open_blend_modes(height, down); let button = h.get_by_role(egui::accesskit::Role::ComboBox).rect(); let list = open_list(&h); - assert!(list.top() >= 0.0 && list.bottom() <= height, "{height}: the list {list:?} is in the window"); - assert!((list.left() - button.left()).abs() < 8.0, "{height}: the list {list:?} starts at the button {button:?}"); + assert!(list.top() >= 0.0 && list.bottom() <= height, "{height}/{down}: the list {list:?} is in the window"); + assert!((list.left() - button.left()).abs() < 8.0, "{height}/{down}: the list {list:?} starts at the button {button:?}"); + assert!(!list.intersects(button.shrink(1.0)), "{height}/{down}: the list {list:?} leaves the button {button:?} free"); + assert_eq!(list.top() >= button.bottom() - 1.0, below, "{height}/{down}: the list {list:?} is below the button {button:?}"); let rows = mode_rows(&h); - assert!(rows.iter().all(|r| list.contains_rect(*r)), "{height}: all modes show in {list:?}"); + assert_eq!(rows.iter().all(|r| list.contains_rect(*r)), all, "{height}/{down}: all modes show in {list:?}"); let over = list.center(); for _ in 1..BlendMode::LAYER_MODES.len() { h.hover_at(over); @@ -2680,12 +2692,67 @@ mod tests { modifiers: Modifiers::NONE, }); h.run_steps(3); - assert_eq!(open_list(&h), list, "{height}: the list stays put at {:?}", h.state()); + assert_eq!(open_list(&h), list, "{height}/{down}: the list stays put at {:?}", h.state()); } - assert_eq!(*h.state(), BlendMode::Luminosity, "{height}: the wheel stepped through every mode"); + assert_eq!(*h.state(), BlendMode::Luminosity, "{height}/{down}: the wheel stepped through every mode"); } } + /// A short list opens below its button when it fits there, and above it near the window's + /// bottom edge, whole either way. + #[test] + fn a_short_dropdown_list_opens_below_or_above_its_button_whole() { + for (down, below) in [(100.0, true), (360.0, false)] { + let mut h = Harness::builder().with_size(egui::vec2(300.0, 400.0)).build_ui_state( + move |ui, selected: &mut usize| { + ui.add_space(down); + super::dropdown(ui, "blend-mode", selected, &MODES, 120.0); + }, + 0, + ); + h.run(); + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(4); + let button = h.get_by_role(egui::accesskit::Role::ComboBox).rect(); + let list = open_list(&h); + assert_eq!(list.top() >= button.bottom() - 1.0, below, "{down}: the list {list:?} is below the button {button:?}"); + assert!(!list.intersects(button.shrink(1.0)), "{down}: the list {list:?} leaves the button {button:?} free"); + for (_, label) in MODES { + let row = h.get_all_by_label(label).last().map(|n| n.rect()).unwrap_or(egui::Rect::NOTHING); + assert!(list.contains_rect(row), "{down}: {label} {row:?} shows in {list:?}"); + } + assert!(list.height() < 150.0, "{down}: the list {list:?} is only as tall as its rows"); + assert_eq!(*h.state(), 0, "{down}: the click chose nothing"); + } + } + + /// In a window too short for the list on either side the list still covers its button; the + /// release of the click that opened it, held a few frames, chooses nothing (#5495 review). + #[test] + fn the_release_of_the_opening_click_chooses_nothing_where_the_list_covers_the_button() { + use photocraft_color::BlendMode; + let mut h = Harness::builder().with_size(egui::vec2(400.0, 110.0)).build_ui_state( + |ui, mode: &mut BlendMode| { + ui.add_space(40.0); + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + super::dropdown_wheel_hovered(ui, "blend-mode", mode, &opts, 120.0); + }, + BlendMode::Normal, + ); + h.run(); + let button = h.get_by_role(egui::accesskit::Role::ComboBox).rect(); + let at = button.center(); + h.hover_at(at); + h.run(); + for (pressed, frames) in [(true, 6), (false, 4)] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(frames); + } + let list = open_list(&h); + assert!(list.contains(at), "the list {list:?} covers the button {button:?} here"); + assert_eq!(*h.state(), BlendMode::Normal, "the click chose nothing"); + } + const MODES: [(usize, &str); 4] = [(0, "Normal"), (1, "Dissolve"), (2, "Darken"), (3, "Multiply")]; /// A wheel dropdown (or a plain one) of [`MODES`], whose state is the chosen index. From a4812f6270034ac8291c93ae09bd59648ed390a2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 17:26:44 +0200 Subject: [PATCH 09/63] Layers: the separator test opens the Blend Mode list in a window tall enough for all of it (#5532) Since #5501's review fix a list that fits on neither side of its box opens below it and scrolls, as at 1440x900, so the test now uses a 1600 pt window to see every group. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/blend_dropdown_groups_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/blend_dropdown_groups_tests.rs b/crates/ui-egui/src/blend_dropdown_groups_tests.rs index de3d1d7d93..8d14adc1f8 100644 --- a/crates/ui-egui/src/blend_dropdown_groups_tests.rs +++ b/crates/ui-egui/src/blend_dropdown_groups_tests.rs @@ -11,9 +11,10 @@ use serde_json::json; use crate::PhotocraftApp; -/// The app with a new document and a pixel layer above the Background, active. +/// The app with a new document and a pixel layer above the Background, active. The window is tall +/// enough for the whole Blend Mode list below its box (at 900 pt it fits on neither side and scrolls). fn harness() -> Harness<'static, PhotocraftApp> { - let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + let mut h = Harness::builder().with_size(vec2(1440.0, 1600.0)).with_max_steps(64).build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); From d8ad5a911660178ec93983101dc353fd4c211dd6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 19:33:07 +0200 Subject: [PATCH 10/63] Merge follow-up: main's new History source tests count the first snapshot (id 0) The snapshot source-column test now takes Snapshot 1's row below the first snapshot's, and history.setSource's bad-param test no longer expects snapshot 0 to be missing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/history_source_cmds.rs | 3 ++- crates/ui-egui/src/panels.rs | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/history_source_cmds.rs b/crates/engine/src/history_source_cmds.rs index c84cbe8400..0c92c0e56e 100644 --- a/crates/engine/src/history_source_cmds.rs +++ b/crates/engine/src/history_source_cmds.rs @@ -366,8 +366,9 @@ mod tests { json!({"state": 1.5}), json!({"state": "1"}), json!({"state": [1]}), - json!({"snapshot": 0}), + // Snapshot 0 is the first snapshot (the document as opened); no others exist. json!({"snapshot": 1}), + json!({"snapshot": 2}), json!({"snapshot": -1}), json!({"snapshot": "Open"}), json!({"snapshot": "Open", "state": 1}), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5bd7b7ab30..ebbe475091 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -5136,11 +5136,15 @@ mod history_transform_tests { app.run("history.newSnapshot", json!({})).unwrap(); let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); h.run_steps(2); - // The snapshot row's cell is the only 24×38 one (the opening row has no column). - let cells: Vec = h + // Snapshot rows have the only 24×38 cells: the first snapshot (the document as opened) + // on top, the new one below it. + let mut cells: Vec = h .ctx .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 38.0)).map(|w| w.rect).collect()); - let cell = *cells.first().expect("a snapshot source cell"); + cells.sort_by(|a, b| a.center().y.total_cmp(&b.center().y)); + cells.dedup(); + assert_eq!(cells.len(), 2, "the first snapshot and Snapshot 1: {cells:?}"); + let cell = cells[1]; click(&mut h, cell.center()); let st = h.state().session.active().unwrap(); assert_eq!(photocraft_engine::history_source_cmds::source(st), Some(HistorySource::Snapshot(1))); From 849d88129f650f71300d0431e8b76f37c05b756d Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 20:23:10 +0200 Subject: [PATCH 11/63] Address CodeRabbit review: the W x H x Resolution swap button turns the frame too With a size preset the options bar's swap button only swapped W and H, and the ratio change then fitted the turned size inside the old frame, shrinking it, while X already turned the frame. Now both turn it about its centre, as with a ratio preset and as in Photoshop. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_size.rs | 8 ++++---- crates/ui-egui/src/crop_ui.rs | 31 +++++++++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 3 ++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index d219a7a50c..1b93c108a0 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -292,11 +292,10 @@ fn text_field(ui: &mut egui::Ui, id: &str, value: &mut String, hint: &str, width } /// The W x H x Resolution fields of the options bar: W, ⇄, H, the resolution and its unit. -pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { +/// Returns true when ⇄ swapped W and H, so the Crop tool turns its frame with them. +pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) -> bool { text_field(ui, "crop-width", &mut o.crop_width, tl!("Width"), 64.0, normalize_length); - if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() { - swap(o); - } + let swapped = crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() && swap(o); text_field(ui, "crop-height", &mut o.crop_height, tl!("Height"), 64.0, normalize_length); text_field(ui, "crop-resolution", &mut o.crop_resolution, tl!("Resolution"), 64.0, normalize_resolution); let units = [(PX_PER_IN.to_string(), "px/in"), (PX_PER_CM.to_string(), "px/cm")]; @@ -308,6 +307,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { o.crop_resolution = crate::widgets::fmt_num2(v); } } + swapped } #[cfg(test)] diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 48c0c9b84c..2969d47e3e 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -2117,6 +2117,37 @@ mod tests { assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); } + /// With W x H x Resolution the bar's ⇄ swaps W and H and turns the frame with them, as X + /// does, instead of fitting the turned size inside the old frame. + #[test] + fn the_swap_button_turns_a_size_preset_frame() { + use egui_kittest::kittest::Queryable; + let whr = |a: &mut PhotocraftApp| { + let o = &mut a.ui.tool_options; + (o.crop_width, o.crop_height, o.crop_resolution) = ("1600 px".into(), "900 px".into(), "72".into()); + }; + let mut a = app(SampleType::U8); + whr(&mut a); + let mut h = swap_button_harness(a, crate::crop_size::WHR, [60.0, 27.5, 140.0, 72.5]); + let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + let o = &h.state().ui.tool_options; + assert_eq!((o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str()), (crate::crop_size::WHR, "900 px", "1600 px")); + assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); + // X does the same. + let mut a = app(SampleType::U8); + whr(&mut a); + a.ui.tool_options.crop_ratio = crate::crop_size::WHR.into(); + a.ui.crop_rect = Some([60.0, 27.5, 140.0, 72.5]); + assert!(swap_orientation(&mut a)); + assert_eq!((a.ui.tool_options.crop_width.as_str(), a.ui.tool_options.crop_height.as_str()), ("900 px", "1600 px")); + assert!(near(a.ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "X: 45 x 80, not shrunk: {:?}", a.ui.crop_rect); + } + /// A square default frame doesn't turn, so ⇄ leaves it untouched: not edited, and leaving the /// tool drops it instead of cropping. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 979f19b237..770a95789a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1335,7 +1335,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut turn = false; let mut ratio_base = None; if o.crop_ratio == crate::crop_size::WHR { - crate::crop_size::fields(o, ui); + // ⇄ turns the frame with W and H, as with a ratio. + turn = crate::crop_size::fields(o, ui); } else { // Keep partial edits in ToolOptions so the first side survives a frame; // Original Ratio shows the document's numbers, as Photoshop does. From bf67b1c43f2cf9756f38789c689dc534c589d3bb Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 20:26:26 +0200 Subject: [PATCH 12/63] Address CodeRabbit review: a dropdown list only takes the room its side has combo_list floored the list's scroll height at 120 pt, so where a side had less room the taller list was pushed back into the window, over its button (and past the window's top in a 110 pt window). It now takes just that side's room and scrolls. The 110 pt test now checks the list leaves the button free and stays in the window, and that the opening click still chooses nothing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 48bd5c7617..2e3761840b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1687,7 +1687,9 @@ pub fn combo_list( let rows = ctx.data(|d| d.get_temp::(rows_id)); let up = rows.is_some_and(|rows| rows > below) && above > below; let (align, room) = if up { (egui::RectAlign::TOP_START, above) } else { (egui::RectAlign::BOTTOM_START, below) }; - let room = room.max(MENU_MIN_HEIGHT); + // Only the room that side has, even when it is small: a taller list would be pushed back + // into the window, over its button (#5495 review). + let room = room.max(0.0); let inner = egui::Popup::menu(&button) .id(popup_id) .anchor(button.rect) @@ -2726,10 +2728,11 @@ mod tests { } } - /// In a window too short for the list on either side the list still covers its button; the - /// release of the click that opened it, held a few frames, chooses nothing (#5495 review). + /// In a window with less than 120 pt on either side of the button the list still leaves + /// its button free, scrolling in the room that side has, and stays in the window; the release + /// of the click that opened it, held a few frames, chooses nothing (#5495 review). #[test] - fn the_release_of_the_opening_click_chooses_nothing_where_the_list_covers_the_button() { + fn in_a_very_short_window_the_list_still_leaves_its_button_free_and_the_opening_click_chooses_nothing() { use photocraft_color::BlendMode; let mut h = Harness::builder().with_size(egui::vec2(400.0, 110.0)).build_ui_state( |ui, mode: &mut BlendMode| { @@ -2749,7 +2752,8 @@ mod tests { h.run_steps(frames); } let list = open_list(&h); - assert!(list.contains(at), "the list {list:?} covers the button {button:?} here"); + assert!(!list.intersects(button.shrink(1.0)), "the list {list:?} leaves the button {button:?} free"); + assert!(list.top() >= 0.0 && list.bottom() <= 110.0, "the list {list:?} is in the window"); assert_eq!(*h.state(), BlendMode::Normal, "the click chose nothing"); } From 1541134b6ff404aa9b176f4e23ae03a354a383a3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 23 Oct 2026 00:00:13 +0200 Subject: [PATCH 13/63] Adapt History snapshots to main: doc_id in the follow spot, one cs New Snapshot entry Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.tsv | 1 - crates/ui-egui/src/panels.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 764938726f..4b38d1c73b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1035,7 +1035,6 @@ From: Z: Full Document Celý dokument Merged Layers Sloučené vrstvy - New Snapshot Nový snímek Create new tool preset Vytvořit nové přednastavení nástroje Crop Factor Crop faktor Ctrl Ctrl diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0e84bf849c..4b99a06710 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3613,7 +3613,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The newest past state is a new document on every step, Undo and Redo, also at the state // limit, where the counts and a repeated name stay the same. let newest = st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).map(|d| std::sync::Arc::as_ptr(&d) as usize); - let spot = (doc.id, history_spot(st), newest); + let spot = (doc_id, history_spot(st), newest); let ctx = ui.ctx().clone(); // A row dragged onto the trash (#5324); a drag that never saw its release must not outlive it. if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { From 841af396b7bf51eb87356f922daf434b21e90726 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 00:36:15 +0200 Subject: [PATCH 14/63] Crop: switching documents leaves the old document's view turned (#9202) In default mode a turned crop frame turns its document's view. When another document became active, cancel_stale's cancel turned only the newly active view upright, so the old document kept the crop's turn with no crop on it. Turn the crop document's own view upright first. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/crop_mode.rs | 27 +++++++++++++++++++++++++++ crates/ui-egui/src/crop_ui.rs | 10 ++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index 36bc4d659a..1edf39c9b5 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -745,6 +745,33 @@ mod tests { assert!(out[0].1 == out[1].1, "same pixels"); } + /// #9202: switching documents drops a turned pending crop and turns its own document's view + /// upright, as Esc would, not only the view of the document switched to. + #[test] + fn switching_documents_turns_the_dropped_crops_view_upright() { + // The crop on the first document, then on the second. + for (on, other) in [(0, 1), (1, 0)] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + app.sync_views(); + assert!(app.session.set_active(on)); + app.sync_views(); + let v = &mut app.ui.views[on]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0); + with_frame(&mut app); + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE); + assert!((view_rotation(&app) - 90.0).abs() < 0.01, "crop on {on}: the view turned with the frame"); + assert!(app.session.set_active(other)); + app.sync_views(); + assert_eq!(app.ui.crop_rect, None, "crop on {on}: the turned crop is dropped"); + app.ui.tool = Tool::Brush; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.session.set_active(on)); + app.sync_views(); + assert_eq!(view_rotation(&app), 0.0, "crop on {on}: its document isn't left turned"); + } + } + #[test] fn picking_the_crop_tool_turns_a_rotated_view_upright() { let mut app = app(); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 73e9ce5642..3bbccad1b3 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -592,6 +592,16 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { let pending = app.ui.crop_rect.is_some() || app.crop.drag.is_some(); // A frame made before any document was claimed (control channel, tests) is the active one's. if pending && app.crop.doc.is_some() && app.crop.doc != active { + // Default mode turned the frame's own view, which `cancel` doesn't reach: it turns the + // active one upright, and `ui.views` is still in the order of `view_docs` here (#9202). + if !crate::crop_mode::classic(app) { + let doc = app.crop.doc; + let synced = app.view_docs.iter().position(|d| Some(*d) == doc); + let i = synced.or_else(|| app.session.documents().iter().position(|st| Some(st.doc.id) == doc)); + if let Some(v) = i.and_then(|i| app.ui.views.get_mut(i)) { + v.rotation = 0.0; + } + } cancel(app); } app.crop.doc = active; From b2b402939a8cfe3fd2969ab42918a04f8ac27d12 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 00:56:48 +0200 Subject: [PATCH 15/63] Crop W x H budgets the hidden pixels it resamples (#9212) image.crop's target size, Image Size and Bitmap's Output resolution budgeted only the new canvas, but resample_doc scales every surface's whole content, including the pixels a non-deleting crop kept outside the canvas. A 20 px frame of a 2000 px image resampled to 2000 px tried to allocate 160 GB. resample_doc now refuses, before touching the document, when the largest surface it scales would pass the budget (at that surface's own bytes per pixel). A failed compound (the turned crop) also rolls back the painting symmetry its rotation moved. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/analysis_cmds.rs | 3 + crates/engine/src/image_cmds.rs | 116 ++++++++++++++++++++++++--- crates/engine/src/mode_cmds.rs | 2 +- crates/engine/src/mode_cmds/tests.rs | 12 +++ 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 8932295d52..55f1ce5b44 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -187,6 +187,8 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so // a failed second step can't leave them pinned to pixels the restored document no longer has. let samplers = st.color_samplers.clone(); + // So is the painting symmetry, which a rotation moves with the pixels. + let symmetry = st.symmetry.clone(); let r = f(s); if r.is_err() { s.journal.truncate(journal); @@ -210,6 +212,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess st.channel_view = channel_view; st.coalesce = coalesce; st.color_samplers = samplers; + st.symmetry = symmetry; st.revision += 1; // A failed compound does not make an otherwise untouched document dirty. if was_clean { diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6353f1f2cc..6c30d204f0 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -254,8 +254,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails, Method::Automatic => Resample::BicubicSharper, }; - resample_doc(doc, nw, nh, filter); - Ok(()) + resample_doc(doc, "image.imageSize", nw, nh, filter) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; @@ -290,13 +289,15 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution -/// mode. Callers check [`check_resample_budget`] first. -pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { +/// mode. Callers check [`check_resample_budget`] for the new canvas first; the pixels outside the +/// canvas are scaled too, so a surface they would grow past the budget is refused here (#9212). +pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample) -> Result<()> { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { - return; + return Ok(()); } + check_hidden_resample_budget(cmd, doc, sx, sy)?; // Content that reaches the canvas edge keeps it: a Background stays opaque to the border. let canvas = Rect::from_xywh(0, 0, ow, oh); let taken = crate::smart_cmds::take_caches(&mut doc.layers); @@ -325,6 +326,53 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl doc.size = Size::new(nw, nh); crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); let Ok(()) = crate::smart_cmds::restore_unrendered(doc, taken, |c| Ok::<_, Infallible>(resize_surface_in_canvas(c, sx, sy, filter, canvas))); + Ok(()) +} + +/// [`check_resample_budget`] for the largest surface [`resample_doc`] scales by `sx` x `sy`: each +/// keeps its pixels outside the canvas (a crop that didn't delete them, a layer moved off the +/// canvas), and those grow with the scale like the rest. +fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64) -> Result<()> { + let canvas = doc.bounds(); + // The largest surface by bytes: its scaled width, height and bytes per pixel (a mask has one + // channel, the layers the document's). + let mut largest = (0u32, 0u32, 1u64); + let mut visit = |surf: &Surface| { + let r = surf.content_bounds(); + // What lies within the canvas scales to within the new canvas, already budgeted. + if r.is_empty() || canvas.union(&r) == canvas { + return; + } + // The output's extent, as `resize_surface_in_canvas` lays it out. + let side = |a: i32, b: i32, k: f64| ((f64::from(b) * k).ceil() - (f64::from(a) * k).floor()).clamp(0.0, f64::from(u32::MAX)) as u32; + let (w, h) = (side(r.x0, r.x1, sx), side(r.y0, r.y1, sy)); + let bpp = surf.format().bytes_per_pixel().max(1) as u64; + let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); + if bytes((w, h, bpp)) > bytes(largest) { + largest = (w, h, bpp); + } + }; + // The surfaces `resample_doc` scales, smart object caches included. + for_each_surface(&mut doc.layers, true, &mut |surf, _| visit(surf)); + for ch in doc.channels.iter().chain(doc.quick_mask.as_ref()) { + visit(&ch.surface); + } + if let Some(sel) = &doc.selection { + visit(sel); + } + let (w, h, bpp) = largest; + let px = u64::from(w).saturating_mul(u64::from(h)); + if px.saturating_mul(bpp) <= MAX_RESAMPLE_BYTES { + return Ok(()); + } + let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000; + Err(bad( + cmd, + format!( + "{w} x {h} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. The pixels outside the canvas are resampled too: choose a smaller size, or delete them first (Crop with Delete Cropped Pixels)", + px / 1_000_000 + ), + )) } fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -614,8 +662,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?; } doc.selection = None; - target.apply(doc); - Ok(()) + target.apply(doc) })?; Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }))) } @@ -630,15 +677,16 @@ struct CropTarget { impl CropTarget { /// Applies the target to a document that has just been cropped (inside the crop's history step). - fn apply(&self, doc: &mut Document) { + fn apply(&self, doc: &mut Document) -> Result<()> { if let Some((w, h)) = self.size { // As for Image Size: an Indexed Color document holds only its colour table's colours (#6019). let filter = if doc.mode == ColorMode::Indexed { Resample::Nearest } else { self.filter }; - resample_doc(doc, w, h, filter); + resample_doc(doc, "image.crop", w, h, filter)?; } if let Some(r) = self.resolution { doc.resolution_dpi = r; } + Ok(()) } /// Where Color Sampler points go: shifted by the crop's origin, then scaled with the pixels @@ -803,8 +851,7 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar crate::crop_fill::fill_uncovered(doc, *active, &coverage)?; } doc.selection = None; - crop_target.apply(doc); - Ok(()) + crop_target.apply(doc) }) })?; Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle }))) @@ -1850,6 +1897,53 @@ mod tests { assert_eq!(doc(&s).size, Size::new(40, 20)); } + /// #9212: a non-deleting crop keeps the pixels outside the frame, and W x H resamples them + /// too. A 20 px frame of a 2000 px Background resampled to 2000 px scales its hidden pixels + /// x100, to 200000 px a side (160 GB at 8 bits, past the 8 GiB budget): refused before + /// allocating, like a target size past the budget. + #[test] + fn crop_target_budgets_the_hidden_pixels_it_resamples() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2000, "height": 2000})).unwrap(); + s.execute("paint.setSymmetry", json!({"mode": "vertical", "center": [500, 700]})).unwrap(); + let symmetry = s.active().unwrap().symmetry.clone(); + let past = s.active().unwrap().history.past_len(); + let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "deleteCroppedPixels": false, "targetWidth": 2000, "targetHeight": 2000}); + for angle in [None, Some(90)] { + let mut p = p.clone(); + if let Some(a) = angle { + p["angle"] = json!(a); + } + let err = s.execute("image.crop", p).unwrap_err().to_string(); + assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{angle:?}: {err}"); + assert_eq!(doc(&s).size, Size::new(2000, 2000), "{angle:?}: the document is unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{angle:?}: no history step"); + // The turned crop rotates first: the painting symmetry turns back with the document. + assert_eq!(s.active().unwrap().symmetry, symmetry, "{angle:?}: the painting symmetry stays"); + } + // Deleting the cropped pixels leaves only the frame to resample. + p["deleteCroppedPixels"] = json!(true); + s.execute("image.crop", p).unwrap(); + assert_eq!(doc(&s).size, Size::new(2000, 2000)); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + } + + /// #9212: Image Size resamples the pixels outside the canvas as well. After a non-deleting + /// crop to 10 px, 20000 px (400 MP, within the budget) scales the hidden 100 px Background x2000. + #[test] + fn image_size_budgets_the_hidden_pixels_it_resamples() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string(); + assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}"); + assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (Size::new(10, 10), past)); + // Without resampling only the resolution changes. + s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(10, 10), 300.0)); + } + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 180f940e91..e809c03dae 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -481,7 +481,7 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { } s.edit("Bitmap", |doc, active| { if (nw, nh) != (doc.size.width, doc.size.height) { - crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic); + crate::image_cmds::resample_doc(doc, CMD, nw, nh, photocraft_algo::resample::Resample::Bicubic)?; } doc.resolution_dpi = dpi; let px = composite(doc, true); diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 84d816a349..57493dcc32 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -399,6 +399,18 @@ fn bitmap_output_resolution_resamples_and_sets_the_resolution() { assert_eq!(first_white(doc(&s), 0, 1, 0), Some(128)); } +/// #9212: the Output resolution resamples the pixels outside the canvas too. A 10 px crop that +/// kept a 1000 px document's pixels, converted from 1 to 100 ppi, would grow them to 100000 px a +/// side (20 GB of gray and alpha at 8 bits, past the 8 GiB budget): refused before allocating. +#[test] +fn bitmap_output_resolution_budgets_the_hidden_pixels() { + let mut s = ramp(1000, 1000, 8, 1.0); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap(); + let err = s.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 100})).unwrap_err().to_string(); + assert!(err.contains("100000 x 100000 px is 10000 megapixels"), "{err}"); + assert_eq!((doc(&s).size.width, doc(&s).mode), (10, ColorMode::Grayscale)); +} + #[test] fn bitmap_halftone_screen_is_laid_out_at_the_output_resolution() { // The same 10 lines/inch screen has twice the cell (in pixels) at twice the resolution: the From 1e75581a963a2633c73c066189f7c59d1898390a Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 01:24:01 +0200 Subject: [PATCH 16/63] Crop: a tool switch during a frame drag ends the drag (#9223) A tool key pressed while dragging the crop frame left crop.drag set when the new tool's handler takes every event before the Crop tool's (Perspective Crop, Ruler, Slice, Frame, Artboard), so the frame was neither committed on leave nor cancelled. tool_event now ends the gesture before any handler runs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 5 +++++ crates/ui-egui/src/crop_ui.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7..00f6dacfdb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5906,6 +5906,11 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { crate::guide_select::clear(app); } + // A tool key pressed mid-drag ends the crop gesture here: a later handler can take every event + // of the new tool, and a drag left set keeps the frame from committing on leave (#9223). + if app.ui.tool != Tool::Crop { + app.crop.drag = None; + } if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) { return; } diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 73e9ce5642..e651507524 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1855,6 +1855,27 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); } + /// A tool key pressed while a drag on the frame is held (⇧C to Perspective Crop): the next + /// tool's events end the gesture, even a tool that takes every event before the Crop tool's + /// handler, and leaving then commits the frame as it does for any other tool (#3368). + #[test] + fn a_tool_switch_during_a_drag_ends_it_and_commits_the_frame() { + for tool in [Tool::PerspectiveCrop, Tool::Ruler, Tool::Slice, Tool::Frame, Tool::Artboard, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + drag(&mut app, &[[20.0, 20.0], [120.0, 80.0]], NONE); + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 50.0, pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?}"); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 60), "{tool:?}"); + } + } + /// Image › Crop with a pending crop commits the frame (the one Image item Photoshop keeps). #[test] fn image_crop_menu_commits_a_pending_crop() { From 60cd5002c455541cff4b5107b6c3091a6a31fe7e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:40:19 +0200 Subject: [PATCH 17/63] Address CodeRabbit review: a tool switch mid-drag ends the crop gesture as a release would A box drawn over the untouched default frame, or that frame resized by a handle, kept `default_frame` set when another tool's key ended the drag, so leaving dropped the edit instead of committing it. The release logic is now one function both paths run. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/crop_ui.rs | 93 ++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 00f6dacfdb..e1a818091b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5907,9 +5907,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) crate::guide_select::clear(app); } // A tool key pressed mid-drag ends the crop gesture here: a later handler can take every event - // of the new tool, and a drag left set keeps the frame from committing on leave (#9223). + // of the new tool, and a drag left set keeps the frame from committing on leave. It ends as a + // release would, so a box drawn over the untouched default frame is committed, not dropped (#9223). if app.ui.tool != Tool::Crop { - app.crop.drag = None; + crate::crop_ui::end_drag(app); } if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) { return; diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index e651507524..a575cefa73 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -760,7 +760,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// Pointer input for the Crop tool. Returns true when the event was its. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if app.ui.tool != Tool::Crop { - app.crop.drag = None; + end_drag(app); return false; } let p = match ev { @@ -828,34 +828,52 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool ToolEvent::Move { .. } => update(app, p, mods), ToolEvent::Up { .. } => { update(app, p, mods); - let Some(drag) = app.crop.drag.take() else { return true }; - let ok = app.ui.crop_rect.is_some_and(size_ok); - // A click outside the untouched default frame (no turn) leaves it the default. - let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app)) - && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); - if ok && changed { - app.crop.default_frame = false; - } - match drag { - CropDrag::Draw { prev, .. } if !ok => app.ui.crop_rect = prev, - CropDrag::Move { rect, .. } | CropDrag::Resize { rect, view: None, .. } if !ok => app.ui.crop_rect = Some(rect), - // A live-centred resize collapsed: the frame and the view it moved go back. - CropDrag::Resize { rect, view: Some(view), .. } if !ok => crate::crop_mode::offset_image(app, rect, Some(view), [0.0, 0.0]), - // Auto Center Preview: a box drawn in default mode moves to the middle. (A resized - // one stays where its centre was: `crop_mode::live_resize`.) - CropDrag::Draw { .. } if ok => { - crate::crop_mode::sync_frame(app); - crate::crop_mode::auto_center(app); - } - _ => {} - } - push_press_step(app); + release(app); } } crate::crop_mode::sync_frame(app); true } +/// Another tool was picked while a drag on the frame is held (a tool key, #9223): the gesture ends +/// where the pointer last moved it, as a release there would, so a box drawn over the untouched +/// default frame counts as drawn and leaving the tool commits it. +pub fn end_drag(app: &mut PhotocraftApp) { + if app.crop.drag.is_none() { + return; + } + let tool = std::mem::replace(&mut app.ui.tool, Tool::Crop); + release(app); + crate::crop_mode::sync_frame(app); + app.ui.tool = tool; +} + +/// The release of a drag on the frame, the pointer's last move already applied. +fn release(app: &mut PhotocraftApp) { + let Some(drag) = app.crop.drag.take() else { return }; + let ok = app.ui.crop_rect.is_some_and(size_ok); + // A click outside the untouched default frame (no turn) leaves it the default. + let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app)) + && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); + if ok && changed { + app.crop.default_frame = false; + } + match drag { + CropDrag::Draw { prev, .. } if !ok => app.ui.crop_rect = prev, + CropDrag::Move { rect, .. } | CropDrag::Resize { rect, view: None, .. } if !ok => app.ui.crop_rect = Some(rect), + // A live-centred resize collapsed: the frame and the view it moved go back. + CropDrag::Resize { rect, view: Some(view), .. } if !ok => crate::crop_mode::offset_image(app, rect, Some(view), [0.0, 0.0]), + // Auto Center Preview: a box drawn in default mode moves to the middle. (A resized + // one stays where its centre was: `crop_mode::live_resize`.) + CropDrag::Draw { .. } if ok => { + crate::crop_mode::sync_frame(app); + crate::crop_mode::auto_center(app); + } + _ => {} + } + push_press_step(app); +} + /// On release: the frame state at the press becomes a box Undo step when the gesture changed it. fn push_press_step(app: &mut PhotocraftApp) { if let Some(before) = app.crop.press_step.take() @@ -1876,6 +1894,35 @@ mod tests { } } + /// The same mid-drag tool switch on the untouched default frame: a box drawn over it, or the + /// frame resized by a handle, is an edit and commits on leave; a box too small to keep goes back + /// to the default frame, which leaving drops (#9223). + #[test] + fn a_tool_switch_during_a_drag_on_the_default_frame_keeps_the_edit() { + let cases: [(&[[f64; 2]], Size); 3] = [ + (&[[20.0, 20.0], [120.0, 80.0]], Size::new(100, 60)), + (&[[200.0, 100.0], [150.0, 80.0]], Size::new(150, 80)), + (&[[20.0, 20.0], [20.0, 20.0]], Size::new(200, 100)), + ]; + for (path, size) in cases { + for tool in [Tool::PerspectiveCrop, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(app.crop.default_frame); + tool_event(&mut app, ToolEvent::Down { x: path[0][0], y: path[0][1], pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: path[1][0], y: path[1][1], pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?} {path:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?} {path:?}"); + assert_eq!(app.session.active().unwrap().doc.size, size, "{tool:?} {path:?}"); + } + } + } + /// Image › Crop with a pending crop commits the frame (the one Image item Photoshop keeps). #[test] fn image_crop_menu_commits_a_pending_crop() { From 9b84eebad8e656b8b61025db35e8b2bd06675fc9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:50:06 +0200 Subject: [PATCH 18/63] Image Size: refuse a resolution outside 1..30000 ppi instead of clamping it (#9260) `image.imageSize` read `resolution` as `as f32` and clamped it to 1..10000, so 1e39 (inf) silently became 10000 ppi, -5 and 0 became 1 ppi, a string was ignored, and a valid 20000 ppi (allowed by File > New, Bitmap and the dialog's own field) dropped to 10000. Parse it once like `image.crop`: non-finite, non-number or outside 1..=MAX_RESOLUTION is an error and the document is untouched; the keepPrintSize path uses the same value. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 38 +++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6353f1f2cc..eb29f9a96d 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -11,6 +11,7 @@ use photocraft_raster::Surface; use serde_json::{Value, json}; use crate::commands::CommandSpec; +use crate::document_preset_cmds::MAX_RESOLUTION; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -212,14 +213,22 @@ fn image_size(s: &mut Session, p: &Value) -> Result { pw = pw.or(Some(ow as f64 * pc / 100.0)); ph = ph.or(Some(oh as f64 * pc / 100.0)); } + // The document range File › New and the dialog's field allow; outside it, refuse rather than + // clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). + let dpi = match p.get("resolution") { + None | Some(Value::Null) => None, + Some(v) => { + let r = v.as_f64().filter(|r| r.is_finite() && (1.0..=MAX_RESOLUTION).contains(r)); + Some(r.ok_or_else(|| bad("image.imageSize", format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))?) + } + }; // `keepPrintSize`: only the resolution given, the pixels follow it (Photoshop's Image Size // with Resample on, as an action records it). if p.get("keepPrintSize").and_then(Value::as_bool) == Some(true) && pw.is_none() && ph.is_none() - && let Some(r) = p.get("resolution").and_then(Value::as_f64) + && let Some(r) = dpi { - let r = r.clamp(1.0, 10_000.0); let old = f64::from(d.doc.resolution_dpi).max(1.0); pw = Some(ow as f64 * r / old); ph = Some(oh as f64 * r / old); @@ -237,7 +246,6 @@ fn image_size(s: &mut Session, p: &Value) -> Result { if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } - let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); @@ -246,7 +254,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { s.edit("Image Size", |doc, _| { if let Some(r) = dpi { // Type keeps its pixel size; resampling below then scales it with the pixels. - crate::canvas_geom::set_resolution(doc, r.clamp(1.0, 10_000.0)); + crate::canvas_geom::set_resolution(doc, r as f32); } let filter = match resample { Method::None => return Ok(()), @@ -1270,6 +1278,28 @@ mod tests { assert_eq!(r, json!({"width": 40, "height": 20})); } + /// #9260: a resolution outside the document range (1..=30000 ppi, as File › New and the + /// dialog's field) is refused and leaves the document as it was, instead of being clamped; + /// one inside it, also above 10000, is set as given. + #[test] + fn image_size_refuses_a_resolution_out_of_range() { + for extra in [json!({"resample": "none"}), json!({"resample": "bicubic"}), json!({"resample": "bicubic", "keepPrintSize": true})] { + for r in [json!(1e39), json!(-5), json!(0), json!(0.5), json!(30_001), json!("300")] { + let mut s = session(); + let (size, dpi, past) = (doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()); + let mut p = extra.clone(); + p["resolution"] = r; + assert!(s.execute("image.imageSize", p.clone()).is_err(), "{p}"); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()), (size, dpi, past), "{p}: nothing changed"); + } + } + let mut s = session(); + s.execute("image.imageSize", json!({"resolution": 20_000, "resample": "none"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(40, 20), 20_000.0)); + s.execute("image.imageSize", json!({"resolution": null, "resample": "none"})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 20_000.0, "null leaves the resolution as it is"); + } + /// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is /// twice as tall (not 4x), with Resample off it stays, and a later re-render doesn't jump. #[test] From d1353e90a744f15b44cb1445d6ac5f66cf8b7fe7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:05:42 +0100 Subject: [PATCH 19/63] Image Size: a document's own out-of-range resolution still passes (#9260) An imported file can carry a resolution outside 1..30000 ppi (PNG pHYs 1 px/m, PSD 0, TIFF in cm), and the Image Size dialog sends it back unchanged on OK; refusing it would block a plain resize that worked before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 20 ++++++++++++++++++-- crates/ui-egui/src/sizing.rs | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index eb29f9a96d..2c8e2e53b3 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -214,11 +214,14 @@ fn image_size(s: &mut Session, p: &Value) -> Result { ph = ph.or(Some(oh as f64 * pc / 100.0)); } // The document range File › New and the dialog's field allow; outside it, refuse rather than - // clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). + // clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). The document's own + // resolution always passes: an imported file may have one outside the range, and the dialog + // sends it back unchanged. let dpi = match p.get("resolution") { None | Some(Value::Null) => None, Some(v) => { - let r = v.as_f64().filter(|r| r.is_finite() && (1.0..=MAX_RESOLUTION).contains(r)); + let own = |r: f64| r as f32 == d.doc.resolution_dpi; + let r = v.as_f64().filter(|&r| r.is_finite() && ((1.0..=MAX_RESOLUTION).contains(&r) || own(r))); Some(r.ok_or_else(|| bad("image.imageSize", format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))?) } }; @@ -1298,6 +1301,19 @@ mod tests { assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(40, 20), 20_000.0)); s.execute("image.imageSize", json!({"resolution": null, "resample": "none"})).unwrap(); assert_eq!(doc(&s).resolution_dpi, 20_000.0, "null leaves the resolution as it is"); + // An imported document's own resolution outside the range (a PNG pHYs of 1 px/m is + // 0.0254 ppi) still resizes: the Image Size dialog sends it back unchanged on OK. + for own in [0.0254_f32, 40_000.0] { + let mut s = session(); + s.edit("ppi", |doc, _| { + doc.resolution_dpi = own; + Ok(()) + }) + .unwrap(); + s.execute("image.imageSize", json!({"width": 80, "height": 40, "resolution": f64::from(own), "resample": "bicubic"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}"); + assert!(s.execute("image.imageSize", json!({"resolution": 0.03, "resample": "none"})).is_err(), "{own}: another value is still checked"); + } } /// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index bf8adf549f..f450e4fab0 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -518,6 +518,30 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(120, 50)); } + /// #9260: OK with a new Width resizes a document whose own resolution is outside the range + /// the command accepts (an imported PNG's pHYs of 1 px/m is 0.0254 ppi); the dialog sends that + /// resolution back unchanged. + #[test] + fn ok_resizes_a_document_with_an_imported_resolution_out_of_range() { + for own in [0.0254_f32, 40_000.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap(); + app.session + .edit("ppi", |doc, _| { + doc.resolution_dpi = own; + Ok(()) + }) + .unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("width".into(), json!(100)); + d.fields.insert("height".into(), json!(50)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(100, 50), own), "{own}"); + } + } + #[test] fn alt_reset_restores_open_dimensions() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From 1e695c5a28cffd940e799cd7c931989125711dde Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:09:27 +0100 Subject: [PATCH 20/63] Crop: Esc in full screen cancels a drag on the default frame (#9275) The untouched default frame lets Esc leave full screen (#7315), but the frame stays the default until the gesture's release, so Esc mid-drag left full screen and the drag went on. A gesture in progress now keeps Esc. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/shortcuts.rs | 8 ++++++-- crates/ui-egui/src/view_cmds/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 023bbb4d79..5df8d24f28 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -692,8 +692,12 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } // In a full screen mode, the Crop tool's untouched default frame (Straighten off) has nothing to cancel: cancelling only - // makes the next one, so Esc goes on to leave full screen (#7315). - let untouched = app.ui.view.screen_mode != "standard" && app.ui.polygon.is_empty() && app.crop.default_frame && !crate::crop_straighten::mode(app); + // makes the next one, so Esc goes on to leave full screen (#7315). A gesture on it is still cancelled (#9275). + let untouched = app.ui.view.screen_mode != "standard" + && app.ui.polygon.is_empty() + && app.crop.default_frame + && app.crop.drag.is_none() + && !crate::crop_straighten::mode(app); if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 05c1f1bb60..82baf593ac 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -577,6 +577,29 @@ fn esc_leaves_full_screen_with_the_crop_tool_showing_its_default_frame() { assert_eq!(app.ui.view.screen_mode, "standard"); } +/// #9275: a gesture on the default frame is something to cancel, so Esc in full screen cancels it +/// (as in Standard Screen Mode) and full screen stays. +#[test] +fn esc_in_full_screen_cancels_a_crop_drag_on_the_default_frame() { + use crate::canvas::{ToolEvent, tool_event}; + let none = egui::Modifiers::NONE; + // Inside draws a new box, on the corner handle resizes, outside turns. + for (what, from) in [("draw", [20.0, 20.0]), ("resize", [100.0, 80.0]), ("turn", [130.0, 110.0])] { + let (mut app, ctx) = app_with(1); + app.ui.tool = crate::Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 50.0, pressure: 1.0 }, none); + assert!(app.crop.drag.is_some() && app.crop.default_frame, "{what}: mid-gesture on the default frame"); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "fullScreen", "{what}: Esc cancels the gesture, full screen stays"); + assert!(app.crop.drag.is_none(), "{what}: the gesture is cancelled"); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 50.0 }, none); + assert!(!crate::crop_ui::pending(&app), "{what}: the release leaves no pending crop"); + } +} + #[test] fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { let (mut app, ctx) = app_with(1); From 3d5c2ac62baf406feb6cd8ab6aad122eb223656d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:15:19 +0100 Subject: [PATCH 21/63] Crop: a click on the frame or a handle never snaps it (#9283) A release at the press point went through the snapping like any drag's, so a click inside an edited frame (or a default-mode click moving the image) or on a handle pulled an edge within reach onto the document's and recorded a box Undo step; a double-click to commit cropped that rect. A click now leaves the frame alone, as a Move-tool click leaves the layer. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/snap_ui.rs | 17 +++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 73e9ce5642..4814ad5331 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1388,6 +1388,27 @@ mod tests { assert_eq!(app.ui.crop_rect, Some([0.0, 10.0, 40.0, 40.0]), "left edge snaps to the document edge"); } + /// A click on the frame (released where it was pressed) changes nothing, as a Move-tool click + /// doesn't move the layer: snapping the release would pull an edge within reach onto the + /// document's, so a double-click to commit cropped another rect. + #[test] + fn a_click_on_the_frame_or_a_handle_never_snaps_it() { + let frame = [5.0, 10.0, 105.0, 60.0]; + for classic in [true, false] { + // Inside (Classic: moves the frame; default: moves the image), the left edge's handle, the top-left corner's. + for p in [[50.0, 30.0], [5.0, 35.0], [5.0, 10.0]] { + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.extras.snap = true; + app.ui.crop_rect = Some(frame); + app.crop.default_frame = false; + drag(&mut app, &[p], NONE); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}, click at {p:?}"); + assert!(!crate::menus::is_enabled(&app, "edit.undo"), "classic {classic}, click at {p:?}: no box step"); + } + } + } + /// #3145: Edit › Undo while a crop is pending steps the *box*, not the document (Photoshop keeps /// Undo live during a crop), so a moved frame comes back. #[test] diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e19c95a2e7..ce8f2347df 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -487,6 +487,18 @@ fn follow_reposition(app: &mut PhotocraftApp) { } } +/// A click on a crop frame or one of its handles (released where it was pressed, the frame not +/// moved) changes nothing, as a Move-tool click doesn't move the layer (`canvas::tool_event`): +/// snapping the release would pull an edge within reach onto a nearby one. +fn crop_click(app: &PhotocraftApp, p: [f64; 2]) -> bool { + use crate::crop_ui::CropDrag; + let Some(snap) = app.prefs_rt.snap.as_ref() else { return false }; + let Some(CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } | CropDrag::MoveImage { rect, .. }) = app.crop.drag else { + return false; + }; + app.ui.tool == Tool::Crop && p == snap.start && app.ui.crop_rect == Some(rect) +} + /// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it). pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent { match ev { @@ -527,6 +539,11 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + if crop_click(app, [x, y]) { + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + return ev; + } follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { From 617d40d2683fac3e5d6a2d1ba4e7b139e0f62d1e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:30:33 +0100 Subject: [PATCH 22/63] Address CodeRabbit review: only the exact own resolution passes, and keepPrintSize with it keeps the pixels (#9260) An out-of-range value that merely rounded to the document's resolution as f32 was accepted, and keepPrintSize with an own resolution below 1 ppi resampled the canvas against old.max(1.0), shrinking it to 1 x 1. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 2c8e2e53b3..49b95c1c05 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -216,23 +216,24 @@ fn image_size(s: &mut Session, p: &Value) -> Result { // The document range File › New and the dialog's field allow; outside it, refuse rather than // clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). The document's own // resolution always passes: an imported file may have one outside the range, and the dialog - // sends it back unchanged. + // sends it back unchanged (exactly: a value that only rounds to it as f32 is refused). + let own_dpi = f64::from(d.doc.resolution_dpi); let dpi = match p.get("resolution") { None | Some(Value::Null) => None, Some(v) => { - let own = |r: f64| r as f32 == d.doc.resolution_dpi; - let r = v.as_f64().filter(|&r| r.is_finite() && ((1.0..=MAX_RESOLUTION).contains(&r) || own(r))); + let r = v.as_f64().filter(|&r| r.is_finite() && ((1.0..=MAX_RESOLUTION).contains(&r) || r == own_dpi)); Some(r.ok_or_else(|| bad("image.imageSize", format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))?) } }; // `keepPrintSize`: only the resolution given, the pixels follow it (Photoshop's Image Size - // with Resample on, as an action records it). + // with Resample on, as an action records it). An unchanged resolution keeps the pixels. if p.get("keepPrintSize").and_then(Value::as_bool) == Some(true) && pw.is_none() && ph.is_none() && let Some(r) = dpi + && r != own_dpi { - let old = f64::from(d.doc.resolution_dpi).max(1.0); + let old = own_dpi.max(1.0); pw = Some(ow as f64 * r / old); ph = Some(oh as f64 * r / old); } @@ -1313,6 +1314,13 @@ mod tests { s.execute("image.imageSize", json!({"width": 80, "height": 40, "resolution": f64::from(own), "resample": "bicubic"})).unwrap(); assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}"); assert!(s.execute("image.imageSize", json!({"resolution": 0.03, "resample": "none"})).is_err(), "{own}: another value is still checked"); + // Only the exact value: one that merely rounds to it as f32 is refused. + let near = if own < 1.0 { f64::from(own) * (1.0 + 1e-9) } else { f64::from(own) + 0.001 }; + assert_eq!(near as f32, own); + assert!(s.execute("image.imageSize", json!({"resolution": near, "resample": "none"})).is_err(), "{own}: {near}"); + // keepPrintSize with the unchanged resolution keeps the pixels. + s.execute("image.imageSize", json!({"resolution": f64::from(own), "resample": "bicubic", "keepPrintSize": true})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}: keepPrintSize"); } } From 6f70783ba953363e55492d5d901457424eb1090e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:35:30 +0100 Subject: [PATCH 23/63] Crop: a Straighten line inside the frame no longer snaps like a frame move (#9277) A press inside an edited, upright Classic Mode frame got the frame-move snap even when it drew a Straighten line (button armed, or Cmd/Ctrl), so every point of the line was shifted by the offset that would align the moved frame, and the frame turned to the wrong angle. The line's ends now snap as points, as they already do outside the frame. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/snap_ui.rs | 43 +++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e19c95a2e7..4c6868d94d 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -240,8 +240,10 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { { // Inside the untouched default frame draws a new crop (in both modes): snap its corner, not // the old canvas-sized frame's bounds. An edited Classic Mode frame still moves and snaps - // its edges. - let gesture = if app.crop.default_frame { Gesture::Point } else { Gesture::Move { rect } }; + // its edges. A straighten line (`crop_straighten::pointer` takes the press first) snaps its + // ends as points, as outside the frame, never by the offset of a frame it doesn't move. + let straightens = app.crop.drag.is_none() && (app.crop.straighten.armed || mods.command); + let gesture = if app.crop.default_frame || straightens { Gesture::Point } else { Gesture::Move { rect } }; Some((gesture, Vec::new())) } else if tool == Tool::Crop && crate::crop_mode::moves_image(app) @@ -699,6 +701,43 @@ mod tests { } } + /// A straighten line drawn inside an upright, edited frame levels the frame on the line as + /// drawn: it isn't shifted by the offset that would snap the frame if the press moved it. The + /// default mode moves the image there, which never shifted the line; it stays as it was. + #[test] + fn a_straighten_line_inside_the_frame_is_not_snapped_as_a_frame_move() { + let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + for (classic, armed, m) in [(true, true, egui::Modifiers::NONE), (true, false, mac_cmd), (false, true, egui::Modifiers::NONE)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.extras.snap = true; + app.ui.view.show.smart_guides = true; + crate::crop_ui::ensure_frame(&mut app); + app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]); + app.crop.default_frame = false; + if classic { + // Without Straighten the press moves the frame and snaps its edges, as on main. + crate::snap_ui::begin(&mut app, [30.0, 30.0], egui::Modifiers::NONE); + let gesture = app.prefs_rt.snap.take().map(|s| s.gesture); + assert_eq!(gesture, Some(Gesture::Move { rect: [20.0, 20.0, 120.0, 70.0] })); + } + app.crop.straighten.armed = armed; + // Moving the frame by the line's (100, 8) would bring its centre 3 px from the + // document's: that offset must not reach the line. Both ends are clear of every target. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 38.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 130.0, y: 38.0 }, m); + let want = crate::crop_straighten::frame_angle([30.0, 30.0], [130.0, 38.0], 0.0).unwrap(); + let got = crate::crop_ui::angle(&app); + assert!((got - want).abs() < 1e-9, "Classic Mode {classic}, armed {armed}: {got} vs {want}"); + } + } + #[test] fn move_tool_snaps_to_other_layer_edges_with_smart_guides() { let mut app = app_with_box(); From 9891eaeb2efcae43002d7829438ba092c0c15711 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:35:15 +0100 Subject: [PATCH 24/63] Crop: History jumps wait for a pending crop (#9285) With a drawn crop frame, a History panel click (state, snapshot, Step Forward/Backward or Delete in its menu) or a control-channel history.selectState resized the document under the frame, which kept its coordinates, so Enter cropped the old ones. The Edit menu already greys these while a crop is pending; the panel and the control channel come through app.run, so crop_ui::intercept now refuses them there and the panel menu greys them, as for Free Transform. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 40 +++++++++++++++++++++++++++ crates/ui-egui/src/history_menu_ui.rs | 24 ++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 73e9ce5642..ca37c16df6 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -495,6 +495,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Intercept Undo/Redo/Toggle Last State while the Crop tool owns box history, for every caller /// (menus, shortcuts, the control channel, MCP) — as `transform_tool::intercept` does. pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { + if holds_history(app, id) { + return Some(Err("Commit or cancel the crop first".into())); + } if app.ui.tool != Tool::Crop || (app.crop.undo.is_empty() && app.crop.redo.is_empty()) { return None; } @@ -506,6 +509,13 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option bool { + pending(app) && matches!(id, "history.selectState" | "history.selectSnapshot" | "history.stepBackward" | "history.stepForward" | "history.deleteState") +} + /// Undo (or redo) one box step. fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value { let s = if redo { app.crop.redo.pop() } else { app.crop.undo.pop() }; @@ -1424,6 +1434,36 @@ mod tests { assert!(crate::menus::is_enabled(&app, "edit.redo")); } + /// A History jump under a drawn frame would resize the document while the frame keeps its + /// coordinates, so Enter cropped the old ones (#9285): the panel's rows, snapshots and menu + /// wait for the crop, as they do for Free Transform. + #[test] + fn history_jumps_wait_for_a_pending_crop() { + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + let mut app = app(SampleType::U8); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("image.canvasSize", json!({"width": 300, "height": 200})).unwrap(); + let snapshot = app.session.active().unwrap().snapshots[0].id; + app.ui.crop_rect = Some([220.0, 120.0, 290.0, 190.0]); + app.crop.default_frame = false; + for (cmd, p) in [ + ("history.selectState", json!({"state": 0})), + ("history.selectSnapshot", json!({"snapshot": snapshot})), + ("history.stepBackward", json!({})), + ("history.deleteState", json!({})), + ] { + assert!(app.run(cmd, p).is_err(), "{cmd} waits for the crop"); + assert_eq!(size(&app), Some((300, 200)), "{cmd} left the document as it was"); + } + assert_eq!(app.ui.crop_rect, Some([220.0, 120.0, 290.0, 190.0]), "the frame stays"); + // Without a drawn frame (the tool's own frame around the canvas) History works as before. + app.crop.default_frame = true; + app.run("history.selectState", json!({"state": 0})).unwrap(); + assert_eq!(size(&app), Some((200, 100))); + app.run("history.stepForward", json!({})).unwrap(); + assert_eq!(size(&app), Some((300, 200))); + } + #[test] fn handles_resize_with_shift_and_alt() { let mut app = app(SampleType::U8); diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs index da77f9d2fb..cadd12e5b2 100644 --- a/crates/ui-egui/src/history_menu_ui.rs +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -42,8 +42,9 @@ fn enabled(app: &PhotocraftApp, cmd: &str) -> bool { if cmd == "window.panel.close" { return !app.session.prefs().workspace_locked; } - // An open Free Transform owns the history (see `panels::history`). - app.ui.transform.is_none() && app.session.is_enabled(cmd) + // An open Free Transform owns the history (see `panels::history`), and a drawn crop frame + // holds its jumps back (`crop_ui::holds_history`). + app.ui.transform.is_none() && !crate::crop_ui::holds_history(app, cmd) && app.session.is_enabled(cmd) } /// Draw the History items of the panel menu and run the one clicked. @@ -153,6 +154,25 @@ mod tests { } } + /// A drawn crop frame greys the items that move the document under it (#9285). + #[test] + fn jumps_wait_for_a_drawn_crop_frame() { + let mut app = app(); + app.ui.tool = crate::state::Tool::Crop; + app.ui.crop_rect = Some([4.0, 4.0, 40.0, 30.0]); + app.crop.default_frame = false; + let mut h = harness(app); + for label in ["Step Backward", "Delete"] { + assert!(is_disabled(&h, label), "{label}"); + } + assert!(!is_disabled(&h, "New Snapshot…"), "a snapshot leaves the document as it is"); + h.state_mut().crop.default_frame = true; + h.run_steps(2); + assert!(!is_disabled(&h, "Step Backward"), "the tool's own frame around the canvas holds nothing back"); + click(&mut h, "Step Backward"); + assert_eq!(states(&h), (3, 1)); + } + #[test] fn new_snapshot_adds_a_snapshot_and_leaves_the_states() { let mut h = harness(app()); From 81c716b85ae90c88b68298cd9651115ebea99376 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:45:10 +0100 Subject: [PATCH 25/63] Crop: test Step Forward under a drawn frame too (#9285) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_ui.rs | 9 +++++++-- crates/ui-egui/src/history_menu_ui.rs | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index ca37c16df6..9de681397c 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -510,8 +510,8 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option bool { pending(app) && matches!(id, "history.selectState" | "history.selectSnapshot" | "history.stepBackward" | "history.stepForward" | "history.deleteState") } @@ -1460,6 +1460,11 @@ mod tests { app.crop.default_frame = true; app.run("history.selectState", json!({"state": 0})).unwrap(); assert_eq!(size(&app), Some((200, 100))); + // Step Forward has a state to redo now; a drawn frame holds it back too. + app.crop.default_frame = false; + assert!(app.run("history.stepForward", json!({})).is_err(), "history.stepForward waits for the crop"); + assert_eq!(size(&app), Some((200, 100))); + app.crop.default_frame = true; app.run("history.stepForward", json!({})).unwrap(); assert_eq!(size(&app), Some((300, 200))); } diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs index cadd12e5b2..b529293b93 100644 --- a/crates/ui-egui/src/history_menu_ui.rs +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -161,8 +161,9 @@ mod tests { app.ui.tool = crate::state::Tool::Crop; app.ui.crop_rect = Some([4.0, 4.0, 40.0, 30.0]); app.crop.default_frame = false; + app.session.execute("history.stepBackward", json!({})).unwrap(); let mut h = harness(app); - for label in ["Step Backward", "Delete"] { + for label in ["Step Backward", "Step Forward", "Delete"] { assert!(is_disabled(&h, label), "{label}"); } assert!(!is_disabled(&h, "New Snapshot…"), "a snapshot leaves the document as it is"); @@ -170,7 +171,7 @@ mod tests { h.run_steps(2); assert!(!is_disabled(&h, "Step Backward"), "the tool's own frame around the canvas holds nothing back"); click(&mut h, "Step Backward"); - assert_eq!(states(&h), (3, 1)); + assert_eq!(states(&h), (2, 2)); } #[test] From 4aff057464cf2d8cb9c229b728d47dcc5c0c69cd Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:49:35 +0100 Subject: [PATCH 26/63] Address CodeRabbit review: a Windows Ctrl straighten line doesn't snap its start (#9277) Windows Ctrl both starts a straighten line and is held while drawing it, which turns snapping off on Move and Up; the press still snapped, so the frame turned by a different angle. The override now covers the press of a straighten line too, and every straighten press snaps as a point, wherever it starts. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/snap_ui.rs | 51 +++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 4c6868d94d..027ef23c81 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -214,6 +214,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let tool = crate::canvas::event_tool(app, mods); let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods); let crop_tol = crate::crop_ui::tolerance(app); + let straightens = tool == Tool::Crop && app.crop.drag.is_none() && (app.crop.straighten.armed || mods.command); let gesture = if let Some(t) = &app.ui.transform { let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) { crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])), @@ -233,6 +234,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { } else if tool == Tool::Move || moves_layer { let exclude = app.session.active().map(|s| s.selected_layers()).unwrap_or_default(); moving_rect(app, tool).map(|rect| (Gesture::Move { rect }, exclude)) + } else if straightens { + // A straighten line (`crop_straighten::pointer` takes the press before the frame's drags) + // snaps its ends as points wherever it starts, never by the offset of a frame or image it + // doesn't move. + Some((Gesture::Point, Vec::new())) } else if tool == Tool::Crop && (crate::crop_mode::classic(app) || app.crop.default_frame) && let Some(rect) = @@ -240,10 +246,8 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { { // Inside the untouched default frame draws a new crop (in both modes): snap its corner, not // the old canvas-sized frame's bounds. An edited Classic Mode frame still moves and snaps - // its edges. A straighten line (`crop_straighten::pointer` takes the press first) snaps its - // ends as points, as outside the frame, never by the offset of a frame it doesn't move. - let straightens = app.crop.drag.is_none() && (app.crop.straighten.armed || mods.command); - let gesture = if app.crop.default_frame || straightens { Gesture::Point } else { Gesture::Move { rect } }; + // its edges. + let gesture = if app.crop.default_frame { Gesture::Point } else { Gesture::Move { rect } }; Some((gesture, Vec::new())) } else if tool == Tool::Crop && crate::crop_mode::moves_image(app) @@ -297,7 +301,10 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { } else { SnapTargets::default() }; - app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); + // Windows Ctrl both starts a straighten line and is held while it is drawn: the override then + // covers the press too, or the line's start would snap and its end not. + let disabled = straightens && override_held(mods); + app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled }); } /// Snap a guide drag that ⌘ with a selection tool starts at `p` (`canvas::command_guide_at`, @@ -738,6 +745,40 @@ mod tests { } } + /// Windows Ctrl both starts a straighten line and is held while drawing it, which turns + /// snapping off: the press stays where it was too, so the frame levels on the line as drawn, + /// inside an edited frame, outside it and in the untouched default frame. ⌘ on the Mac is not + /// the override: both ends snap. + #[test] + fn a_windows_ctrl_straighten_line_does_not_snap_its_start_either() { + let win_ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + for frame in [Some([20.0, 20.0, 120.0, 70.0]), Some([130.0, 20.0, 190.0, 70.0]), None] { + for (m, start) in [(win_ctrl, [97.0, 30.0]), (mac_cmd, [100.0, 30.0])] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = true; + app.ui.extras.snap = true; + crate::crop_ui::ensure_frame(&mut app); + if let Some(r) = frame { + app.ui.crop_rect = Some(r); + app.crop.default_frame = false; + } + // The press is 3 px from the document's centre line; the end is clear of every target. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 97.0, y: 30.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 180.0, y: 38.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 180.0, y: 38.0 }, m); + let want = crate::crop_straighten::frame_angle(start, [180.0, 38.0], 0.0).unwrap(); + let got = crate::crop_ui::angle(&app); + assert!((got - want).abs() < 1e-9, "frame {frame:?}, {m:?}: {got} vs {want}"); + } + } + } + #[test] fn move_tool_snaps_to_other_layer_edges_with_smart_guides() { let mut app = app_with_box(); From 3640b3162505d2484c0972fb54b66e702a93e06e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 03:13:26 +0100 Subject: [PATCH 27/63] Crop / Perspective Crop: a drag held past the canvas edge scrolls the view (#9307) The edge auto-scroll (#4183) ran only while app.drag was set, but both crop tools keep their gestures in app.crop.drag / app.pcrop.drag. Frame draws, moves and classic resizes, and Perspective Crop box, corner and quad drags, now scroll too; turns and the default mode's camera-driven drags stay as they were. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/crop_ui.rs | 61 ++++++++++++++++++++++ crates/ui-egui/src/perspective_crop_ui.rs | 62 +++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7..50900231fd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4895,9 +4895,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); } // A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the - // drag's next move then extends into what scrolled in. + // drag's next move then extends into what scrolled in. The crop tools keep their own drags. if primary - && app.drag.is_some() + && (app.drag.is_some() || crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app)) && tool != Tool::Hand && !middle && let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten()) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 73e9ce5642..25aacdb5b8 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -419,6 +419,13 @@ pub fn active(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_some() } +/// A drag that sizes or moves the frame over the image, which scrolls the view when held past the +/// canvas edge (#4183). Not the turns, nor the default mode's image moves and live-centred resizes: +/// those drive the camera themselves. +pub fn autoscrolls(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop && matches!(app.crop.drag, Some(CropDrag::Draw { .. } | CropDrag::Move { .. } | CropDrag::Resize { view: None, .. })) +} + /// Cancel the pending crop: the frame, any gesture on it and the default-frame state. With the /// Crop tool still picked, [`ensure_frame`] then makes the active document a new default frame. pub fn cancel(app: &mut PhotocraftApp) { @@ -1536,6 +1543,60 @@ mod tests { assert!(st.crop.drag.is_none()); } + /// Real egui input: a handle dragged past the canvas edge and held there scrolls the view, as a + /// marquee drag does (#4183), so the frame can reach the part of the image that was off screen. + #[test] + fn a_handle_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The right edge's middle handle: document (100, 25). + let p0 = h.state().ui.views[0].center; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: p0, flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 25.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Resize { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[2] > 175.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(out, false)); + h.step(); + } + /// #1918: a pending crop belongs to the document it was drawn on. File › New, opening a file /// or switching tabs cancels it; it never shows on, or crops, another document. /// A turned pending frame is dropped with its angle when another document becomes active, and diff --git a/crates/ui-egui/src/perspective_crop_ui.rs b/crates/ui-egui/src/perspective_crop_ui.rs index 2a696bd5ed..1753eb3d7b 100644 --- a/crates/ui-egui/src/perspective_crop_ui.rs +++ b/crates/ui-egui/src/perspective_crop_ui.rs @@ -53,6 +53,11 @@ pub fn pending(app: &PhotocraftApp) -> bool { app.ui.perspective_crop.is_some() || !app.pcrop.clicks.is_empty() || app.pcrop.drag.is_some() } +/// A box, corner or quad drag in progress: held past the canvas edge, it scrolls the view (#4183). +pub fn dragging(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::PerspectiveCrop && app.pcrop.drag.is_some() +} + pub fn cancel(app: &mut PhotocraftApp) { app.ui.perspective_crop = None; app.pcrop.drag = None; @@ -327,6 +332,63 @@ mod tests { assert_eq!(app.ui.perspective_crop, None); } + /// Real egui input: a corner dragged past the canvas edge and held there scrolls the view, as a + /// marquee drag does (#4183), so the corner can reach the part of the image that was off screen. + #[test] + fn a_corner_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // The top-right corner: document (100, 10). + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::Corner { index: 1, .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.perspective_crop.is_some_and(|q| q[1][0] > 175.0), "the corner reached past what showed: {:?}", st.ui.perspective_crop); + h.event(button(out, false)); + h.step(); + } + /// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the /// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order. #[test] From 92dfbca72f024fc858fd7ff8f822a2398810f5c6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 03:26:57 +0100 Subject: [PATCH 28/63] Crop auto-scroll: only for a press on the canvas (#9307) A crop drag left over by a tool switch (#9223) must not scroll the view while a panel slider beside the canvas is held. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 5 ++-- crates/ui-egui/src/crop_ui.rs | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 50900231fd..d6e7b0e4a1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4895,9 +4895,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); } // A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the - // drag's next move then extends into what scrolled in. The crop tools keep their own drags. + // drag's next move then extends into what scrolled in. The crop tools keep their own drags, + // pressed on this canvas (not a panel slider held while a stale crop drag lingers). if primary - && (app.drag.is_some() || crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app)) + && (app.drag.is_some() || (pointer_here && (crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app)))) && tool != Tool::Hand && !middle && let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten()) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 25aacdb5b8..c589714106 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1597,6 +1597,51 @@ mod tests { h.step(); } + /// A press that started off the canvas (a panel slider beside it) doesn't scroll the view, + /// even with a crop drag still recorded: only a drag on the canvas auto-scrolls. + #[test] + fn a_press_beside_the_canvas_does_not_scroll_it_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + // Left over from a drag a tool switch didn't end. + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The canvas takes the left 400 points; the rest stands in for a panel. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p = pos2(500.0, 100.0); + h.event(Event::PointerMoved(p)); + h.event(button(p, true)); + h.step(); + h.event(Event::PointerMoved(pos2(550.0, 100.0))); + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "the view stayed put"); + h.event(button(pos2(550.0, 100.0), false)); + h.step(); + } + /// #1918: a pending crop belongs to the document it was drawn on. File › New, opening a file /// or switching tabs cancels it; it never shows on, or crops, another document. /// A turned pending frame is dropped with its angle when another document becomes active, and From a4bb2d90469071d7a9ab1f0909b245a11f32838c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 10:43:32 +0100 Subject: [PATCH 29/63] Address CodeRabbit review: a click-sized Perspective Crop press doesn't auto-scroll A new-box press held past egui's click time and slipped a point or two past the canvas edge counted as a drag for auto-scroll, while the release still classifies it as a corner-placing click; the scroll then moved the pointer's document position and turned the click into a box. `dragging` now uses the same click test as the release (`is_click`). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/perspective_crop_ui.rs | 90 ++++++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/perspective_crop_ui.rs b/crates/ui-egui/src/perspective_crop_ui.rs index 1753eb3d7b..d3c5488d04 100644 --- a/crates/ui-egui/src/perspective_crop_ui.rs +++ b/crates/ui-egui/src/perspective_crop_ui.rs @@ -54,8 +54,21 @@ pub fn pending(app: &PhotocraftApp) -> bool { } /// A box, corner or quad drag in progress: held past the canvas edge, it scrolls the view (#4183). +/// A press that hasn't moved further than a click doesn't: scrolling would turn a corner-placing +/// click near the edge into a box. pub fn dragging(app: &PhotocraftApp) -> bool { - app.ui.tool == Tool::PerspectiveCrop && app.pcrop.drag.is_some() + app.ui.tool == Tool::PerspectiveCrop + && match app.pcrop.drag { + Some(Drag::New { start, cur }) => !is_click(app, start, cur), + Some(_) => true, + None => false, + } +} + +/// A new-box press released within [`CLICK_PX`] screen points of where it began places a corner. +fn is_click(app: &PhotocraftApp, start: [f64; 2], cur: [f64; 2]) -> bool { + let click = crate::distort_ui::tolerance(app) * CLICK_PX / 8.0; + (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click } pub fn cancel(app: &mut PhotocraftApp) { @@ -126,8 +139,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { ToolEvent::Up { .. } => { drag_to(app, p); if let Some(Drag::New { start, cur }) = app.pcrop.drag { - let click = tol * CLICK_PX / 8.0; - if (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click { + if is_click(app, start, cur) { app.pcrop.clicks.push(start); if let [a, b, c, d] = app.pcrop.clicks[..] { app.ui.perspective_crop = Some(ordered([a, b, c, d])); @@ -389,6 +401,78 @@ mod tests { h.step(); } + /// A corner-placing click pressed just inside the canvas edge that slips a point past it doesn't + /// scroll the view (scrolling would turn it into a box); a new box dragged past the edge does. + #[test] + fn a_click_at_the_canvas_edge_places_a_corner_without_scrolling() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas 100 points from the window's left: document x 25..175 + // shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // Pressed a point inside the left edge (the scrollbars sit right and bottom), slipping two + // points past it: still a click. + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + // Held past egui's click time, the press becomes a drag that hasn't left the click size. + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.pcrop.clicks.len(), 1, "the click placed a corner: {:?}", st.pcrop.clicks); + assert_eq!(st.ui.perspective_crop, None); + // A new box dragged from inside the canvas past its edge does scroll. + let start = pos2(200.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(140.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::New { .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the box drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); + } + /// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the /// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order. #[test] From 130b2b373fa1efaa9b2ec0f894dc2a52682e00f7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 11:08:24 +0100 Subject: [PATCH 30/63] SVG: read the DOCTYPE's internal subset when checking the markup Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 49 +++++++++++++++++++++++++++++++++++---- crates/io/tests/it/svg.rs | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 6363945d7e..bc4881cf27 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -263,7 +263,7 @@ fn parse(bytes: &[u8]) -> Result { /// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one). fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Result { let xml = inflate(bytes)?; - if nesting_depth(&xml, MAX_XML_DEPTH) > MAX_XML_DEPTH { + if nesting_depth(&xml, MAX_XML_DEPTH)? > MAX_XML_DEPTH { return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep"))); } let has_text = xml.windows(4).any(|w| w == b"text"); @@ -297,8 +297,10 @@ fn inflate(bytes: &[u8]) -> Result, IoError> { /// The deepest element nesting in `xml`, stopping as soon as it passes `limit` (the result is /// then `limit + 1`). Comments, CDATA sections, processing instructions and the DOCTYPE add /// nothing; quoted attribute values may hold `>` or `/>`. Malformed markup is only counted -/// loosely: the parser rejects it afterwards. -fn nesting_depth(xml: &[u8], limit: usize) -> usize { +/// loosely: the parser rejects it afterwards. The parser expands the DTD's entities after this +/// count, so an entity holding markup (a `<`) is an error: it could nest deeper than anything +/// counted here. A `<` there stays text. +fn nesting_depth(xml: &[u8], limit: usize) -> Result { let after = |from: usize, pat: &[u8]| -> usize { xml.get(from..).and_then(|s| s.windows(pat.len()).position(|w| w == pat)).map_or(xml.len(), |p| from.saturating_add(p).saturating_add(pat.len())) }; @@ -320,6 +322,41 @@ fn nesting_depth(xml: &[u8], limit: usize) -> usize { } (xml.len(), false) }; + // The byte after the DOCTYPE starting at `from`: its internal subset `[...]` is walked + // declaration by declaration, so a quote in a comment there can't hide the markup after it. + let doctype_end = |from: usize| -> Result { + let (mut quote, mut j) = (None, from); + loop { + let Some(&b) = xml.get(j) else { return Ok(xml.len()) }; + j = j.saturating_add(1); + match quote { + Some(q) if b == q => quote = None, + Some(_) => {} + None if b == b'"' || b == b'\'' => quote = Some(b), + None if b == b'>' => return Ok(j), + None if b == b'[' => break, + None => {} + } + } + while let Some(rest) = xml.get(j..).filter(|r| !r.is_empty()) { + j = if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b" usize { after(at.saturating_add(9), b"]]>") } else if rest.starts_with(b"") + } else if rest.starts_with(b" usize { depth = depth.saturating_add(1); deepest = deepest.max(depth); if deepest > limit { - return deepest; + return Ok(deepest); } } end }; } - deepest + Ok(deepest) } /// The document canvas for a drawing: its size in px, scaled down to [`MAX_SIDE`] when needed. diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index c71bb0ae4f..527067508b 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -351,6 +351,47 @@ fn deeply_nested_markup_is_an_error_not_a_stack_overflow() { assert_eq!(r.document.layers.len(), 1); } +/// The parser expands a DTD's internal entities after the depth check: an entity holding +/// markup, or a quote in a DTD comment that made the check skip ahead, hid 20 000 nested groups +/// from it and overflowed the stack again. Both are errors now; same small stack as above. +#[test] +fn a_dtd_cannot_hide_nesting_from_the_depth_check() { + let deep = |open: &str, close: &str| format!("{}{}", open.repeat(20_000), close.repeat(20_000)); + let with_dtd = |subset: &str, body: &str| format!("{body}"); + let cases = [ + ("entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("single-quoted entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("quote in a comment", with_dtd("", &format!("{}'", deep("", "")))), + ]; + std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(move || { + for (name, svg) in &cases { + let gz = gzip(svg.as_bytes()); + for bytes in [svg.as_bytes(), gz.as_slice()] { + let e = import("deep.svg", bytes).err().unwrap_or_else(|| panic!("{name} opened")); + assert!(matches!(&e, IoError::Svg(_)), "{name}: {e}"); + } + } + }) + .unwrap() + .join() + .unwrap(); +} + +/// Text-only entities, as Illustrator declares its namespaces, still open, `<` included: +/// the parser keeps a character reference in an entity as text. +#[test] +fn text_entities_in_a_dtd_still_open() { + let r = open( + " ?> ]>\ + &c;", + ); + assert_eq!(r.document.layers.len(), 1); + assert!(close(px(&r.document, 4, 4), [1.0, 0.0, 0.0, 1.0])); +} + /// The depth check reads the markup, not the parser's tree: it must not count `<` in comments, /// CDATA, processing instructions or the DOCTYPE, nor end a tag at a `>` inside quotes. #[test] From b05e02c9b319beccf58ddd8ad000b05dc91064e2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 11:20:04 +0100 Subject: [PATCH 31/63] SVG: end the DTD's element, attribute and notation declarations as the parser does Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 7 +++++-- crates/io/tests/it/svg.rs | 10 ++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index bc4881cf27..17eaef221d 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -343,12 +343,15 @@ fn nesting_depth(xml: &[u8], limit: usize) -> Result { after(j.saturating_add(4), b"-->") } else if rest.starts_with(b"") - } else if rest.starts_with(b"`, quotes or not. + after(j.saturating_add(2), b">") } else if rest.starts_with(b"]") { return Ok(tag_end(j.saturating_add(1)).0); } else { diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 527067508b..f856616989 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -352,7 +352,7 @@ fn deeply_nested_markup_is_an_error_not_a_stack_overflow() { } /// The parser expands a DTD's internal entities after the depth check: an entity holding -/// markup, or a quote in a DTD comment that made the check skip ahead, hid 20 000 nested groups +/// markup, or a quote in a DTD comment or declaration that made the check skip ahead, hid 20 000 nested groups /// from it and overflowed the stack again. Both are errors now; same small stack as above. #[test] fn a_dtd_cannot_hide_nesting_from_the_depth_check() { @@ -362,6 +362,12 @@ fn a_dtd_cannot_hide_nesting_from_the_depth_check() { ("entity", with_dtd(&format!("", deep("", "")), "&a;")), ("single-quoted entity", with_dtd(&format!("", deep("", "")), "&a;")), ("quote in a comment", with_dtd("", &format!("{}'", deep("", "")))), + ("quote in an element declaration", with_dtd("", &format!("{}'", deep("", "")))), + ( + "entity between quoted attribute lists", + with_dtd(&format!("", deep("", "")), "&a;"), + ), + ("entity between quoted notations", with_dtd(&format!("", deep("", "")), "&a;")), ]; std::thread::Builder::new() .stack_size(512 * 1024) @@ -385,7 +391,7 @@ fn a_dtd_cannot_hide_nesting_from_the_depth_check() { fn text_entities_in_a_dtd_still_open() { let r = open( " ?> ]>\ + ?> ]>\ &c;", ); assert_eq!(r.document.layers.len(), 1); From c1c36251cb8a810733468c1bc47089f7f9c2803d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 12:03:41 +0100 Subject: [PATCH 32/63] SVG: refuse filters too costly to render (#9409) resvg's feMorphology reads the whole radius window per pixel, feConvolveMatrix the whole kernel, and feTurbulence runs every octave (usvg doesn't clamp numOctaves), and none of it can be interrupted: a small file kept opening or a smart-object render busy for minutes. Estimate the filter work before rendering (masks, clip paths, patterns and embedded drawings included, at the render scale) and refuse the drawing past about ten seconds of it. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 56 +++++++++++++++++++++++++++++++++++++++ crates/io/tests/it/svg.rs | 33 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 6363945d7e..2676e688b5 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -105,6 +105,12 @@ const MAX_PEAK_PIXEL_BYTES: u64 = 3 << 29; const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). const MAX_RECURSION: usize = 200; +/// Most filter work a drawing may ask of resvg, in pixel reads (see [`filter_cost`]): about ten +/// seconds in a release build (resvg manages roughly 200 million reads a second, and its filters +/// can't be interrupted). +const MAX_FILTER_COST: u64 = 2_000_000_000; +/// One `feTurbulence` octave per pixel, in pixel reads (it measures about 15 times a read). +const TURBULENCE_OCTAVE_COST: u64 = 16; /// Deepest element nesting accepted. The XML parser recurses once per level, so a file nested /// a few thousand deep (70 KB of ``) overflows a 2 MB thread stack, an abort no guard can /// catch; real drawings stay under a hundred levels. @@ -390,6 +396,7 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, warnings.push("SVG: the text needs a font and none is installed, so it was dropped".to_string()); } let cv = canvas(&tree, &mut warnings); + check_filter_cost(&tree, cv.scale)?; let mut doc = Document::new(name, cv.size, ColorMode::Rgb, SampleType::U8); let fmt = doc.pixel_format(); let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale); @@ -418,6 +425,7 @@ pub fn rasterize(bytes: &[u8], scale: f32) -> Result usize { n } +/// The work resvg's filters would do to render the drawing at `scale` canvas px per unit, in +/// pixel reads: its morphology reads the whole radius window per pixel, convolution the whole +/// kernel, and turbulence runs every octave (usvg doesn't clamp `numOctaves`). Masks, clip paths, +/// patterns and embedded drawings count too. +fn filter_cost(root: &usvg::Group, scale: f32, rec: usize) -> u64 { + use usvg::filter::Kind; + let mut cost = 0u64; + let mut stack: Vec<&usvg::Group> = vec![root]; + while let Some(g) = stack.pop() { + let ts = tiny_skia::Transform::from_scale(scale, scale).pre_concat(g.abs_transform()); + let (sx, sy) = ts.get_scale(); + for f in g.filters() { + let Some(r) = f.rect().transform(ts) else { continue }; + // Float-to-int casts saturate, so a huge region counts as huge. + let (w, h) = (r.width().ceil() as u64, r.height().ceil() as u64); + let window = |radius: f32, s: f32, side: u64| ((radius * s).ceil() as u64).saturating_mul(2).min(side); + for p in f.primitives() { + let reads = match p.kind() { + Kind::ConvolveMatrix(c) => u64::from(c.matrix().columns()) * u64::from(c.matrix().rows()), + Kind::Morphology(m) => window(m.radius_x().get(), sx, w).saturating_mul(window(m.radius_y().get(), sy, h)), + Kind::Turbulence(t) => u64::from(t.num_octaves()).saturating_mul(TURBULENCE_OCTAVE_COST), + _ => 1, + }; + cost = cost.saturating_add(w.saturating_mul(h).saturating_mul(reads.max(1))); + } + } + for node in g.children() { + if let usvg::Node::Group(child) = node { + stack.push(child); + } + if rec < MAX_RECURSION { + node.subroots(|sub| cost = cost.saturating_add(filter_cost(sub, scale, rec + 1))); + } + } + } + cost +} + +/// An error when the drawing's filters are too costly to render at `scale` (see [`filter_cost`]). +fn check_filter_cost(tree: &usvg::Tree, scale: f32) -> Result<(), IoError> { + if filter_cost(tree.root(), scale, 0) > MAX_FILTER_COST { + return Err(IoError::Svg( + "the drawing's filters are too costly to render (a huge morphology radius, convolution kernel or turbulence octave count)".into(), + )); + } + Ok(()) +} + /// Converts a parsed tree into layers. struct Conv { fmt: PixelFormat, diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index c71bb0ae4f..64dfe70c81 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -413,6 +413,39 @@ fn rasterize_renders_the_whole_drawing_at_a_scale() { assert!(svg::rasterize(huge.as_bytes(), 64.0).is_err(), "a render past the pixel cap is an error, not an allocation"); } +/// resvg's morphology, convolution and turbulence cost pixels × radius², × order² and × octaves: +/// such a drawing is refused up front instead of rendering for minutes. +#[test] +fn filters_too_costly_to_render_are_refused_not_run() { + let drawing = |side: u32, fe: &str| { + format!( + "{fe}" + ) + }; + let kernel = vec!["1"; 100 * 100].join(" "); + let in_mask = format!( + "" + ); + for svg in [ + drawing(2000, ""), + drawing(2000, &format!("")), + drawing(2000, ""), + in_mask, + ] { + let e = import("x.svg", svg.as_bytes()).err().unwrap_or_else(|| panic!("opened: {}", &svg[..200])); + assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); + assert!(svg::rasterize(svg.as_bytes(), 1.0).is_err(), "rendered: {}", &svg[..200]); + } + // Ordinary filters still open and render. + let ordinary = drawing(500, ""); + assert!(!open(&ordinary).document.layers.is_empty()); + assert!(svg::rasterize(ordinary.as_bytes(), 1.0).is_ok()); + // The smart-object render counts its scale: cheap at 1×, refused at 40×. + let small = drawing(100, ""); + assert!(svg::rasterize(small.as_bytes(), 1.0).is_ok()); + assert!(svg::rasterize(small.as_bytes(), 40.0).is_err()); +} + #[test] fn an_svg_round_trips_through_pcraft_as_shapes() { let r = open(&format!( From a617ba83fe2e18652d279fb11677ec769ae3dc58 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 12:05:11 +0100 Subject: [PATCH 33/63] SVG: a drawing the renderer panics on is an error, not a crash (#9404) usvg, resvg and tiny-skia panic on some malformed drawings (an feOffset past the pixmap's size, an embedded SVG image of near-zero width). Parse and every render now run under catch_unwind, as the HEIF and JPEG XL decoders do: a parse panic is IoError::Svg, a panicking element is dropped with a warning and the rest opens, a smart-object render is an error, and an icon-theme mask is None. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/icon_theme.rs | 36 +++++++++++++++++++++---------- crates/io/src/svg.rs | 27 ++++++++++++++++++----- crates/io/tests/it/svg.rs | 25 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/apps/photocraft/src/icon_theme.rs b/apps/photocraft/src/icon_theme.rs index cf2ede3b7b..562dcf76e3 100644 --- a/apps/photocraft/src/icon_theme.rs +++ b/apps/photocraft/src/icon_theme.rs @@ -202,17 +202,22 @@ pub fn rasterize_mask(svg: &[u8], px: u32) -> Option> { image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) }, ..usvg::Options::default() }; - let tree = usvg::Tree::from_data(svg, &opts).ok()?; - let size = tree.size(); - let side = size.width().max(size.height()); - if !side.is_finite() || side <= 0.0 { - return None; - } - let s = px as f32 / side; - let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); - let mut pixmap = tiny_skia::Pixmap::new(px, px)?; - resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); - Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) + // usvg and resvg panic on some malformed drawings: such an icon is unreadable, not a crash. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let tree = usvg::Tree::from_data(svg, &opts).ok()?; + let size = tree.size(); + let side = size.width().max(size.height()); + if !side.is_finite() || side <= 0.0 { + return None; + } + let s = px as f32 / side; + let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); + let mut pixmap = tiny_skia::Pixmap::new(px, px)?; + resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); + Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) + })) + .ok() + .flatten() } #[cfg(test)] @@ -303,6 +308,15 @@ mod tests { assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none()); } + /// resvg panics rendering an offset past the pixmap's size: the icon is unreadable instead. + #[test] + fn a_mask_the_renderer_panics_on_is_none() { + let svg = r##" + + "##; + assert!(rasterize_mask(svg.as_bytes(), 16).is_none()); + } + /// An icon's `` is never loaded: not a device file, not an embedded picture. #[test] fn masks_load_no_images() { diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 6363945d7e..1340a9f0f1 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -274,10 +274,19 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Resul // re-checks the gzip magic and would decompress a second time without our size limit if // the inflated content happened to start with a nested gzip stream. let text = std::str::from_utf8(&xml).map_err(|_| IoError::Svg("the SVG is not valid UTF-8".into()))?; - let tree = usvg::Tree::from_str(text, &opt).map_err(|e| IoError::Svg(e.to_string()))?; + let tree = guarded(|| usvg::Tree::from_str(text, &opt)) + .ok_or_else(|| IoError::Svg("the SVG parser failed on this drawing".into()))? + .map_err(|e| IoError::Svg(e.to_string()))?; Ok(Parsed { tree, fontless_text }) } +/// `f` under `catch_unwind`, `None` when it panics: usvg, resvg and tiny-skia panic on some +/// malformed drawings, which must fail like any unreadable file, not crash (as the HEIF and +/// JPEG XL decoders are guarded). Where a panic aborts (wasm) this changes nothing. +fn guarded(f: impl FnOnce() -> T) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok() +} + /// The plain XML of `bytes`: itself, or the inflated content of a gzip `.svgz`, refused past /// [`MAX_INFLATED_BYTES`] (a decompression bomb) or when the stream is corrupt. fn inflate(bytes: &[u8]) -> Result, IoError> { @@ -400,8 +409,11 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); if let Some(mut pixmap) = pixmap(w, h) { - resvg::render(&tree, global, &mut pixmap.as_mut()); - doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt)))); + if guarded(|| resvg::render(&tree, global, &mut pixmap.as_mut())).is_some() { + doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt)))); + } else { + conv.warnings.push("SVG: the drawing could not be rendered".to_string()); + } } } else { doc.layers = conv.convert_children(tree.root().children(), 0, 0, tree.root().abs_transform()); @@ -421,7 +433,8 @@ pub fn rasterize(bytes: &[u8], scale: f32) -> Result"); + let e = import("image.svg", bad_image.as_bytes()).err().unwrap_or_else(|| panic!("opened")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + // resvg panics rendering an offset past the pixmap's size: that element is dropped with a + // warning, the rest of the drawing still opens. + let offset = "\ + "; + let svg = format!("{offset}"); + let r = open(&svg); + assert_eq!(r.document.layers.len(), 1, "{:?}", r.document.layers.iter().map(|l| &l.name).collect::>()); + assert!(matches!(r.document.layers[0].content, LayerContent::Shape(_))); + assert!(r.warnings.iter().any(|w| w.contains("could not be rendered")), "{:?}", r.warnings); + // The same drawing as a smart object, and past the element limit (opened as one pixel layer). + let e = svg::rasterize(svg.as_bytes(), 1.0).err().unwrap_or_else(|| panic!("rendered")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + let many = format!("{offset}{}", "".repeat(2_000)); + let r = open(&many); + assert!(r.document.layers.is_empty()); + assert!(r.warnings.iter().any(|w| w.contains("could not be rendered")), "{:?}", r.warnings); +} + #[test] fn an_svg_round_trips_through_pcraft_as_shapes() { let r = open(&format!( From b974aca9dde5150bf6f71b84248ef3367a72e56c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 12:10:39 +0100 Subject: [PATCH 34/63] SVG: a drawing that could not be rendered isn't also called empty (#9404) Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 4 ++-- crates/io/tests/it/svg.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 1340a9f0f1..91719a14f1 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -412,13 +412,13 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, if guarded(|| resvg::render(&tree, global, &mut pixmap.as_mut())).is_some() { doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt)))); } else { - conv.warnings.push("SVG: the drawing could not be rendered".to_string()); + conv.note("raster-panic", "SVG: the drawing could not be rendered".to_string()); } } } else { doc.layers = conv.convert_children(tree.root().children(), 0, 0, tree.root().abs_transform()); } - if doc.layers.is_empty() { + if doc.layers.is_empty() && !conv.noted.contains(&"raster-panic") { conv.warnings.push("SVG: nothing to draw (an empty drawing)".to_string()); } Ok(ImportResult { document: doc, warnings: conv.warnings, source_read_only: false, preview_only: false }) diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 4205a7f277..a7942caddd 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -436,6 +436,7 @@ fn drawings_the_svg_renderer_panics_on_are_errors_not_crashes() { let r = open(&many); assert!(r.document.layers.is_empty()); assert!(r.warnings.iter().any(|w| w.contains("could not be rendered")), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("nothing to draw")), "not an empty drawing: {:?}", r.warnings); } #[test] From 8250046f99f842b5b87baa7ac0b87c2451631517 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 12:21:30 +0100 Subject: [PATCH 35/63] SVG: filter cost counts masks, patterns and embedded drawings at their drawn scale Count only the reads beyond an ordinary per-pixel allowance, so ordinary filter chains still open on the largest canvas. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 61 ++++++++++++++++++++++++++++++--------- crates/io/tests/it/svg.rs | 21 ++++++++++---- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 2676e688b5..6ffac539a9 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -105,10 +105,13 @@ const MAX_PEAK_PIXEL_BYTES: u64 = 3 << 29; const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). const MAX_RECURSION: usize = 200; -/// Most filter work a drawing may ask of resvg, in pixel reads (see [`filter_cost`]): about ten -/// seconds in a release build (resvg manages roughly 200 million reads a second, and its filters -/// can't be interrupted). +/// Most filter work beyond the ordinary a drawing may ask of resvg, in pixel reads (see +/// [`filter_cost`]): about ten seconds in a release build (resvg manages roughly 200 million reads +/// a second, and its filters can't be interrupted). const MAX_FILTER_COST: u64 = 2_000_000_000; +/// Reads per pixel any primitive may take for free (a 16 × 16 morphology window or kernel, 16 +/// turbulence octaves), so ordinary filters open on any canvas. +const ORDINARY_FILTER_READS: u64 = 256; /// One `feTurbulence` octave per pixel, in pixel reads (it measures about 15 times a read). const TURBULENCE_OCTAVE_COST: u64 = 16; /// Deepest element nesting accepted. The XML parser recurses once per level, so a file nested @@ -480,16 +483,17 @@ fn count_nodes(root: &usvg::Group) -> usize { n } -/// The work resvg's filters would do to render the drawing at `scale` canvas px per unit, in -/// pixel reads: its morphology reads the whole radius window per pixel, convolution the whole -/// kernel, and turbulence runs every octave (usvg doesn't clamp `numOctaves`). Masks, clip paths, -/// patterns and embedded drawings count too. -fn filter_cost(root: &usvg::Group, scale: f32, rec: usize) -> u64 { +/// The work beyond [`ORDINARY_FILTER_READS`] per pixel that resvg's filters would do to render +/// the drawing through `base` (drawing units to canvas px), in pixel reads: its morphology reads +/// the whole radius window per pixel, convolution the whole kernel, and turbulence runs every +/// octave (usvg doesn't clamp `numOctaves`). Masks, clip paths, patterns and embedded drawings +/// count too, at the scale of the element that uses them, as resvg renders them. +fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize) -> u64 { use usvg::filter::Kind; let mut cost = 0u64; let mut stack: Vec<&usvg::Group> = vec![root]; while let Some(g) = stack.pop() { - let ts = tiny_skia::Transform::from_scale(scale, scale).pre_concat(g.abs_transform()); + let ts = base.pre_concat(g.abs_transform()); let (sx, sy) = ts.get_scale(); for f in g.filters() { let Some(r) = f.rect().transform(ts) else { continue }; @@ -501,17 +505,30 @@ fn filter_cost(root: &usvg::Group, scale: f32, rec: usize) -> u64 { Kind::ConvolveMatrix(c) => u64::from(c.matrix().columns()) * u64::from(c.matrix().rows()), Kind::Morphology(m) => window(m.radius_x().get(), sx, w).saturating_mul(window(m.radius_y().get(), sy, h)), Kind::Turbulence(t) => u64::from(t.num_octaves()).saturating_mul(TURBULENCE_OCTAVE_COST), - _ => 1, + _ => 0, }; - cost = cost.saturating_add(w.saturating_mul(h).saturating_mul(reads.max(1))); + cost = cost.saturating_add(w.saturating_mul(h).saturating_mul(reads.saturating_sub(ORDINARY_FILTER_READS))); } } for node in g.children() { if let usvg::Node::Group(child) = node { stack.push(child); } - if rec < MAX_RECURSION { - node.subroots(|sub| cost = cost.saturating_add(filter_cost(sub, scale, rec + 1))); + if rec >= MAX_RECURSION { + continue; + } + let world = base.pre_concat(node.abs_transform()); + let mut sub = |root: &usvg::Group, ts: tiny_skia::Transform| cost = cost.saturating_add(filter_cost(root, ts, rec + 1)); + match node { + // resvg draws a pattern's tile at the path's scale times the pattern's own. + usvg::Node::Path(p) => { + for paint in [p.fill().map(|f| f.paint()), p.stroke().map(|s| s.paint())].into_iter().flatten() { + if let usvg::Paint::Pattern(pt) = paint { + sub(pt.root(), world.pre_concat(pt.transform())); + } + } + } + _ => node.subroots(|root| sub(root, world)), } } } @@ -520,7 +537,7 @@ fn filter_cost(root: &usvg::Group, scale: f32, rec: usize) -> u64 { /// An error when the drawing's filters are too costly to render at `scale` (see [`filter_cost`]). fn check_filter_cost(tree: &usvg::Tree, scale: f32) -> Result<(), IoError> { - if filter_cost(tree.root(), scale, 0) > MAX_FILTER_COST { + if filter_cost(tree.root(), tiny_skia::Transform::from_scale(scale, scale), 0) > MAX_FILTER_COST { return Err(IoError::Svg( "the drawing's filters are too costly to render (a huge morphology radius, convolution kernel or turbulence octave count)".into(), )); @@ -975,6 +992,22 @@ mod tests { assert!(!is_svg(b"")); } + /// Ordinary filters, however many and on the largest canvas, never count against the budget. + #[test] + fn ordinary_filters_cost_nothing_on_any_canvas() { + let svg = format!( + "\ + \ + \ + \ + ", + vec!["1"; 25].join(" ") + ); + let tree = parse(svg.as_bytes()).unwrap().tree; + assert_eq!(filter_cost(tree.root(), tiny_skia::Transform::identity(), 0), 0); + assert!(check_filter_cost(&tree, 1.0).is_ok()); + } + #[test] fn quadratics_become_cubics_and_closing_merges_the_start_knot() { let mut pb = tiny_skia::PathBuilder::new(); diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 64dfe70c81..55b835d306 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -423,18 +423,27 @@ fn filters_too_costly_to_render_are_refused_not_run() { ) }; let kernel = vec!["1"; 100 * 100].join(" "); - let in_mask = format!( - "" - ); + // Masks, patterns and embedded drawings filter at the scale of the element using them: a + // 20-unit radius-10 morphology drawn 100× larger. + let f = ""; + let inner = format!("{f}"); + let embedded = base64_data_url("image/svg+xml", inner.as_bytes()); for svg in [ drawing(2000, ""), drawing(2000, &format!("")), drawing(2000, ""), - in_mask, + format!( + "{f}" + ), + format!( + "{f}" + ), + format!(""), ] { - let e = import("x.svg", svg.as_bytes()).err().unwrap_or_else(|| panic!("opened: {}", &svg[..200])); + let head: String = svg.chars().take(300).collect(); + let e = import("x.svg", svg.as_bytes()).err().unwrap_or_else(|| panic!("opened: {head}")); assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); - assert!(svg::rasterize(svg.as_bytes(), 1.0).is_err(), "rendered: {}", &svg[..200]); + assert!(svg::rasterize(svg.as_bytes(), 1.0).is_err(), "rendered: {head}"); } // Ordinary filters still open and render. let ordinary = drawing(500, ""); From 1c595a34d9937f415a86aa7fd62ec6db14fb495c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 12:53:26 +0100 Subject: [PATCH 36/63] Address CodeRabbit review: filter cost check stops after a visit budget Patterns that fill and stroke with the next one double the elements the check walks at every level (and resvg's render with it): 24 levels took about 100 s. Past 10 million visits the drawing now counts as too costly. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 21 +++++++++++++++------ crates/io/tests/it/svg.rs | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 6ffac539a9..5d040c69d3 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -112,6 +112,10 @@ const MAX_FILTER_COST: u64 = 2_000_000_000; /// Reads per pixel any primitive may take for free (a 16 × 16 morphology window or kernel, 16 /// turbulence octaves), so ordinary filters open on any canvas. const ORDINARY_FILTER_READS: u64 = 256; +/// Most elements [`filter_cost`] visits, counting a shared mask, clip path or pattern once per +/// use, as resvg renders it: patterns that fill and stroke with the next one double the work at +/// each level, so a few dozen levels would keep the check (and the render) busy for hours. +const MAX_FILTER_VISITS: usize = 10_000_000; /// One `feTurbulence` octave per pixel, in pixel reads (it measures about 15 times a read). const TURBULENCE_OCTAVE_COST: u64 = 16; /// Deepest element nesting accepted. The XML parser recurses once per level, so a file nested @@ -487,8 +491,9 @@ fn count_nodes(root: &usvg::Group) -> usize { /// the drawing through `base` (drawing units to canvas px), in pixel reads: its morphology reads /// the whole radius window per pixel, convolution the whole kernel, and turbulence runs every /// octave (usvg doesn't clamp `numOctaves`). Masks, clip paths, patterns and embedded drawings -/// count too, at the scale of the element that uses them, as resvg renders them. -fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize) -> u64 { +/// count too, at the scale of the element that uses them, as resvg renders them. Past `visits` +/// elements the drawing counts as too costly. +fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize, visits: &mut usize) -> u64 { use usvg::filter::Kind; let mut cost = 0u64; let mut stack: Vec<&usvg::Group> = vec![root]; @@ -511,6 +516,10 @@ fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize) -> u6 } } for node in g.children() { + *visits = visits.saturating_sub(1); + if *visits == 0 { + return u64::MAX; + } if let usvg::Node::Group(child) = node { stack.push(child); } @@ -518,7 +527,7 @@ fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize) -> u6 continue; } let world = base.pre_concat(node.abs_transform()); - let mut sub = |root: &usvg::Group, ts: tiny_skia::Transform| cost = cost.saturating_add(filter_cost(root, ts, rec + 1)); + let mut sub = |root: &usvg::Group, ts: tiny_skia::Transform| cost = cost.saturating_add(filter_cost(root, ts, rec + 1, visits)); match node { // resvg draws a pattern's tile at the path's scale times the pattern's own. usvg::Node::Path(p) => { @@ -537,9 +546,9 @@ fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize) -> u6 /// An error when the drawing's filters are too costly to render at `scale` (see [`filter_cost`]). fn check_filter_cost(tree: &usvg::Tree, scale: f32) -> Result<(), IoError> { - if filter_cost(tree.root(), tiny_skia::Transform::from_scale(scale, scale), 0) > MAX_FILTER_COST { + if filter_cost(tree.root(), tiny_skia::Transform::from_scale(scale, scale), 0, &mut { MAX_FILTER_VISITS }) > MAX_FILTER_COST { return Err(IoError::Svg( - "the drawing's filters are too costly to render (a huge morphology radius, convolution kernel or turbulence octave count)".into(), + "the drawing's filters are too costly to render (a huge morphology radius, convolution kernel or turbulence octave count, or patterns nested too deep)".into(), )); } Ok(()) @@ -1004,7 +1013,7 @@ mod tests { vec!["1"; 25].join(" ") ); let tree = parse(svg.as_bytes()).unwrap().tree; - assert_eq!(filter_cost(tree.root(), tiny_skia::Transform::identity(), 0), 0); + assert_eq!(filter_cost(tree.root(), tiny_skia::Transform::identity(), 0, &mut { MAX_FILTER_VISITS }), 0); assert!(check_filter_cost(&tree, 1.0).is_ok()); } diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 55b835d306..60f9d6c15f 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -455,6 +455,27 @@ fn filters_too_costly_to_render_are_refused_not_run() { assert!(svg::rasterize(small.as_bytes(), 40.0).is_err()); } +/// Patterns that fill and stroke with the next one double resvg's work at every level: past the +/// check's element budget the drawing is refused, not walked (or rendered) for hours. +#[test] +fn patterns_nested_too_deep_are_refused_not_walked() { + let chain = |depth: usize| { + let defs: String = (0..depth) + .map(|i| { + let paint = if i + 1 < depth { format!("url(#p{})", i + 1) } else { "red".into() }; + format!("") + }) + .collect(); + format!("{defs}") + }; + let deep = chain(24); + let e = import("x.svg", deep.as_bytes()).err().unwrap_or_else(|| panic!("opened a 2^24-use pattern chain")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("too costly")), "{e}"); + assert!(svg::rasterize(deep.as_bytes(), 1.0).is_err()); + // A few levels still open. + assert!(!open(&chain(4)).document.layers.is_empty()); +} + #[test] fn an_svg_round_trips_through_pcraft_as_shapes() { let r = open(&format!( From 4ab5cdc3add91af965e2db31707643e0f545fdfb Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 15:06:22 +0100 Subject: [PATCH 37/63] Address CodeRabbit review: patterns past the recursion cutoff count as too costly usvg keeps a chain of single-use patterns past 200 levels and resvg draws every one, so a costly filter at the end of the chain went uncharged. A referenced root (pattern, mask, clip path, embedded drawing) the check would skip at the cutoff now makes the drawing too costly; ordinary children there still count as before. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 18 +++++++++++++++++- crates/io/tests/it/svg.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 5d040c69d3..e6ae119ef2 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -492,7 +492,8 @@ fn count_nodes(root: &usvg::Group) -> usize { /// the whole radius window per pixel, convolution the whole kernel, and turbulence runs every /// octave (usvg doesn't clamp `numOctaves`). Masks, clip paths, patterns and embedded drawings /// count too, at the scale of the element that uses them, as resvg renders them. Past `visits` -/// elements the drawing counts as too costly. +/// elements, or with a referenced root nested past [`MAX_RECURSION`], the drawing counts as too +/// costly. fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize, visits: &mut usize) -> u64 { use usvg::filter::Kind; let mut cost = 0u64; @@ -524,6 +525,21 @@ fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize, visit stack.push(child); } if rec >= MAX_RECURSION { + // usvg keeps referenced roots past this depth and resvg draws them: one the check + // would skip makes the drawing too costly. Ordinary children still count. + let mut skipped = false; + match node { + usvg::Node::Path(p) => { + skipped = [p.fill().map(|f| f.paint()), p.stroke().map(|s| s.paint())] + .into_iter() + .flatten() + .any(|paint| matches!(paint, usvg::Paint::Pattern(_))) + } + _ => node.subroots(|_| skipped = true), + } + if skipped { + return u64::MAX; + } continue; } let world = base.pre_concat(node.abs_transform()); diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 60f9d6c15f..68a1c09b8a 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -476,6 +476,37 @@ fn patterns_nested_too_deep_are_refused_not_walked() { assert!(!open(&chain(4)).document.layers.is_empty()); } +/// usvg keeps a chain of single-use patterns past the check's recursion cutoff and resvg draws +/// every one of them: a costly filter at the end of the chain is refused, not rendered unseen. +#[test] +fn a_costly_filter_past_the_pattern_recursion_cutoff_is_refused() { + let chain = |depth: usize, fe: &str| { + let defs: String = (0..depth) + .map(|i| { + let body = if i + 1 < depth { + format!("", i + 1) + } else { + "".into() + }; + format!("{body}") + }) + .collect(); + format!("{fe}{defs}") + }; + // Just over the budget (about 3.5 billion reads), so a regression fails in seconds, not hours. + let fe = ""; + assert!(import("x.svg", chain(4, fe).as_bytes()).is_err(), "the filter alone is over the budget"); + let deep = chain(210, fe); + let e = import("x.svg", deep.as_bytes()).err().unwrap_or_else(|| panic!("opened a costly filter 210 patterns deep")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("too costly")), "{e}"); + assert!(svg::rasterize(deep.as_bytes(), 1.0).is_err()); + // A short chain with an ordinary filter still opens, and so do groups nested past the cutoff. + assert!(!open(&chain(4, "")).document.layers.is_empty()); + let groups = + format!("{}{}", "".repeat(250), "".repeat(250)); + assert!(!open(&groups).document.layers.is_empty()); +} + #[test] fn an_svg_round_trips_through_pcraft_as_shapes() { let r = open(&format!( From 21d0ac3a1026c01d8d4b1906e91940956165d53b Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 26 Oct 2026 13:27:52 +0100 Subject: [PATCH 38/63] Tests: a .pcraft smart object with a hostile SVG source does not abort Image Size or a PSD save Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/tests/it/svg_smart_object.rs | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/engine/tests/it/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs index 16d8f3d459..450d54c175 100644 --- a/crates/engine/tests/it/svg_smart_object.rs +++ b/crates/engine/tests/it/svg_smart_object.rs @@ -62,3 +62,32 @@ fn re_placing_at_another_scale_re_renders_the_vector_source() { assert!(alpha(&surf, 268, 60) > 0.99 && alpha(&surf, 271, 60) < 0.01); assert!(red(&surf, 150, 81) < 0.02 && red(&surf, 150, 75) > 0.98 && red(&surf, 150, 87) > 0.98); } + +/// A `.pcraft` opens from its cached pixels and parses an embedded SVG source only on a later +/// edit. A source whose DTD entity hides 20 000 nested groups from the depth check used to open +/// clean and then overflow the stack on Image Size or a PSD save; both are an error or a fallback +/// now. Run on a 2 MB stack, as the app's import and command threads are. +#[test] +fn a_pcraft_smart_object_with_a_hostile_svg_does_not_abort_a_later_edit() { + let deep = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let hostile = format!("]>&a;"); + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100, "background": "white"})).unwrap(); + photocraft_engine::file_cmds::place_bytes(&mut s, "logo.svg", SVG.as_bytes().to_vec(), None, &json!({"scale": 100})).unwrap(); + let mut doc = (*s.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layers.last_mut().unwrap().content else { panic!("smart object") }; + sm.source = photocraft_doc::SmartSource::Embedded { file_name: "logo.svg".into(), bytes: std::sync::Arc::new(hostile.into_bytes()) }; + let carrier = photocraft_format::save_to_bytes(&doc, &photocraft_format::SaveOptions::default()).unwrap(); + + photocraft_engine::file_cmds::open_bytes_as(&mut s, "carrier.pcraft", &carrier, None, None).unwrap(); + let _ = s.execute("image.imageSize", json!({"width": 50, "height": 50})); + let dir = tempfile::tempdir().unwrap(); + let _ = s.execute("file.saveACopy", json!({"path": dir.path().join("copy.psd").to_string_lossy()})); + }) + .unwrap() + .join() + .unwrap(); +} From da87348f8612c701c008a75835ed7a0841154ae9 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 26 Oct 2026 13:52:42 +0100 Subject: [PATCH 39/63] SVG: the fallback font search reads each font's character map once (#9671) A character the text's font lacks made usvg's fallback search ask every face in the database whether it has the character, and for a face loaded from a file (every system font) that reads the whole font file. Once per text element: 20 elements with U+10FF00 took 3 s with 273 installed faces. The fallback selector is now our own copy of usvg's loop (same order, used-face exclusion and style filter), and the per-face check answers from a per-process cache of each file face's cmap table, picked as ttf-parser's Face::parse picks it (last record of a linear walk, so an unsorted table directory gives the same table) and looked up the same way. Bounded at 64 MiB; a file that doesn't load isn't remembered. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + crates/io/Cargo.toml | 2 + crates/io/src/svg.rs | 141 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 128d0f35bd..1930841ac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5139,6 +5139,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tiff", + "ttf-parser", "zune-core 0.5.3", "zune-jpegxl", ] diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 83ccd3b784..5cf15cf62d 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -44,6 +44,8 @@ roxmltree = "0.21" # already in the tree through the UI's icon loader; text shaping needs the fonts feature set. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] } +# The SVG fallback font search keeps each font file's character map (already in the tree through usvg). +ttf-parser = { version = "0.25", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 7ff563d466..4532c33697 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -268,15 +268,78 @@ fn in_memory(id: usvg::fontdb::ID, db: &mut Arc) -> usvg loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id) } +/// Most `cmap` table bytes [`has_char`] keeps; past it a face is asked on disk (slow, still right). +#[cfg(not(target_arch = "wasm32"))] +const MAX_CACHED_CMAP_BYTES: usize = 64 << 20; + +/// Whether face `face` maps `c` to a glyph, as usvg's own check decides it. usvg reads a font +/// file's whole font for every face it asks, so one character no font has re-read every +/// installed font once per text element (430 ms each with 273 faces). A file face's `cmap` table +/// is kept instead, read once per process; a file that doesn't load isn't remembered. +#[cfg(not(target_arch = "wasm32"))] +fn has_char(db: &usvg::fontdb::Database, face: &usvg::fontdb::FaceInfo, c: char) -> bool { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::{Mutex, PoisonError}; + use ttf_parser::{Face, Tag, cmap}; + type Cmaps = (HashMap<(PathBuf, u32), Arc>>, usize); + static CMAPS: OnceLock> = OnceLock::new(); + let maps = || CMAPS.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + let in_cmap = + |table: &[u8]| cmap::Table::parse(table).is_some_and(|t| t.subtables.into_iter().any(|s| s.is_unicode() && s.glyph_index(u32::from(c)).is_some())); + let usvg_check = |data: &[u8], index| Face::parse(data, index).is_ok_and(|f| f.glyph_index(c).is_some()); + let usvg::fontdb::Source::File(path) = &face.source else { + return db.with_face_data(face.id, usvg_check).unwrap_or(false); + }; + let key = (path.clone(), face.index); + if let Some(table) = maps().0.get(&key).cloned() { + return in_cmap(&table); + } + // usvg's check parses the whole face first: one that doesn't parse has no characters. Its + // `cmap` is the last such table record (a linear walk, unsorted directories included). + let cmap_of = |data: &[u8], index| { + let face = Face::parse(data, index).ok()?; + let raw = face.raw_face(); + let record = raw.table_records.into_iter().filter(|r| r.tag == Tag::from_bytes(b"cmap")).last(); + let table = record.and_then(|r| { + let start = usize::try_from(r.offset).ok()?; + raw.data.get(start..start.checked_add(usize::try_from(r.length).ok()?)?) + }); + Some(table.unwrap_or_default().to_vec()) + }; + let Some(table) = db.with_face_data(face.id, cmap_of).flatten() else { + return false; + }; + let found = in_cmap(&table); + let mut maps = maps(); + // An import that missed at the same time may have kept it already. + if !maps.0.contains_key(&key) && maps.1.saturating_add(table.len()) <= MAX_CACHED_CMAP_BYTES { + maps.1 = maps.1.saturating_add(table.len()); + maps.0.insert(key, Arc::new(table)); + } + found +} + +/// usvg's fallback choice (the first face, in database order, not used yet, sharing the base +/// face's style, weight or stretch and having `c`), asked through [`has_char`]. +#[cfg(not(target_arch = "wasm32"))] +fn fallback_face(c: char, used: &[usvg::fontdb::ID], db: &usvg::fontdb::Database) -> Option { + let base = db.face(*used.first()?)?; + db.faces() + .filter(|f| !used.contains(&f.id)) + .filter(|f| base.style == f.style || base.weight == f.weight || base.stretch == f.stretch) + .find(|f| has_char(db, f, c)) + .map(|f| f.id) +} + /// usvg's font choice, with the chosen faces read from memory ([`in_memory`]). fn font_resolver() -> usvg::FontResolver<'static> { #[cfg(not(target_arch = "wasm32"))] { let select = usvg::FontResolver::default_font_selector(); - let fallback = usvg::FontResolver::default_fallback_selector(); usvg::FontResolver { select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))), - select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))), + select_fallback: Box::new(|c, used, db| fallback_face(c, used, db).map(|id| in_memory(id, db))), } } #[cfg(target_arch = "wasm32")] @@ -1281,6 +1344,80 @@ mod tests { } } + /// #9671: the fallback search for a character the text's font lacks reads each font file's + /// character map once per process, not the whole file on every text element (430 ms an + /// element with 273 installed faces). A second import still finds the fallback face after + /// its file stopped being readable, so it didn't go back to the disk. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_fallback_search_reads_each_font_file_once() { + use std::sync::atomic::{AtomicBool, Ordering}; + static GONE: AtomicBool = AtomicBool::new(false); + fn path() -> std::path::PathBuf { + std::env::temp_dir().join(format!("photocraft-svg-test-fallback-{}.ttf", std::process::id())) + } + fn inter_and_mono_on_disk() -> Arc { + std::fs::write(path(), include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_file(path()).unwrap(); + if GONE.load(Ordering::SeqCst) { + std::fs::write(path(), b"not a font any more").unwrap(); + } + Arc::new(db) + } + // Inter has no box-drawing characters; JetBrains Mono has. + let svg = "I\u{2500}I"; + let outlines = |parsed: Parsed| { + let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap().document.layers; + let groups = layers.into_iter().filter_map(|l| if let LayerContent::Group(g) = l.content { Some(g) } else { None }); + groups.flat_map(|g| g.children).filter_map(|l| if let LayerContent::Shape(s) = l.content { Some(s.path) } else { None }).collect::>() + }; + let first = parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap(); + let mono = + first.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_))); + assert!(mono, "the box-drawing character falls back to JetBrains Mono"); + let first = outlines(first); + GONE.store(true, Ordering::SeqCst); + let second = outlines(parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap()); + let _ = std::fs::remove_file(path()); + assert!(first.iter().any(|p| !p.subpaths.is_empty()), "the text has outlines"); + assert!(second == first, "the second import set the character in the same fallback face"); + } + + /// #9671: the kept `cmap` is the one usvg reads (the last such record of a linear walk), also + /// in a font whose table directory isn't sorted by tag, where a binary search misses it. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_fallback_search_reads_the_cmap_of_an_unsorted_font() { + fn inter_and_unsorted_mono_on_disk() -> Arc { + let mut font = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec(); + let count = usize::from(u16::from_be_bytes([font[4], font[5]])); + // The `cmap` record moves to the end of the directory. + let mut records: Vec> = font[12..12 + count * 16].chunks(16).map(<[u8]>::to_vec).collect(); + let at = records.iter().position(|r| r.starts_with(b"cmap")).unwrap(); + let cmap = records.remove(at); + records.push(cmap); + font.splice(12..12 + count * 16, records.concat()); + let raw = ttf_parser::RawFace::parse(&font, 0).unwrap(); + assert!(raw.table(ttf_parser::Tag::from_bytes(b"cmap")).is_none(), "a binary search misses the cmap"); + assert!(raw.table(ttf_parser::Tag::from_bytes(b"name")).is_some(), "fontdb still finds the name"); + let path = std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id())); + std::fs::write(&path, font).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_file(&path).unwrap(); + Arc::new(db) + } + let svg = + b"I\xE2\x94\x80I"; + let parsed = parse_with(svg, inter_and_unsorted_mono_on_disk).unwrap(); + let _ = std::fs::remove_file(std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id()))); + let mono = + parsed.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_))); + assert!(mono, "the box-drawing character falls back to JetBrains Mono"); + } + /// #9406: moving a font collection into memory keeps its faces in their order, so the /// fallback search for a later text still finds the face it found on disk first. #[cfg(not(target_arch = "wasm32"))] From bfa718f20b8f3af2ecdd50eb73acf24b7c750b49 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 26 Oct 2026 14:43:48 +0100 Subject: [PATCH 40/63] Dropdowns: a list opening above its box is in place from its first visible frame (#5495) egui placed an upward list's bottom by the size it had the frame before, so it moved up by ~46 pt as it settled and a click two frames after opening missed the row (main's intermediate_ui_scale_choices test). Its top is now placed from the rows' measured height plus the popup frame's margin. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/widgets.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cf860b2b93..e5115bc12d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1769,15 +1769,25 @@ pub fn combo_list( let rows_id = popup_id.with("rows"); let rows = ctx.data(|d| d.get_temp::(rows_id)); let up = rows.is_some_and(|rows| rows > below) && above > below; - let (align, room) = if up { (egui::RectAlign::TOP_START, above) } else { (egui::RectAlign::BOTTOM_START, below) }; // Only the room that side has, even when it is small: a taller list would be pushed back // into the window, over its button (#5495 review). - let room = room.max(0.0); + let room = (if up { above } else { below }).max(0.0); + // Above the button the list's top is placed from its known height: egui would place its + // bottom by the size the list had last frame, so it would move as it settles, under a + // click aimed at a row. `chrome` is the popup frame's margin and stroke. + let anchor = match rows.filter(|_| up) { + Some(rows) => { + let chrome = egui::Frame::popup(&ctx.global_style()).total_margin().sum().y; + let top = button.rect.top() - rows.min(room) - chrome; + egui::Rect::from_x_y_ranges(button.rect.x_range(), top - button.rect.height()..=top) + } + None => button.rect, + }; let parent_layer = ui.layer_id(); let inner = egui::Popup::menu(&button) .id(popup_id) - .anchor(button.rect) - .align(align) + .anchor(anchor) + .align(egui::RectAlign::BOTTOM_START) .align_alternatives(&[]) .width(button.rect.width()) .close_behavior(close) From 55673a1e490c2b65a4a951ff9fcc3303302a7120 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 27 Oct 2026 01:42:42 +0100 Subject: [PATCH 41/63] Address CodeRabbit review: pick the first snapshot's type layers on their own (#6087) Served fonts chose the layers to redraw from the open document only, so the first History snapshot was refreshed only for layers the open document still had, and with the open layer's "shows our own layout" verdict. A type layer deleted since the document opened kept its fallback-font pixels in the snapshot, and a PSD layer edited since lost the file's pixels there. `served_fonts::pick` now checks the snapshot (when it no longer is the open document) on its own, and `Session::refresh_type_layers` takes `TypeLayerRef`s that say which version each layer was picked in. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 28 ++++++++---- crates/engine/src/type_cmds.rs | 38 +++++++++++++--- crates/ui-egui/src/served_fonts.rs | 69 ++++++++++++++++++++++++------ 3 files changed, 107 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 52f065b5fb..70e2207cc2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -255,6 +255,16 @@ pub struct DocState { pub(crate) source_snapshot: Option, } +/// A type layer for [`Session::refresh_type_layers`]: in the open document, or in its first +/// History snapshot (the document as opened) when `first_snapshot`. The caller picks each against +/// that version of the layer, which may differ from the open one or be gone from it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TypeLayerRef { + pub doc: photocraft_doc::DocId, + pub layer: LayerId, + pub first_snapshot: bool, +} + /// `doc` with its type layers among `ids` re-rendered (`Session::refresh_type_layers`), or `None` /// when none of them is a type layer of `doc`. fn refresh_text_layers(doc: &Arc, ids: &[LayerId]) -> Option { @@ -1019,33 +1029,33 @@ impl Session { /// unchanged, so this is no history step and leaves a clean document clean. Unknown documents /// and layers are skipped. A document a background job is computing from must not move under /// it: its layers are returned, to try again later. - pub fn refresh_type_layers(&mut self, layers: &[(photocraft_doc::DocId, LayerId)]) -> Vec<(photocraft_doc::DocId, LayerId)> { + pub fn refresh_type_layers(&mut self, layers: &[TypeLayerRef]) -> Vec { let mut busy = Vec::new(); let mut docs: Vec = Vec::new(); - for (d, _) in layers { - if !docs.contains(d) { - docs.push(*d); + for l in layers { + if !docs.contains(&l.doc) { + docs.push(l.doc); } } for doc_id in docs { - let mine: Vec = layers.iter().filter(|(d, _)| *d == doc_id).map(|(_, id)| *id).collect(); if self.job_on(doc_id).is_some() { - busy.extend(mine.iter().map(|id| (doc_id, *id))); + busy.extend(layers.iter().filter(|l| l.doc == doc_id)); continue; } + let ids = |first: bool| -> Vec { layers.iter().filter(|l| l.doc == doc_id && l.first_snapshot == first).map(|l| l.layer).collect() }; let Some(st) = self.docs.iter_mut().find(|st| st.doc.id == doc_id) else { continue }; // The first snapshot is the document as opened (#2966, #6087): it was taken before the // font arrived, so its type layers get the same refresh, or returning to it brings the // fallback-font pixels back. While it still is the open document, it shares the - // refreshed one. + // refreshed one; once edited, it is refreshed on its own, layers the edit removed too. let first_is_doc = st.snapshots.iter().any(|s| s.id == 0 && Arc::ptr_eq(&s.doc, &st.doc)); if !first_is_doc && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) - && let Some(doc) = refresh_text_layers(&first.doc, &mine) + && let Some(doc) = refresh_text_layers(&first.doc, &ids(true)) { first.doc = Arc::new(doc); } - let Some(doc) = refresh_text_layers(&st.doc, &mine) else { continue }; + let Some(doc) = refresh_text_layers(&st.doc, &ids(false)) else { continue }; let doc = Arc::new(doc); if first_is_doc && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) { first.doc = doc.clone(); diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index c5840cac6c..ffa4bb11bb 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -1291,8 +1291,9 @@ mod tests { st.saved_revision = st.revision; } let rev = s.active().unwrap().revision; - let unknown = [(doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; - assert!(s.refresh_type_layers(&[&[(doc_id, id)][..], &unknown].concat()).is_empty()); + let layers = [(doc_id, id), (doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; + let layers: Vec<_> = layers.iter().map(|&(doc, layer)| crate::TypeLayerRef { doc, layer, first_snapshot: false }).collect(); + assert!(s.refresh_type_layers(&layers).is_empty()); assert!(text_layer(&s, id.0).cache.is_some()); let st = s.active().unwrap(); assert!(st.revision > rev); @@ -1306,6 +1307,7 @@ mod tests { /// the refresh re-renders its type layers too, so returning to it keeps the real font (#6087). #[test] fn refreshing_type_layers_refreshes_the_first_snapshot() { + use crate::TypeLayerRef; let mut s = session(); let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap()); let mut stale = (*s.active().unwrap().doc).clone(); @@ -1315,26 +1317,52 @@ mod tests { LayerContent::Text(t) => t.cache.is_some(), other => panic!("{}", other.kind_name()), }; + let open = |doc, layer| TypeLayerRef { doc, layer, first_snapshot: false }; + let first = |doc, layer| TypeLayerRef { doc, layer, first_snapshot: true }; // Opened with the fallback font: the first snapshot is the open document itself. s.add_document(stale.clone(), None); let doc_id = s.active().unwrap().doc.id; assert!(!snap_cache(&s)); - assert!(s.refresh_type_layers(&[(doc_id, id)]).is_empty()); + assert!(s.refresh_type_layers(&[open(doc_id, id)]).is_empty()); assert!(text_layer(&s, id.0).cache.is_some()); assert!(snap_cache(&s), "the first snapshot is refreshed with the document"); let st = s.active().unwrap(); assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "and still shares it"); // Edited since it opened: the snapshot is refreshed on its own, the edit stays. - s.add_document(stale, None); + s.add_document(stale.clone(), None); let doc_id = s.active().unwrap().doc.id; s.execute("layer.new.layer", json!({})).unwrap(); let layers = s.active().unwrap().doc.layers.len(); - assert!(s.refresh_type_layers(&[(doc_id, id), (doc_id, LayerId(9999))]).is_empty()); + assert!(s.refresh_type_layers(&[open(doc_id, id), first(doc_id, id), first(doc_id, LayerId(9999))]).is_empty()); assert!(snap_cache(&s)); assert!(text_layer(&s, id.0).cache.is_some()); let st = s.active().unwrap(); assert_eq!(st.doc.layers.len(), layers); assert_eq!(st.snapshots[0].doc.layers.len(), layers - 1, "the snapshot keeps the document as opened"); + // Each version is refreshed only for the layers picked in it: one picked in the open + // document (e.g. a PSD layer edited since, while the snapshot keeps the file's pixels) + // leaves the snapshot alone, and the other way round. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(s.refresh_type_layers(&[open(doc_id, id)]).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some()); + assert!(!snap_cache(&s), "the snapshot was not picked"); + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + let rev = s.active().unwrap().revision; + assert!(s.refresh_type_layers(&[first(doc_id, id)]).is_empty()); + assert!(snap_cache(&s)); + assert!(text_layer(&s, id.0).cache.is_none(), "the open layer was not picked"); + assert_eq!(s.active().unwrap().revision, rev, "and the open document did not change"); + // The layer is gone from the open document: the snapshot still gets the real font. + s.add_document(stale, None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + assert!(s.active().unwrap().doc.layer(id).is_none()); + assert!(s.refresh_type_layers(&[first(doc_id, id)]).is_empty()); + assert!(snap_cache(&s), "a layer deleted since it opened is refreshed in the snapshot"); } #[test] diff --git a/crates/ui-egui/src/served_fonts.rs b/crates/ui-egui/src/served_fonts.rs index b7ae085f41..142cedd7da 100644 --- a/crates/ui-egui/src/served_fonts.rs +++ b/crates/ui-egui/src/served_fonts.rs @@ -2,15 +2,16 @@ //! see `photocraft_text::served`): reading the manifest, the families to fetch, and installing //! what arrived, which registers the files and re-renders the type layers drawn without them. -use std::sync::{Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError}; -use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer}; +use photocraft_doc::{LayerContent, TextLayer}; +use photocraft_engine::TypeLayerRef; pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests}; use crate::PhotocraftApp; /// Layers whose document was busy with a background job when their font arrived. -static PENDING: Mutex> = Mutex::new(Vec::new()); +static PENDING: Mutex> = Mutex::new(Vec::new()); /// Registers delivered font files (`(family, bytes)`, the family as the manifest lists it) and /// re-renders the type layers that use those families, or whose text falls back to them (a script @@ -23,17 +24,7 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec)>) { let families: Vec = delivered.iter().map(|(f, _)| f.clone()).collect(); // Decided before the fonts are in: "shows our own layout" compares the layer's pixels // with what the engine draws now, that is with the fallback font. - for st in app.session.documents() { - for (_, _, l) in st.doc.walk() { - if let LayerContent::Text(t) = &l.content - && redraws_for(t, &families) - && crate::type_tool::shows_own_layout(&st.doc, t) - && !layers.contains(&(st.doc.id, l.id)) - { - layers.push((st.doc.id, l.id)); - } - } - } + pick(&app.session, &families, &mut layers); let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner); for (family, bytes) in delivered { let added = eng.fonts.register_font_data(bytes); @@ -48,6 +39,28 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec)>) { } } +/// Adds to `layers` the type layers to redraw once `families` arrive, in every open document and +/// in its first History snapshot (the document as opened), which is checked on its own: since +/// then its layers may have been edited (a PSD layer re-rendered) or deleted (#6087). +fn pick(session: &photocraft_engine::Session, families: &[String], layers: &mut Vec) { + for st in session.documents() { + // While it still is the open document, the engine refreshes it with it. + let first = st.snapshots.iter().find(|s| s.id == 0 && !Arc::ptr_eq(&s.doc, &st.doc)).map(|s| &s.doc); + for (doc, first_snapshot) in std::iter::once((&st.doc, false)).chain(first.map(|d| (d, true))) { + for (_, _, l) in doc.walk() { + let key = TypeLayerRef { doc: st.doc.id, layer: l.id, first_snapshot }; + if let LayerContent::Text(t) = &l.content + && redraws_for(t, families) + && crate::type_tool::shows_own_layout(doc, t) + && !layers.contains(&key) + { + layers.push(key); + } + } + } + } +} + /// Should the layer be redrawn when `families` arrive: it uses one of them, or its text is in a /// script one of them is the served fallback for? fn redraws_for(t: &TextLayer, families: &[String]) -> bool { @@ -79,6 +92,34 @@ mod tests { assert!(uses_family(&psd, &["Lato".into()])); } + /// #6087: the first History snapshot is picked on its own, so a type layer deleted since the + /// document opened still gets the font there, and one still shared with the open document is + /// picked once. + #[test] + fn the_first_snapshot_is_picked_on_its_own() { + use photocraft_doc::LayerId; + use serde_json::json; + let family = "Served Test Pick"; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24, "font": family})).unwrap()["layer"].as_u64().unwrap()); + let doc = (*s.active().unwrap().doc).clone(); + s.add_document(doc, None); + let doc_id = s.active().unwrap().doc.id; + let key = |first_snapshot| TypeLayerRef { doc: doc_id, layer: id, first_snapshot }; + let picked = |s: &photocraft_engine::Session| { + let mut layers = Vec::new(); + pick(s, &[family.into()], &mut layers); + layers.retain(|l| l.doc == doc_id); + layers + }; + assert_eq!(picked(&s), [key(false)], "the snapshot still is the open document"); + s.execute("layer.new.layer", json!({})).unwrap(); + assert_eq!(picked(&s), [key(false), key(true)]); + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + assert_eq!(picked(&s), [key(true)], "deleted from the open document, still in the snapshot"); + } + #[test] fn a_layer_in_a_script_redraws_when_that_scripts_fallback_arrives() { // Thai: other tests use other scripts (the served catalog is process-wide). From 0dfbbbf1a93d3aa80feda6fcef26c5eefe86c933 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:01:52 +0100 Subject: [PATCH 42/63] Crop: the click guard skips the snap on release again An earlier merge (f35107a8ea, snap_ui.rs moved into canvas/) applied the guard's hunk to the Move branch, whose context lines match the Up branch's. A click has no Move, so its release still snapped the frame and the test a_click_on_the_frame_or_a_handle_never_snaps_it failed. The guard is back on Up, where a release at the press point leaves the frame alone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/snap_ui.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 92123bb38b..d52e6f28cb 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -526,11 +526,6 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } - if crop_click(app, [x, y]) { - app.prefs_rt.snap = None; - app.prefs_rt.snap_lines.clear(); - return ev; - } follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { @@ -544,6 +539,11 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + if crop_click(app, [x, y]) { + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + return ev; + } follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { From cd01064dc21187b1f4eef2dd85590821770e1c1b Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:10:41 +0100 Subject: [PATCH 43/63] Address CodeRabbit review: cost pattern filters at resvg's scale-only tile transform resvg renders a pattern's root under Transform::from_scale(get_scale()) of the path transform times patternTransform, so a skewed patternTransform with slim full-affine bounds drew a much larger filter region than the estimate charged (1.06e9 vs about 1.9e11 reads). Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 7 +++++-- crates/io/src/svg/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 4d92f2e67e..94a841b7ff 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -669,11 +669,14 @@ fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize, visit let world = base.pre_concat(node.abs_transform()); let mut sub = |root: &usvg::Group, ts: tiny_skia::Transform| cost = cost.saturating_add(filter_cost(root, ts, rec + 1, visits)); match node { - // resvg draws a pattern's tile at the path's scale times the pattern's own. + // resvg draws a pattern's tile at the path's scale times the pattern's own, and + // only that scale (`get_scale`, no skew or rotation): a skewed patternTransform + // whose full-affine bounds are slim still renders a large tile. usvg::Node::Path(p) => { for paint in [p.fill().map(|f| f.paint()), p.stroke().map(|s| s.paint())].into_iter().flatten() { if let usvg::Paint::Pattern(pt) = paint { - sub(pt.root(), world.pre_concat(pt.transform())); + let (sx, sy) = world.pre_concat(pt.transform()).get_scale(); + sub(pt.root(), tiny_skia::Transform::from_scale(sx, sy)); } } } diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index 9d438c9f37..98c8ac7124 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -26,6 +26,26 @@ fn ordinary_filters_cost_nothing_on_any_canvas() { assert!(check_filter_cost(&tree, 1.0).is_ok()); } +/// resvg draws a pattern's tile under the scale part of its transform only (`get_scale`): a +/// skewed patternTransform (determinant 1) whose full-affine bounds of a slim filter region are +/// 180 × 181 px still draws that region at 2400 × 180 px, with a morphology window as large, +/// about 2 × 10^11 reads. Never rendered here: only the estimate is checked. +#[test] +fn a_skewed_pattern_filter_counts_at_resvgs_tile_scale() { + let svg = "\ + \ + \ + \ + "; + let tree = parse(svg.as_bytes()).unwrap().tree; + // The tile resvg would allocate: 20 × 20 units at get_scale ≈ (100, 100). + let (sx, sy) = tiny_skia::Transform::from_row(0.0, -0.01, 100.0, 100.0, 0.0, 0.0).get_scale(); + assert!((sx * 20.0).round() >= 2000.0 && (sy * 20.0).round() >= 1999.0, "{sx} {sy}"); + let cost = filter_cost(tree.root(), tiny_skia::Transform::identity(), 0, &mut { MAX_FILTER_VISITS }); + assert!(cost > 50 * MAX_FILTER_COST, "charged only {cost} reads"); + assert!(check_filter_cost(&tree, 1.0).is_err()); +} + #[test] fn quadratics_become_cubics_and_closing_merges_the_start_knot() { let mut pb = tiny_skia::PathBuilder::new(); From ec49d0b5820ea1e84decc549f772d035747bdf7c Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:19:25 +0100 Subject: [PATCH 44/63] Address CodeRabbit review: end a crop drag before the frame sees a tool switch The canvas ends the gesture before `ensure_frame` (which dropped the untouched default frame under the box being drawn, and the Hand never reached `tool_event`), keeping the pen eraser exception. A press inside the default frame that never moves is no edit, so leaving still drops a selection-derived default frame. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/tools/crop_ui.rs | 12 +++- crates/ui-egui/src/tools/crop_ui/tests.rs | 70 +++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3896b84cf6..374c6a6e5a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1876,6 +1876,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::crop_straighten::set_held(app, !ctx.text_edit_focused() && ui.input(|i| i.modifiers.command)); // A tab switched this frame drops the other document's pending crop (#1918). crate::crop_ui::cancel_stale(app); + // A tool picked mid-drag ends the crop gesture before the frame sees the switch, which would + // otherwise drop the untouched default frame under the box being drawn (#9223). The Hand and + // Zoom never reach `tool_event`, so this is the one place that sees every tool. + crate::crop_ui::end_drag_on_leave(app); crate::crop_ui::ensure_frame(app); let mut drawing = crate::crop_ui::active(app); if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) { diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 249ba88f5c..6a569aa68f 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -863,7 +863,9 @@ fn release(app: &mut PhotocraftApp, drag: CropDrag) { let ok = app.ui.crop_rect.is_some_and(size_ok); // A click outside the untouched default frame (no turn) leaves it the default. let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app)) - && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); + && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)) + // A press that never moved draws nothing: the frame shown is still the previous one. + && !matches!(drag, CropDrag::Draw { anchor, cur, .. } if anchor == cur); if ok && changed { app.crop.default_frame = false; } @@ -886,6 +888,14 @@ pub fn end_drag(app: &mut PhotocraftApp) { app.ui.tool = tool; } +/// [`end_drag`] once another tool is current, except while the pen's eraser end stands in for the +/// Crop tool (`stylus`): the gesture goes on when the tip comes back. +pub fn end_drag_on_leave(app: &mut PhotocraftApp) { + if app.ui.tool != Tool::Crop && app.stylus.tool_before_eraser != Some(Tool::Crop) { + end_drag(app); + } +} + /// On release: the frame state at the press becomes a box Undo step when the gesture changed it. fn push_press_step(app: &mut PhotocraftApp) { if let Some(before) = app.crop.press_step.take() diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 4c00297a02..8160a3c42f 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -858,6 +858,76 @@ fn a_tool_switch_during_a_drag_on_the_default_frame_keeps_the_edit() { } } +/// Real egui input: a tool picked while a box is being drawn over the untouched default frame +/// (a tool key mid-drag) commits that box on leaving, whichever tool it is; the canvas ends the +/// gesture before the frame sees the switch, also for the Hand, whose events never reach +/// `tool_event` (#9223). +#[test] +fn a_tool_switch_during_a_real_drag_commits_the_drawn_box() { + use egui::{Event, PointerButton, pos2}; + for tool in [Tool::Brush, Tool::PerspectiveCrop, Tool::Hand] { + let mut a = app(SampleType::U8); + a.sync_views(); + let v = &mut a.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.doc_size, v.rotation) = (2.0, [100.0, 50.0], false, [200, 100], 0.0); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().crop.default_frame, "{tool:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p0 = pos2(200.0, 150.0); + h.event(Event::PointerMoved(p0)); + h.event(button(p0, true)); + h.step(); + for p in [pos2(220.0, 170.0), pos2(260.0, 190.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + assert!(h.state().crop.drag.is_some(), "{tool:?}"); + h.state_mut().ui.tool = tool; + h.event(Event::PointerMoved(pos2(270.0, 195.0))); + h.step(); + h.event(button(pos2(270.0, 195.0), false)); + h.run_steps(2); + let st = h.state(); + assert!(st.crop.drag.is_none(), "{tool:?}"); + assert_eq!(st.ui.crop_rect, None, "{tool:?}"); + assert_eq!(st.session.active().unwrap().doc.size, Size::new(30, 20), "{tool:?}: the drawn 60x40 screen px at 200%"); + } +} + +/// A press inside the untouched default frame that never moves draws nothing, so the frame +/// stays the default one and leaving drops it: no crop to the selection's bounds, released or +/// cut short by a tool switch (#9223). +#[test] +fn a_press_that_never_moves_leaves_the_default_frame_untouched() { + for switch in [false, true] { + let mut app = app(SampleType::U8); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([10.0, 20.0, 40.0, 60.0])); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, NONE); + if switch { + app.ui.tool = Tool::Brush; + end_drag_on_leave(&mut app); + } else { + tool_event(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }, NONE); + assert!(app.crop.default_frame, "a click is no edit"); + app.ui.tool = Tool::Brush; + } + ensure_frame(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "switch: {switch}"); + assert_eq!(app.ui.crop_rect, None, "switch: {switch}"); + } +} + /// Image › Crop with a pending crop commits the frame (the one Image item Photoshop keeps). #[test] fn image_crop_menu_commits_a_pending_crop() { From cf9a7254d597a8903c2326bc383684c1af55d389 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:20:25 +0100 Subject: [PATCH 45/63] Address CodeRabbit review: budget the image between bicubic passes A bicubic shrink of both axes stores the image between its two passes with one axis still at its old size, larger than the result. The hidden pixel budget now checks the largest rectangle of each surface's pass plan (algo::resample::largest_resize_rect), masks, channels and the selection with their Bilinear filter. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/resample.rs | 18 +++++++++++- crates/algo/src/resample/tests.rs | 19 ++++++++++++ crates/engine/src/cmd/image_cmds.rs | 36 +++++++++++------------ crates/engine/src/cmd/image_cmds/tests.rs | 18 ++++++++++++ 4 files changed, 72 insertions(+), 19 deletions(-) diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index 4dcf0b6944..c8c0580bbf 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -288,6 +288,21 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect { Rect::new((r.x0 as f64 * sx).floor() as i32, (r.y0 as f64 * sy).floor() as i32, (r.x1 as f64 * sx).ceil() as i32, (r.y1 as f64 * sy).ceil() as i32) } +/// The largest rectangle a resize of content at `r` by `(sx, sy)` with `filter` stores: the +/// result, or the image between the passes of a two-pass bicubic. That one keeps an axis at its +/// old size, so when both axes shrink it is larger than the result (#9212). +pub fn largest_resize_rect(r: Rect, sx: f64, sy: f64, filter: Resample) -> Rect { + let area = |r: Rect| u64::from(r.width()) * u64::from(r.height()); + let (mut cur, mut largest) = (r, Rect::EMPTY); + for p in plan(filter, sx, sy) { + cur = scaled_rect(cur, p.sx, p.sy); + if area(cur) > area(largest) { + largest = cur; + } + } + largest +} + /// Resizes a surface by `(sx, sy)` about the document origin. The surface's /// default pixel is kept (masks keep their "reveal all" background). pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface { @@ -346,7 +361,8 @@ fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option) /// Runs `filter`'s passes. Between two passes the image is stored in its own format, which /// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so -/// the intermediate image is never larger than the result. +/// the intermediate image is never larger than the result when an axis grows, but is when both +/// shrink ([`largest_resize_rect`]). fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option, budget: usize) -> Surface { let mut cur: Option = None; let mut canvas = canvas; diff --git a/crates/algo/src/resample/tests.rs b/crates/algo/src/resample/tests.rs index 0ee08847e1..3fb68936d3 100644 --- a/crates/algo/src/resample/tests.rs +++ b/crates/algo/src/resample/tests.rs @@ -400,3 +400,22 @@ fn nearest_neighbor_reduction_does_not_average() { } } } + +/// #9212: a bicubic shrink of both axes stores the image between its passes with one axis at the +/// old size, larger than the result; single-pass filters and growing axes store nothing larger. +#[test] +fn largest_resize_rect_counts_the_image_between_the_passes() { + let r = Rect::new(0, 0, 100, 80); + for f in [Resample::Bicubic, Resample::BicubicSharper, Resample::BicubicAutomatic, Resample::PreserveDetails] { + assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 80), "{f:?}"); + } + // The axis with the smaller scale goes first. + assert_eq!(largest_resize_rect(r, 0.5, 0.25, Resample::Bicubic), Rect::new(0, 0, 100, 20)); + for f in [Resample::Bilinear, Resample::Nearest, Resample::Lanczos] { + assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 40), "{f:?}"); + } + for f in [Resample::Bicubic, Resample::BicubicAutomatic] { + assert_eq!(largest_resize_rect(r, 2.0, 3.0, f), Rect::new(0, 0, 200, 240), "{f:?} grows"); + assert_eq!(largest_resize_rect(r, 0.5, 2.0, f), Rect::new(0, 0, 50, 160), "{f:?} shrinks x, grows y"); + } +} diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 4494b5d13d..ad4203ece5 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -3,7 +3,7 @@ use std::convert::Infallible; -use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface}; +use photocraft_algo::resample::{Resample, crop_surface, largest_resize_rect, resize_surface_in_canvas, scaled_rect, translate_surface}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size}; use photocraft_geom::Rect; @@ -297,7 +297,7 @@ pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filt if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { return Ok(()); } - check_hidden_resample_budget(cmd, doc, sx, sy)?; + check_hidden_resample_budget(cmd, doc, sx, sy, filter)?; // Content that reaches the canvas edge keeps it: a Background stays opaque to the border. let canvas = Rect::from_xywh(0, 0, ow, oh); let taken = crate::smart_cmds::take_caches(&mut doc.layers); @@ -329,23 +329,23 @@ pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filt Ok(()) } -/// [`check_resample_budget`] for the largest surface [`resample_doc`] scales by `sx` x `sy`: each -/// keeps its pixels outside the canvas (a crop that didn't delete them, a layer moved off the -/// canvas), and those grow with the scale like the rest. -fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64) -> Result<()> { - let canvas = doc.bounds(); - // The largest surface by bytes: its scaled width, height and bytes per pixel (a mask has one +/// [`check_resample_budget`] for the largest surface [`resample_doc`] stores while scaling by +/// `sx` x `sy` with `filter` (masks, channels and the selection with Bilinear): each keeps its +/// pixels outside the canvas (a crop that didn't delete them, a layer moved off the canvas), and +/// those grow with the scale like the rest. A bicubic shrink also stores the image between its +/// two passes, one axis still at its old size ([`largest_resize_rect`]). +fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64, filter: Resample) -> Result<()> { + let new_canvas = scaled_rect(doc.bounds(), sx, sy); + // The largest surface by bytes: its width, height and bytes per pixel (a mask has one // channel, the layers the document's). let mut largest = (0u32, 0u32, 1u64); - let mut visit = |surf: &Surface| { - let r = surf.content_bounds(); - // What lies within the canvas scales to within the new canvas, already budgeted. - if r.is_empty() || canvas.union(&r) == canvas { + let mut visit = |surf: &Surface, filter: Resample| { + let r = largest_resize_rect(surf.content_bounds(), sx, sy, filter); + // What stays within the new canvas is already budgeted. + if r.is_empty() || new_canvas.union(&r) == new_canvas { return; } - // The output's extent, as `resize_surface_in_canvas` lays it out. - let side = |a: i32, b: i32, k: f64| ((f64::from(b) * k).ceil() - (f64::from(a) * k).floor()).clamp(0.0, f64::from(u32::MAX)) as u32; - let (w, h) = (side(r.x0, r.x1, sx), side(r.y0, r.y1, sy)); + let (w, h) = (r.width(), r.height()); let bpp = surf.format().bytes_per_pixel().max(1) as u64; let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); if bytes((w, h, bpp)) > bytes(largest) { @@ -353,12 +353,12 @@ fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64) } }; // The surfaces `resample_doc` scales, smart object caches included. - for_each_surface(&mut doc.layers, true, &mut |surf, _| visit(surf)); + for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| visit(surf, if is_mask { Resample::Bilinear } else { filter })); for ch in doc.channels.iter().chain(doc.quick_mask.as_ref()) { - visit(&ch.surface); + visit(&ch.surface, Resample::Bilinear); } if let Some(sel) = &doc.selection { - visit(sel); + visit(sel, Resample::Bilinear); } let (w, h, bpp) = largest; let px = u64::from(w).saturating_mul(u64::from(h)); diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index fe9af44182..19b12a5200 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -1008,3 +1008,21 @@ fn duplicate_document() { assert_eq!(doc(&s).layers.len(), 1); assert!(doc(&s).name.ends_with("copy")); } + +/// #9212: a bicubic shrink stores the image between its two passes with one axis at its old +/// size. A Background whose kept pixels reach (69999, 69999), halved from 200 to 100 px, ends at +/// about 35000 x 35000 px (4.9 GB, within the 8 GiB budget) but passes through about +/// 35000 x 70000 px (9.8 GB): refused before allocating. Bilinear has one pass and fits. The +/// check alone runs, so nothing that large is allocated either way. +#[cfg(target_pointer_width = "64")] +#[test] +fn hidden_resample_budget_counts_the_image_between_bicubic_passes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let mut d = doc(&s).clone(); + let bg = d.background_mut().unwrap().surface_mut().unwrap(); + bg.fill_rect(Rect::new(69_999, 69_999, 70_000, 70_000), &[1.0, 0.0, 0.0, 1.0]); + let err = check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bicubic).unwrap_err().to_string(); + assert!(err.contains(" x 70000 px is "), "{err}"); + check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bilinear).unwrap(); +} From 25782b0ca94f9ad9a36c9317dc918379e20bfb09 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 18:25:17 +0100 Subject: [PATCH 46/63] Address CodeRabbit review: crop auto-scroll only for real crop drags - Crop: a new-frame drag still within 3 screen points of its press (a long press near the edge that slipped past it) doesn't scroll, so it can't grow a frame under a still pointer; Move and Resize still scroll. - Both crop tools scroll only while the canvas widget itself holds the press (not a scrollbar or panel with a stale crop drag) and the crop tool has the pointer (not a temporary Zoom held mid-drag). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 14 +- crates/ui-egui/src/tools/crop_ui.rs | 20 ++- crates/ui-egui/src/tools/crop_ui/tests.rs | 121 ++++++++++++++++++ .../ui-egui/src/tools/perspective_crop_ui.rs | 67 ++++++++++ 4 files changed, 213 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0669106626..ffdf5d11bb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1608,8 +1608,6 @@ struct CanvasTailInput<'a> { bars_id: egui::Id, layer_edges: egui::layers::ShapeIdx, camera: Option<([f32; 2], f32)>, - /// The pointer was pressed on this canvas (not on a panel over it). - pointer_here: bool, } fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut View, input: &CanvasTailInput<'_>) { @@ -1629,16 +1627,19 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie let bars_id = input.bars_id; let layer_edges = input.layer_edges; let camera = input.camera; - let pointer_here = input.pointer_here; app.tool_override = None; if let Some(b) = layer_edges_rect(app, idx) { painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); } // A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the - // drag's next move then extends into what scrolled in. The crop tools keep their own drags, - // pressed on this canvas (not a panel slider held while a stale crop drag lingers). + // drag's next move then extends into what scrolled in. The crop tools keep their own drags: + // they scroll only while the press is the canvas's own (not a panel slider or a scrollbar held + // while a stale crop drag lingers) and the crop tool has it (not a temporary Zoom, which + // freezes the drag). + let crop_drag = + response.is_pointer_button_down_on() && tool == app.ui.tool && (crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app)); if primary - && (app.drag.is_some() || (pointer_here && (crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app)))) + && (app.drag.is_some() || crop_drag) && tool != Tool::Hand && !middle && let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten()) @@ -2032,7 +2033,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: bars_id, layer_edges, camera, - pointer_here, }; finish_canvas_view(app, ui, &mut view, &tail); view diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index f9eeee5f7c..92f65104cc 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -421,9 +421,25 @@ pub fn active(app: &PhotocraftApp) -> bool { /// A drag that sizes or moves the frame over the image, which scrolls the view when held past the /// canvas edge (#4183). Not the turns, nor the default mode's image moves and live-centred resizes: -/// those drive the camera themselves. +/// those drive the camera themselves. A new frame's drag that hasn't left the size of a click (a +/// long press that slipped past the edge) doesn't either: scrolling would draw a frame under the +/// still pointer. pub fn autoscrolls(app: &PhotocraftApp) -> bool { - app.ui.tool == Tool::Crop && matches!(app.crop.drag, Some(CropDrag::Draw { .. } | CropDrag::Move { .. } | CropDrag::Resize { view: None, .. })) + app.ui.tool == Tool::Crop + && match app.crop.drag { + Some(CropDrag::Draw { anchor, cur, .. }) => !is_click(app, anchor, cur), + Some(CropDrag::Move { .. } | CropDrag::Resize { view: None, .. }) => true, + _ => false, + } +} + +/// A new-frame drag shorter than this (screen points) on both axes is still a click. +const CLICK_PX: f64 = 3.0; + +/// Document points `a` and `b` lie within [`CLICK_PX`] screen points of each other on both axes. +fn is_click(app: &PhotocraftApp, a: [f64; 2], b: [f64; 2]) -> bool { + let click = CLICK_PX / (app.point_zoom() as f64).max(0.01); + (b[0] - a[0]).abs() <= click && (b[1] - a[1]).abs() <= click } /// Cancel the pending crop: the frame, any gesture on it and the default-frame state. With the diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 1069c3bd47..cf5391606c 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -588,6 +588,127 @@ fn a_press_beside_the_canvas_does_not_scroll_it_for_a_crop_drag() { h.step(); } +/// The 200x100 test document at 400% (document x 25..175 shows in a 600-point canvas). +fn zoomed_view(height: u32) -> crate::state::View { + crate::state::View { + zoom: 4.0, + center: [100.0, height as f32 / 2.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, height], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + } +} + +/// A press held just inside the canvas edge (past egui's click time, so egui starts a drag) that +/// slips a point past it is still a click: it draws no frame and doesn't scroll the view, which +/// would grow a frame under the still pointer. A new frame dragged past the edge does scroll. +#[test] +fn a_held_click_at_the_canvas_edge_draws_no_frame_and_does_not_scroll() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![zoomed_view(100)]; + a.ui.extras.rulers = false; + // The canvas starts 100 points from the window's left, so its left edge (no scrollbar there) + // has room past it. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let frame = h.state().ui.crop_rect; + assert!(h.state().crop.default_frame, "{frame:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert!(matches!(h.state().crop.drag, Some(CropDrag::Draw { .. })), "egui started a drag: {:?}", h.state().crop.drag); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.ui.crop_rect, frame, "the click drew no frame"); + assert!(st.crop.default_frame); + // A new frame dragged from inside the canvas past its edge does scroll. + let start = pos2(300.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(240.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Draw { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the frame drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[0] < 25.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); +} + +/// A scrollbar drag is the scrollbar's, even with a crop drag still recorded (left over from a +/// drag a tool switch didn't end): dragged out past the canvas edge, it doesn't auto-scroll the +/// view as a crop drag would. +#[test] +fn a_scrollbar_drag_past_the_canvas_edge_does_not_scroll_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let doc = Document::with_background("crop", Size::new(200, 200), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::Crop; + a.ui.tool_options.crop_shield.classic_mode = true; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x200 at 400% in a 600x400 canvas: both scrollbars show. + a.ui.views = vec![zoomed_view(200)]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The window is taller than the canvas, so the pointer can leave it below the bottom bar. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 500.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().ui.view.shows_scrollbars()); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The horizontal bar's thumb (the view is centred, so is the thumb), dragged straight down + // out of the canvas: the bar keeps x, and nothing else may move the view. + let thumb = pos2(294.0, 400.0 - crate::canvas::scrollbars::THICKNESS / 2.0); + h.event(Event::PointerMoved(thumb)); + h.event(button(thumb, true)); + h.step(); + for p in [pos2(294.0, 420.0), pos2(294.0, 480.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 100.0], "the view stayed put"); + h.event(button(pos2(294.0, 480.0), false)); + h.step(); +} + /// #1918: a pending crop belongs to the document it was drawn on. File › New, opening a file /// or switching tabs cancels it; it never shows on, or crops, another document. /// A turned pending frame is dropped with its angle when another document becomes active, and diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index d3c5488d04..ed60c90d41 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -401,6 +401,73 @@ mod tests { h.step(); } + /// ⌘Space (Ctrl+Space off the Mac) pressed during a corner drag held past the canvas edge + /// selects the temporary Zoom, which takes the pointer from the drag: the view no longer + /// scrolls for the corner the Zoom froze. + #[test] + fn a_temporary_zoom_during_a_corner_drag_stops_the_scrolling() { + use egui::{Event, Key, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(800, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 800x100 at 400% in a 600x400 canvas: document x 25..175 shows, with room to scroll right. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [800, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true, Modifiers::NONE)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + // Scrolling while the crop tool has the pointer. + let before = h.state().ui.views[0].center; + h.run_steps(2); + assert!(h.state().ui.views[0].center[0] > before[0], "the corner drag scrolls: {:?} -> {:?}", before, h.state().ui.views[0].center); + let zoom = Modifiers::COMMAND; + let zoom = if cfg!(target_os = "macos") { Modifiers { mac_cmd: true, ..zoom } } else { Modifiers { ctrl: true, ..zoom } }; + h.event(Event::ModifiersChanged(zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: zoom }); + h.step(); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn)); + assert!(h.state().pcrop.drag.is_some(), "the corner drag is still recorded"); + let before = h.state().ui.views[0].center; + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, before, "the temporary Zoom's press doesn't scroll for the corner"); + h.event(button(out, false, zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: zoom }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + } + /// A corner-placing click pressed just inside the canvas edge that slips a point past it doesn't /// scroll the view (scrolling would turn it into a box); a new box dragged past the edge does. #[test] From dd8af8e98800bb2a546e230d4ef940eb2869601d Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 18:40:25 +0100 Subject: [PATCH 47/63] Address CodeRabbit review: refresh first-snapshot picks in the open document that is the snapshot again A layer picked in the first History snapshot while the document had moved on (e.g. deleted since it opened) was only refreshed in the snapshot. If the document was busy and Undo took it back to the snapshot before the retry, the open document kept the fallback font. While the two share one document, its refresh now covers both kinds of pick. The mirror case: while shared, pick listed only the open document, so a finished job moving the document on before the retry left the snapshot with the fallback font. Pick now lists the layer for the snapshot too. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/type_cmds/tests.rs | 26 ++++++++++++++++++++++++ crates/engine/src/session/layers.rs | 12 ++++++++++- crates/ui-egui/src/text/served_fonts.rs | 22 +++++++++++++------- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/type_cmds/tests.rs b/crates/engine/src/cmd/type_cmds/tests.rs index dfd973734d..f254684a77 100644 --- a/crates/engine/src/cmd/type_cmds/tests.rs +++ b/crates/engine/src/cmd/type_cmds/tests.rs @@ -178,6 +178,32 @@ fn refreshing_type_layers_refreshes_the_first_snapshot() { assert!(snap_cache(&s), "a layer deleted since it opened is refreshed in the snapshot"); } +/// A layer picked in the first snapshot while the document had moved on is refreshed in the open +/// document too when, by the time of the refresh (a busy document's retry), Undo has taken it back +/// to the snapshot: the two share one document, which must not keep the fallback font. +#[test] +fn a_layer_picked_in_the_first_snapshot_refreshes_the_open_document_it_is_again() { + let mut s = session(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + let mut stale = (*s.active().unwrap().doc).clone(); + let Some(Layer { content: LayerContent::Text(t), .. }) = stale.layer_mut(id) else { panic!("not a type layer") }; + t.cache = None; + s.add_document(stale, None); + let doc_id = s.active().unwrap().doc.id; + // Picked while the layer was deleted from the open document: only in the snapshot. + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + let picked = [crate::TypeLayerRef { doc: doc_id, layer: id, first_snapshot: true }]; + assert!(s.undo()); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "Undo is back at the snapshot itself"); + let rev = st.revision; + assert!(s.refresh_type_layers(&picked).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some(), "the open document has the real font"); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "and still shares it with the snapshot"); + assert!(st.revision > rev); +} + #[test] fn create_renders_and_is_undoable() { let mut s = session(); diff --git a/crates/engine/src/session/layers.rs b/crates/engine/src/session/layers.rs index 83d880fef9..4511b7fbf1 100644 --- a/crates/engine/src/session/layers.rs +++ b/crates/engine/src/session/layers.rs @@ -79,7 +79,17 @@ impl Session { { first.doc = Arc::new(doc); } - let Some(doc) = refresh_text_layers(&st.doc, &ids(false)) else { continue }; + // While shared, a layer picked in the snapshot is a layer of the open document too + // (a document busy when its font arrived can be back at the snapshot by the retry). + let mut open = ids(false); + if first_is_doc { + for id in ids(true) { + if !open.contains(&id) { + open.push(id); + } + } + } + let Some(doc) = refresh_text_layers(&st.doc, &open) else { continue }; let doc = Arc::new(doc); if first_is_doc && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) { first.doc = doc.clone(); diff --git a/crates/ui-egui/src/text/served_fonts.rs b/crates/ui-egui/src/text/served_fonts.rs index ae0df25e56..57ce3264f8 100644 --- a/crates/ui-egui/src/text/served_fonts.rs +++ b/crates/ui-egui/src/text/served_fonts.rs @@ -116,17 +116,25 @@ pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(Str /// then its layers may have been edited (a PSD layer re-rendered) or deleted (#6087). fn pick(session: &photocraft_engine::Session, families: &[String], layers: &mut Vec) { for st in session.documents() { - // While it still is the open document, the engine refreshes it with it. - let first = st.snapshots.iter().find(|s| s.id == 0 && !Arc::ptr_eq(&s.doc, &st.doc)).map(|s| &s.doc); + let first = st.snapshots.iter().find(|s| s.id == 0).map(|s| &s.doc); + // While it still is the open document, a layer picked there is picked in the snapshot too + // (without checking it twice): a busy document is refreshed later, and by then a finished + // job can have moved the open document on. + let shared = first.is_some_and(|f| Arc::ptr_eq(f, &st.doc)); + let first = first.filter(|_| !shared); for (doc, first_snapshot) in std::iter::once((&st.doc, false)).chain(first.map(|d| (d, true))) { for (_, _, l) in doc.walk() { - let key = TypeLayerRef { doc: st.doc.id, layer: l.id, first_snapshot }; if let LayerContent::Text(t) = &l.content && redraws_for(t, families) && crate::type_tool::shows_own_layout(doc, t) - && !layers.contains(&key) { - layers.push(key); + let also = (shared && !first_snapshot).then_some(true); + for first_snapshot in std::iter::once(first_snapshot).chain(also) { + let key = TypeLayerRef { doc: st.doc.id, layer: l.id, first_snapshot }; + if !layers.contains(&key) { + layers.push(key); + } + } } } } @@ -166,7 +174,7 @@ mod tests { /// #6087: the first History snapshot is picked on its own, so a type layer deleted since the /// document opened still gets the font there, and one still shared with the open document is - /// picked once. + /// picked in both without being checked twice. #[test] fn the_first_snapshot_is_picked_on_its_own() { use photocraft_doc::LayerId; @@ -185,7 +193,7 @@ mod tests { layers.retain(|l| l.doc == doc_id); layers }; - assert_eq!(picked(&s), [key(false)], "the snapshot still is the open document"); + assert_eq!(picked(&s), [key(false), key(true)], "the snapshot still is the open document"); s.execute("layer.new.layer", json!({})).unwrap(); assert_eq!(picked(&s), [key(false), key(true)]); s.execute("layer.delete", json!({"layer": id.0})).unwrap(); From 79fee531168dce1c02161acdc0cefb2dd133ad06 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 19:05:38 +0100 Subject: [PATCH 48/63] Address CodeRabbit review: the hidden-pixel resample refusal suggests Delete Cropped Pixels only when it would help Delete Cropped Pixels clips pixel layers, fill caches and video frames only. When a layer mask, channel, the quick mask, the selection, type, a shape or a smart object holds the hidden pixels over the budget, the refusal now says so instead of pointing to an option that can't clear it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/image_cmds.rs | 54 ++++++++++++++++------- crates/engine/src/cmd/image_cmds/tests.rs | 23 ++++++++++ 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index df27a24ae3..76880145cf 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -1,6 +1,7 @@ //! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and //! bit depth) conversions, Duplicate. +use std::collections::HashSet; use std::convert::Infallible; use photocraft_algo::resample::{Resample, crop_surface, largest_resize_rect, resize_surface_in_canvas, scaled_rect, translate_surface}; @@ -333,23 +334,39 @@ pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filt /// `sx` x `sy` with `filter` (masks, channels and the selection with Bilinear): each keeps its /// pixels outside the canvas (a crop that didn't delete them, a layer moved off the canvas), and /// those grow with the scale like the rest. A bicubic shrink also stores the image between its -/// two passes, one axis still at its old size ([`largest_resize_rect`]). +/// two passes, one axis still at its old size ([`largest_resize_rect`]). The refusal suggests +/// Delete Cropped Pixels only when the surfaces over the budget are all ones it clips +/// ([`delete_cropped_pixels`]). fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64, filter: Resample) -> Result<()> { let new_canvas = scaled_rect(doc.bounds(), sx, sy); - // The largest surface by bytes: its width, height and bytes per pixel (a mask has one - // channel, the layers the document's). - let mut largest = (0u32, 0u32, 1u64); + // The surfaces Delete Cropped Pixels clips; masks, channels, the selection, type, shapes and + // smart objects keep their pixels outside the crop. + let mut clipped: HashSet<*const Surface> = HashSet::new(); + for_each_layer(&mut doc.layers, &mut |l| { + clipped.extend(l.fill_cache.as_ref().map(|fc| std::ptr::from_ref(&fc.surface))); + if let Some(v) = &l.video { + clipped.extend(v.frames.iter().chain(v.original_frames.iter().flatten()).map(std::ptr::from_ref)); + } + if let LayerContent::Raster(s) = &l.content { + clipped.insert(std::ptr::from_ref(s)); + } + }); + let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); + // The largest surface by bytes, and the largest one Delete Cropped Pixels doesn't clip: its + // width, height and bytes per pixel (a mask has one channel, the layers the document's). + let (mut largest, mut largest_kept) = ((0u32, 0u32, 1u64), (0u32, 0u32, 1u64)); let mut visit = |surf: &Surface, filter: Resample| { let r = largest_resize_rect(surf.content_bounds(), sx, sy, filter); // What stays within the new canvas is already budgeted. if r.is_empty() || new_canvas.union(&r) == new_canvas { return; } - let (w, h) = (r.width(), r.height()); - let bpp = surf.format().bytes_per_pixel().max(1) as u64; - let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); - if bytes((w, h, bpp)) > bytes(largest) { - largest = (w, h, bpp); + let this = (r.width(), r.height(), surf.format().bytes_per_pixel().max(1) as u64); + if bytes(this) > bytes(largest) { + largest = this; + } + if !clipped.contains(&std::ptr::from_ref(surf)) && bytes(this) > bytes(largest_kept) { + largest_kept = this; } }; // The surfaces `resample_doc` scales, smart object caches included. @@ -360,17 +377,24 @@ fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64, if let Some(sel) = &doc.selection { visit(sel, Resample::Bilinear); } - let (w, h, bpp) = largest; - let px = u64::from(w).saturating_mul(u64::from(h)); - if px.saturating_mul(bpp) <= MAX_RESAMPLE_BYTES { + let (kept, (w, h, bpp)) = if bytes(largest_kept) > MAX_RESAMPLE_BYTES { + (true, largest_kept) + } else if bytes(largest) > MAX_RESAMPLE_BYTES { + (false, largest) + } else { return Ok(()); - } + }; + let hint = if kept { + "choose a smaller size (layer masks, channels, the quick mask, the selection, type, shapes and smart objects keep theirs through Crop with Delete Cropped Pixels)" + } else { + "choose a smaller size, or delete them first (Crop with Delete Cropped Pixels)" + }; let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000; Err(bad( cmd, format!( - "{w} x {h} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. The pixels outside the canvas are resampled too: choose a smaller size, or delete them first (Crop with Delete Cropped Pixels)", - px / 1_000_000 + "{w} x {h} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. The pixels outside the canvas are resampled too: {hint}", + u64::from(w).saturating_mul(u64::from(h)) / 1_000_000 ), )) } diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index ef12a7bdf1..fc34b29244 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -855,12 +855,35 @@ fn image_size_budgets_the_hidden_pixels_it_resamples() { let past = s.active().unwrap().history.past_len(); let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string(); assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}"); + // The hidden pixels are a pixel layer's: Delete Cropped Pixels would clear them. + assert!(err.contains("or delete them first (Crop with Delete Cropped Pixels)"), "{err}"); assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (Size::new(10, 10), past)); // Without resampling only the resolution changes. s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap(); assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(10, 10), 300.0)); } +/// Delete Cropped Pixels clips pixel layers only: an alpha channel keeps its pixels outside the +/// crop, so the refusal doesn't send the user to an option that can't clear it. +#[test] +fn hidden_resample_budget_names_what_delete_cropped_pixels_keeps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.edit("channel", |doc, _| { + let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false); + let mut ch = Surface::new(fmt); + ch.write_region(Rect::new(0, 0, 100, 100), &[1.0; 100 * 100]); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch)); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": true})).unwrap(); + let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string(); + assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}"); + assert!(err.contains("channels") && !err.contains("delete them first"), "{err}"); + assert_eq!(doc(&s).size, Size::new(10, 10)); +} + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); From 7d903ba8fb4e8a92f8f65ff538b4c2eac9806dbc Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 5 Nov 2026 12:26:31 +0100 Subject: [PATCH 49/63] svg: pass the cache limits main added to import_parsed in the fallback test Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index 3d84285c62..0a59cc661d 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -476,7 +476,7 @@ fn the_fallback_search_reads_each_font_file_once() { // Inter has no box-drawing characters; JetBrains Mono has. let svg = "I\u{2500}I"; let outlines = |parsed: Parsed| { - let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap().document.layers; + let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap().document.layers; let groups = layers.into_iter().filter_map(|l| if let LayerContent::Group(g) = l.content { Some(g) } else { None }); groups.flat_map(|g| g.children).filter_map(|l| if let LayerContent::Shape(s) = l.content { Some(s.path) } else { None }).collect::>() }; From 3317aede3b0f7c910915f0fe30d728d0bd0ab1b5 Mon Sep 17 00:00:00 2001 From: envy-ai <177786885+envy-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:27:28 -0500 Subject: [PATCH 50/63] Fix Paint Bucket target with Gradient-style crosshair (#7994) * Mark Paint Bucket fill target with a lower-right crosshair * Unify Paint Bucket artwork and crosshair in one cursor bitmap * Match Paint Bucket target crosshair to Gradient cursor * Keep Paint Bucket cursor errors concise and fix Windows Clippy * Simplify blend preview condition for strict Clippy * Fix deprecated IDs in Linux bucket cursor test * Paint Bucket: a cursor error clears when the cursor recovers The failure branch wrote its error to the status bar, but nothing cleared it after a later scale worked, and the same failure could not report again. The open dialog stays until it is closed: it reports what happened. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: envy-ai Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas/cursors.rs | 41 +++- crates/ui-egui/src/canvas/tool_cursor.rs | 13 ++ .../ui-egui/src/canvas/tool_cursor/bucket.rs | 102 +++++++++ .../ui-egui/src/canvas/tool_cursor/tests.rs | 209 ++++++++++++++++++ crates/ui-egui/src/caps_lock_tests.rs | 3 + crates/ui-egui/src/icons.rs | 12 + crates/ui-egui/src/input_tests.rs | 46 +++- docs/ui-design.md | 1 + 9 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/canvas/tool_cursor/bucket.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f75576c2e4..318242b504 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1595,7 +1595,7 @@ fn draw_default_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: & if precise_other_cursor(app) { egui::CursorIcon::Crosshair } else { - paint_bucket_cursor(ui.ctx(), p) + paint_bucket_cursor(app, painter, p) } } // Over the first anchor, the close-path pointer (#4596), before the pens' own cursors. diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index e93b1840dc..dee8017444 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -10,7 +10,7 @@ pub(super) fn visible_crosshair(icon: egui::CursorIcon, painter: &egui::Painter, if !draw || icon != egui::CursorIcon::Crosshair { return icon; } - crate::tool_cursor::crosshair(painter, p, 8.0, 2.0) + crate::tool_cursor::standard_crosshair(painter, p) } /// The Pencil's cursor at `doc` (document pixels): the whole-pixel square its dab fills @@ -150,12 +150,39 @@ pub(crate) fn pipette_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon { egui::CursorIcon::None } -/// The Paint Bucket's cursor: a bucket whose pouring corner is the filled pixel. Draws it at `p` -/// and returns the OS cursor to set (hidden). -pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon { - // The left corner of the icon's bucket is at (2, 12) of its 24-unit box. - crate::icons::cursor(ctx, "paint-bucket", p, vec2(2.0, 12.0) / 24.0, 22.0); - egui::CursorIcon::None +/// The Paint Bucket artwork and target share one cached cursor image. Report rasterization +/// failures once per distinct message, with the same concise error in the dialog and status bar. +pub(crate) fn paint_bucket_cursor(app: &mut PhotocraftApp, painter: &egui::Painter, p: Pos2) -> egui::CursorIcon { + let ctx = painter.ctx(); + let id = egui::Id::unique("photocraft-bucket-cursor-error"); + match crate::tool_cursor::bucket(painter, p) { + Ok(icon) => { + // The failure belonged to another scale: its status goes, and a new one reports again. + if let Some(error) = ctx.data(|data| data.get_temp::(id)) { + ctx.data_mut(|data| data.remove::(id)); + if app.ui.status == error { + app.ui.status.clear(); + app.ui.status_error = false; + } + } + icon + } + Err(error) => { + if ctx.data(|data| data.get_temp::(id)).as_ref() != Some(&error) { + if !app.ui.dialogs.iter().any(|dialog| { + dialog.kind == crate::state::DialogKind::Error && dialog.fields.get("message").and_then(serde_json::Value::as_str) == Some(&error) + }) { + let mut fields = serde_json::Map::new(); + fields.insert("message".into(), json!(error)); + app.ui.open_dialog(crate::state::DialogKind::Error, fields); + } + ctx.data_mut(|data| data.insert_temp(id, error.clone())); + } + app.ui.status = error; + app.ui.status_error = true; + egui::CursorIcon::Default + } + } } /// Decided when the press starts, so ⌥ pressed or released mid-stroke never switches between diff --git a/crates/ui-egui/src/canvas/tool_cursor.rs b/crates/ui-egui/src/canvas/tool_cursor.rs index e4a81bdc49..84b69b26cf 100644 --- a/crates/ui-egui/src/canvas/tool_cursor.rs +++ b/crates/ui-egui/src/canvas/tool_cursor.rs @@ -12,6 +12,7 @@ use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; use crate::canvas::ViewXform; use crate::state::Tool; +mod bucket; mod hand; mod pen; mod zoom; @@ -39,6 +40,9 @@ enum Shape { Outline { rings: Arc<[Vec]>, centre: bool }, } +/// The visible canvas crosshair used by the Gradient tool and other standard crosshair tools. +const STANDARD_CROSSHAIR: Shape = Shape::Crosshair { length: 8.0, gap: 2.0 }; + #[derive(Clone)] struct Cached { shape: Shape, @@ -80,6 +84,11 @@ impl egui::plugin::Plugin for CursorLifecycle { } } +/// The bucket artwork and its exact fill crosshair move as one native cursor or painted image. +pub(crate) fn bucket(painter: &Painter, at: Pos2) -> Result { + bucket::show(painter, at) +} + pub(crate) fn circle(painter: &Painter, at: Pos2, radius: f32, centre: bool) -> CursorIcon { show(painter, at, Shape::Circle { radius, centre }) } @@ -88,6 +97,10 @@ pub(crate) fn crosshair(painter: &Painter, at: Pos2, length: f32, gap: f32) -> C show(painter, at, Shape::Crosshair { length, gap }) } +pub(crate) fn standard_crosshair(painter: &Painter, at: Pos2) -> CursorIcon { + show(painter, at, STANDARD_CROSSHAIR.clone()) +} + pub(crate) fn outline(painter: &Painter, at: Pos2, rings: Vec>, centre: bool) -> CursorIcon { show(painter, at, Shape::Outline { rings: rings.into(), centre }) } diff --git a/crates/ui-egui/src/canvas/tool_cursor/bucket.rs b/crates/ui-egui/src/canvas/tool_cursor/bucket.rs new file mode 100644 index 0000000000..41d37ea1f3 --- /dev/null +++ b/crates/ui-egui/src/canvas/tool_cursor/bucket.rs @@ -0,0 +1,102 @@ +//! One image owns the bucket artwork and its lower-right fill target. Splitting those between +//! the frame and the OS pointer lets them drift apart whenever a frame arrives after the pointer. + +use super::{CursorIcon, CustomCursorImage, Painter, Pos2, native, native_scale}; +use std::sync::Arc; + +const SIZE: f32 = 22.0; +const HOT: egui::Vec2 = egui::vec2(26.0 / 24.0 * SIZE, 25.0 / 24.0 * SIZE); + +#[derive(Clone)] +struct Cached { + scale: f32, + image: Result, + texture: Option, +} + +pub(super) fn show(painter: &Painter, at: Pos2) -> Result { + let ctx = painter.ctx(); + let native = native(ctx); + let scale = if native { native_scale(ctx) } else { ctx.pixels_per_point() }; + let id = egui::Id::unique("photocraft-bucket-cursor"); + let mut cached = ctx.data(|d| d.get_temp::(id)).filter(|cached| cached.scale == scale).unwrap_or_else(|| Cached { + scale, + // Keep diagnostics in the log once per cached failure; the error also reaches UI text. + image: rasterize(scale).inspect_err(|error| log::error!("{error}\n{}", std::backtrace::Backtrace::force_capture())), + texture: None, + }); + let result = match &cached.image { + Err(error) => Err(error.clone()), + Ok(image) => { + if native { + ctx.set_cursor_image(Some(image.clone())); + } else { + let texture = cached.texture.get_or_insert_with(|| { + let size = image.size.map(usize::from); + ctx.load_texture("Paint Bucket cursor", egui::ColorImage::from_rgba_unmultiplied(size, &image.rgba), egui::TextureOptions::LINEAR) + }); + let size = egui::vec2(f32::from(image.size[0]), f32::from(image.size[1])) / scale; + // A texture's texel centre is half a physical pixel into its cell. Keep that + // centre exactly on the pointer, just as the native bitmap hotspot is. + let hot = (egui::vec2(f32::from(image.hotspot[0]), f32::from(image.hotspot[1])) + egui::Vec2::splat(0.5)) / scale; + let rect = egui::Rect::from_min_size(at - hot, size); + egui::Area::new(egui::Id::unique("pc-tool-cursor")).order(egui::Order::Tooltip).fixed_pos(rect.min).constrain(false).interactable(false).show( + ctx, + |ui| { + ui.painter().image(texture.id(), rect, egui::Rect::from_min_max(Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + }, + ); + } + Ok(CursorIcon::None) + } + }; + ctx.data_mut(|d| d.insert_temp(id, cached)); + result +} + +pub(super) fn rasterize(scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err(format!("Invalid Paint Bucket cursor scale: {scale}")); + } + // Round the hotspot itself, rather than the box, so it always lands on an exact bitmap pixel. + // Keep one logical pixel of the existing icon halo and its antialiasing fringe inside the box. + let margin = (scale + 1.0).ceil(); + let hot = [(HOT.x * scale).round() + margin, (HOT.y * scale).round() + margin]; + let cross = super::rasterize(&super::STANDARD_CROSSHAIR, scale).ok_or_else(|| format!("Paint Bucket crosshair does not fit a cursor at scale {scale}"))?; + let size = [hot[0] + f32::from(cross.size[0] - cross.hotspot[0]), hot[1] + f32::from(cross.size[1] - cross.hotspot[1])]; + if size.iter().any(|side| !side.is_finite() || *side > f32::from(super::MAX_SIDE)) { + return Err(format!("Paint Bucket cursor does not fit the platform bitmap limit at scale {scale}")); + } + let size = size.map(|side| side as u16); + let hotspot = hot.map(|coord| coord as u16); + let mut pixels = vec![egui::Color32::TRANSPARENT; usize::from(size[0]) * usize::from(size[1])]; + let icon = crate::icons::rasterize("paint-bucket", (SIZE * scale).round() as u32)?; + let margin = margin as i32; + let halo = scale.round() as i32; + // The same eight dark halo offsets and white SVG as icons::cursor, now in the image that + // also owns the crosshair. Color32 keeps composition premultiplied until the final upload. + for (dx, dy) in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, 1), (-1, 1), (1, -1), (0, 0)] { + for (i, pixel) in icon.pixels.iter().enumerate() { + let x = (i % icon.size[0]) as i32 + margin + dx * halo; + let y = (i / icon.size[0]) as i32 + margin + dy * halo; + let color = if dx == 0 && dy == 0 { *pixel } else { egui::Color32::from_black_alpha((f32::from(pixel.a()) * 200.0 / 255.0).round() as u8) }; + over(&mut pixels, size, x, y, color); + } + } + for (i, pixel) in cross.rgba.as_chunks::<4>().0.iter().enumerate() { + let x = (i % usize::from(cross.size[0])) as i32 + i32::from(hotspot[0]) - i32::from(cross.hotspot[0]); + let y = (i / usize::from(cross.size[0])) as i32 + i32::from(hotspot[1]) - i32::from(cross.hotspot[1]); + over(&mut pixels, size, x, y, egui::Color32::from_rgba_unmultiplied(pixel[0], pixel[1], pixel[2], pixel[3])); + } + let rgba = pixels.into_iter().flat_map(|pixel| pixel.to_srgba_unmultiplied()).collect::>(); + Ok(CustomCursorImage { rgba: Arc::from(rgba), size, hotspot }) +} + +fn over(pixels: &mut [egui::Color32], size: [u16; 2], x: i32, y: i32, foreground: egui::Color32) { + if x < 0 || y < 0 || x >= i32::from(size[0]) || y >= i32::from(size[1]) { + return; + } + if let Some(background) = pixels.get_mut(y as usize * usize::from(size[0]) + x as usize) { + *background = background.blend(foreground); + } +} diff --git a/crates/ui-egui/src/canvas/tool_cursor/tests.rs b/crates/ui-egui/src/canvas/tool_cursor/tests.rs index f058b84b2c..bd5b40c97e 100644 --- a/crates/ui-egui/src/canvas/tool_cursor/tests.rs +++ b/crates/ui-egui/src/canvas/tool_cursor/tests.rs @@ -10,6 +10,11 @@ fn alpha(image: &CustomCursorImage, x: u16, y: u16) -> u8 { image.rgba[(usize::from(y) * usize::from(image.size[0]) + usize::from(x)) * 4 + 3] } +fn rgba_at(image: &CustomCursorImage, x: u16, y: u16) -> [u8; 4] { + let offset = (usize::from(y) * usize::from(image.size[0]) + usize::from(x)) * 4; + image.rgba[offset..offset + 4].try_into().unwrap() +} + fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { let mut output = ctx.run_ui(Default::default(), draw); // These tests inspect cursor output without a renderer. @@ -17,6 +22,20 @@ fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutp output } +#[cfg(target_os = "linux")] +fn collect_meshes(shape: &egui::Shape, meshes: &mut Vec<(egui::TextureId, egui::Rect)>) { + match shape { + egui::Shape::Mesh(mesh) => meshes.push((mesh.texture_id, mesh.calc_bounds())), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect_meshes(shape, meshes); + } + } + egui::Shape::Noop => {} + other => panic!("unexpected non-image cursor fallback primitive: {other:?}"), + } +} + #[test] fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() { for scale in [1.0, 1.25, 1.5, 2.0, 3.0] { @@ -48,6 +67,161 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() { assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230); } +#[test] +fn bucket_bitmap_keeps_the_svg_above_left_of_its_exact_crosshair_hotspot() { + for scale in [1.0, 1.25, 2.0] { + let image = super::bucket::rasterize(scale).unwrap(); + let expected_x = (26.0 / 24.0 * 22.0 * scale).round() + (scale + 1.0).ceil(); + let expected_y = (25.0 / 24.0 * 22.0 * scale).round() + (scale + 1.0).ceil(); + assert_eq!(image.hotspot, [expected_x as u16, expected_y as u16], "bucket box target at {scale}x"); + let gradient_cross = rasterize(&STANDARD_CROSSHAIR, scale).unwrap(); + assert_eq!(alpha(&image, image.hotspot[0], image.hotspot[1]), 0, "Gradient-style open center at {scale}x"); + + let side = usize::from(image.size[0]); + let has_bucket_art = image.rgba.as_chunks::<4>().0.iter().enumerate().any(|(i, p)| { + let x = (i % side) as u16; + let y = (i / side) as u16; + p[3] > 200 && x + 2 < image.hotspot[0] && y + 2 < image.hotspot[1] + }); + assert!(has_bucket_art, "opaque bucket art remains above-left of the hotspot at {scale}x"); + + for arm in [1, 5, 8] { + let arm = (arm as f32 * scale).round() as u16; + assert_eq!( + rgba_at(&image, image.hotspot[0] + arm, image.hotspot[1]), + rgba_at(&gradient_cross, gradient_cross.hotspot[0] + arm, gradient_cross.hotspot[1]), + "right arm matches the Gradient crosshair at {scale}x" + ); + assert_eq!( + rgba_at(&image, image.hotspot[0], image.hotspot[1] + arm), + rgba_at(&gradient_cross, gradient_cross.hotspot[0], gradient_cross.hotspot[1] + arm), + "lower arm matches the Gradient crosshair at {scale}x" + ); + } + } +} + +#[test] +fn bucket_rasterization_reports_invalid_scales_and_missing_artwork() { + for scale in [0.0, f32::NAN, f32::INFINITY] { + let error = super::bucket::rasterize(scale).unwrap_err(); + assert!(error.contains("Invalid Paint Bucket cursor scale"), "{scale}: {error}"); + } + let error = crate::icons::rasterize("missing-paint-bucket", 22).unwrap_err(); + assert!(error.contains("Cursor icon \"missing-paint-bucket\" is missing"), "{error}"); +} + +#[test] +fn bucket_cursor_errors_preserve_the_concise_rasterization_message_across_frames() { + let ctx = egui::Context::default(); + ctx.set_zoom_factor(100.0); + let mut errors = Vec::new(); + for _ in 0..2 { + let output = frame(&ctx, |ui| { + let scale = if native(ui.ctx()) { native_scale(ui.ctx()) } else { ui.ctx().pixels_per_point() }; + let expected = super::bucket::rasterize(scale).unwrap_err(); + let error = super::bucket(ui.painter(), egui::pos2(100.0, 100.0)).unwrap_err(); + assert_eq!(error, expected, "the dialog and status receive only the original rasterization error"); + assert_eq!(error.lines().count(), 1, "diagnostic backtraces belong in the log"); + errors.push(error); + }); + assert!(output.platform_output.cursor_image.is_none(), "a failed rasterization never installs a cursor"); + } + assert_eq!(errors[0], errors[1], "the cached failure stays stable across frames"); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_bucket_cursor_error_leaves_the_status_bar_when_the_cursor_recovers() { + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(100.0); + frame(&ctx, |ui| { + crate::canvas::paint_bucket_cursor(&mut app, ui.painter(), pos2(100.0, 100.0)); + }); + assert!(app.ui.status_error, "the failure reaches the status bar"); + assert!(app.ui.status.contains("Paint Bucket"), "{}", app.ui.status); + + ctx.set_zoom_factor(1.0); + for _ in 0..2 { + frame(&ctx, |ui| { + crate::canvas::paint_bucket_cursor(&mut app, ui.painter(), pos2(100.0, 100.0)); + }); + } + assert!(!app.ui.status_error, "the cursor works again"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn bucket_cursor_reuses_the_native_bitmap_while_the_hotspot_moves() { + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + let cursor = |at| { + let output = frame(&ctx, |ui| { + let icon = super::bucket(ui.painter(), at).unwrap(); + ui.ctx().set_cursor_icon(icon); + }); + output.platform_output.cursor_image.expect("root desktop viewport provides the bucket bitmap") + }; + + let first = cursor(pos2(100.0, 100.0)); + let moved = cursor(pos2(137.0, 163.0)); + assert!(Arc::ptr_eq(&first.rgba, &moved.rgba), "moving the pointer reuses the rasterized bitmap"); + assert_eq!(first.hotspot, moved.hotspot); +} + +#[test] +#[cfg(target_os = "linux")] +fn scaled_wayland_bucket_uses_one_cached_image_area_without_a_split_crosshair() { + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + set_wayland(&ctx, true); + let run = |native_ppp: f32, at: egui::Pos2| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native_ppp); + let mut output = ctx.run_ui(input, |ui| { + let icon = super::bucket(ui.painter(), at).unwrap(); + ui.ctx().set_cursor_icon(icon); + }); + output.textures_delta.clear(); + let mut images = Vec::new(); + for shape in &output.shapes { + collect_meshes(&shape.shape, &mut images); + } + (output, images) + }; + + let first_at = pos2(100.0, 100.0); + run(1.25, first_at); // egui's first Area pass measures content and emits no shapes. + let (first, first_images) = run(1.25, first_at); + assert!(first.platform_output.cursor_image.is_none(), "scaled Wayland does not send a bitmap to winit"); + assert_eq!(first.platform_output.cursor_icon, CursorIcon::None, "the image replaces the system arrow"); + assert_eq!(first_images.len(), 1, "bucket and fill crosshair are one Image primitive"); + let image = super::bucket::rasterize(1.25).unwrap(); + let image_bounds = first_images[0].1; + let image_hotspot = image_bounds.min + egui::vec2((f32::from(image.hotspot[0]) + 0.5) / 1.25, (f32::from(image.hotspot[1]) + 0.5) / 1.25); + assert_eq!(image_hotspot, first_at, "the center of the hotspot texel stays on the pointer at 1.25x"); + let moved_at = pos2(137.0, 163.0); + let (moved, moved_images) = run(1.25, moved_at); + assert_eq!(moved_images.len(), 1); + assert_eq!(moved_images[0].0, first_images[0].0, "the single scaled texture is cached while it moves"); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("pc-tool-cursor")); + assert!(ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)), "one cursor Area owns the image"); + assert!( + !ctx.memory(|memory| memory.areas().visible_layer_ids().iter().any(|id| id.id == egui::Id::unique("pc-icon-cursor"))), + "the old icon overlay is absent" + ); + let mut moved_shapes = Vec::new(); + for shape in &moved.shapes { + collect_meshes(&shape.shape, &mut moved_shapes); + } + assert_eq!(moved_shapes.len(), 1, "there are no independent crosshair line primitives"); + let moved_bounds = moved_images[0].1; + let moved_hotspot = moved_bounds.min + egui::vec2((f32::from(image.hotspot[0]) + 0.5) / 1.25, (f32::from(image.hotspot[1]) + 0.5) / 1.25); + assert_eq!(moved_hotspot, moved_at, "the cached image keeps its hotspot aligned after movement"); +} + /// On Linux a tip stays within what X servers and hardware cursor planes take (256 px); a larger /// one falls back to the painted outline. Windows and macOS take far larger cursors. #[test] @@ -352,6 +526,41 @@ fn write_cursor_preview() { std::fs::write(path, png).unwrap(); } +/// Run explicitly to inspect the unified bucket bitmap at 1× and 2× against three backgrounds. +#[test] +#[ignore = "writes tmp/cursor-verification/unified-bucket-preview.png for visual inspection"] +fn write_bucket_cursor_preview() { + let (width, height) = (600_usize, 300_usize); + let mut rgba = vec![255; width * height * 4]; + let backgrounds = [32_u8, 128, 255]; + for (col, background) in backgrounds.into_iter().enumerate() { + for y in 0..height { + for x in col * 200..(col + 1) * 200 { + rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(background); + } + } + for (row, scale) in [1.0, 2.0].into_iter().enumerate() { + let image = super::bucket::rasterize(scale).unwrap(); + let side = usize::from(image.size[0]); + let pointer = (col * 200 + 100, row * 150 + 75); + let x0 = pointer.0 - usize::from(image.hotspot[0]); + let y0 = pointer.1 - usize::from(image.hotspot[1]); + for (i, pixel) in image.rgba.as_chunks::<4>().0.iter().enumerate() { + let at = ((y0 + i / side) * width + x0 + i % side) * 4; + let a = f32::from(pixel[3]) / 255.0; + for channel in 0..3 { + rgba[at + channel] = (f32::from(pixel[channel]) * a + f32::from(background) * (1.0 - a)).round() as u8; + } + } + } + } + let image = photocraft_codecs::Image::from_u8(width as u32, height as u32, photocraft_codecs::ChannelLayout::Rgba, rgba).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp/cursor-verification/unified-bucket-preview.png"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, png).unwrap(); +} + #[cfg(not(target_arch = "wasm32"))] fn harness() -> Harness<'static, crate::PhotocraftApp> { let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/caps_lock_tests.rs b/crates/ui-egui/src/caps_lock_tests.rs index eee2eb5053..f4f8f5d72f 100644 --- a/crates/ui-egui/src/caps_lock_tests.rs +++ b/crates/ui-egui/src/caps_lock_tests.rs @@ -164,6 +164,9 @@ fn caps_lock_swaps_the_other_cursors_between_their_icon_and_the_crosshair() { assert_eq!(img.size, expected.size, "{tool:?}: bitmap dimensions match rasterized variant"); assert_eq!(img.hotspot, expected.hotspot, "{tool:?}: hotspot matches variant"); assert_eq!(img.rgba, expected.rgba, "{tool:?}: RGBA bitmap matches variant rasterization"); + } else if tool == Tool::PaintBucket && cfg!(not(target_arch = "wasm32")) { + assert_eq!(standard.icon, CursorIcon::Crosshair, "PaintBucket: the OS bitmap has a visible fallback"); + assert!(standard.image.is_some(), "PaintBucket: the standard cursor is the unified OS bitmap"); } else { assert_eq!(standard.icon, CursorIcon::None, "{tool:?}: the standard cursor is the tool's own icon, drawn"); assert!(standard.image.is_none(), "{tool:?}: painted fallback has no native cursor bitmap"); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c910d80aed..c102064a7f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -47,6 +47,18 @@ pub fn image(name: &str, size: f32, tint: Color32) -> egui::Image<'static> { egui::Image::new(white_icons().source(name)).fit_to_exact_size(Vec2::splat(size)).tint(tint) } +/// Rasterize the same styled SVG used by the toolbar for a composed native cursor. +pub(crate) fn rasterize(name: &str, side: u32) -> Result { + if !white_icons().contains(name) { + return Err(format!("Cursor icon {name:?} is missing")); + } + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(name) else { + return Err(format!("Cursor icon {name:?} has no embedded SVG bytes")); + }; + egui_extras::image::load_svg_bytes_with_size(&bytes, egui::load::SizeHint::Width(side), &Default::default()) + .map_err(|error| format!("Could not rasterize cursor icon {name:?}: {error}")) +} + /// Paint an icon centred in `rect`. pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) { let r = Rect::from_center_size(rect.center(), Vec2::splat(size)); diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index fb708d9438..aa6b066885 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -1006,20 +1006,56 @@ fn eyedropper_and_alt_sampling_show_a_pipette() { assert_eq!(precise(&mut h), (egui::CursorIcon::Crosshair, desktop)); } -/// The Paint Bucket shows a bucket instead of the crosshair (#3968); Precise Other Cursors keeps -/// the crosshair. +/// The bucket and fill target share one pointer bitmap or one painted image, and Precise Other Cursors hides both. #[test] fn paint_bucket_shows_a_bucket_cursor() { + fn cursor_area_visible(h: &Harness<'static, PhotocraftApp>, id: &str) -> bool { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique(id)); + h.ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)) + } + let mut h = harness(); let p = h.state().last_canvas_rect.center(); h.state_mut().ui.tool = crate::state::Tool::PaintBucket; h.hover_at(p); h.run_steps(2); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the bucket replaces the pointer"); + + let output = h.output(); + if cfg!(not(target_arch = "wasm32")) { + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Crosshair, "the bitmap keeps a visible fallback"); + let cursor = output.platform_output.cursor_image.as_ref().expect("native root viewport uses the unified bucket bitmap"); + let [hot_x, hot_y] = cursor.hotspot; + let hot_pixel = (usize::from(hot_y) * usize::from(cursor.size[0]) + usize::from(hot_x)) * 4 + 3; + assert_eq!(cursor.rgba[hot_pixel], 0, "the Gradient-style crosshair leaves its exact bitmap hotspot open"); + let side = usize::from(cursor.size[0]); + assert!( + cursor.rgba.as_chunks::<4>().0.iter().enumerate().any(|(i, pixel)| { + let x = (i % side) as u16; + let y = (i / side) as u16; + pixel[3] > 200 && x + 2 < hot_x && y + 2 < hot_y + }), + "the bucket artwork is opaque above-left of the same hotspot" + ); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "native cursor has no frame-painted icon overlay"); + assert!(!cursor_area_visible(&h, "pc-tool-cursor"), "native cursor does not create a painted cursor Area"); + } else { + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::None, "the painted image hides the OS arrow"); + assert!(cursor_area_visible(&h, "pc-tool-cursor"), "scaled or web cursor uses one unified image Area"); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "the old icon overlay is absent"); + } + assert_eq!(h.ctx.pointer_latest_pos(), Some(p), "the click target stays at the cursor hotspot"); + h.state_mut().run("prefs.set", json!({"values": {"cursors.other": "precise"}})).unwrap(); - h.hover_at(p); h.run_steps(2); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Crosshair, "Precise keeps the crosshair"); + let precise = h.output(); + assert_eq!(precise.platform_output.cursor_icon, egui::CursorIcon::Crosshair); + assert_eq!( + precise.platform_output.cursor_image.is_some(), + cfg!(not(target_arch = "wasm32")), + "Precise uses its crosshair bitmap on native and the system cursor on web" + ); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "Precise omits the bucket artwork"); + assert!(!cursor_area_visible(&h, "pc-tool-cursor"), "Precise omits the painted bucket image"); } #[test] diff --git a/docs/ui-design.md b/docs/ui-design.md index c4bcd34882..54bbb30e26 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -57,6 +57,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Free Transform | `transform_tool.rs` | ⌘T. Corners and edges scale proportionally (⇧ frees them: an edge then stretches one axis); ⌥ scales about the reference point; ⌘-corner distorts; dragging outside rotates (⇧ snaps to 15°); dragging inside moves. Preview = document without the moving pixels + a textured 24×24 mesh. ↩ or a double-click commits via `edit.transform {rect, quad}`. With several layers selected one box frames and moves them all (`layers`; the Background is left out, Warp is one layer at a time). | | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | +| Paint Bucket cursor | `canvas.rs`, `tool_cursor.rs` | The standard bucket cursor has the same dark/light, open-centre crosshair as the Gradient tool, just below and to the right of its drop. The crosshair centre is the exact fill/click target; the bucket image sits above-left of that point. The shared cursor primitive rasterizes the existing styled bucket SVG and crosshair into one cached bitmap with that centre as its exact hotspot. Native desktop uses the entire image as the OS cursor, so its bucket and crosshair move together independently of the frame rate; there is no separate bucket overlay. The web and scaled Wayland paint the same unified image in one frame. A rasterization failure keeps its concise error message in the dialog and status bar; its diagnostic backtrace goes to the log once when the failure is cached. Precise Other Cursors selects the crosshair alone, Caps Lock toggles that choice, and Alt/Option samples with the Eyedropper. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | | Channels selection | `channels_panel.rs` | Load channel as selection in the footer follows the highlighted channel row. When the active layer mask is targeted for painting while the underlying channel view remains Composite, it loads that layer mask; otherwise it loads the selected composite, color, alpha or vector mask channel. | | Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | From fcb4f4e494aabcf5095f6f73f513800ece7505f9 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 02:33:48 +0530 Subject: [PATCH 51/63] Grow and Similar: budget the colour index and report allocation failures (#9825) * Select: Grow and Similar index colours under a byte budget and fail instead of aborting ColorMatcher::new returns a Result, the colour tree is a flat node array reserved at its exact size, and Grow/Similar's mask and frontier stack use fallible allocation so a failure keeps the selection. * Select: a 1 GiB colour index on 32-bit, and Sample All Layers reports a failed composite The web build's 256 MiB index budget (about 4.2M colours) refused Grow and Similar on a noisy 24 MP photo whose index fits easily; 1 GiB admits every distinct colour of such a photo. The 64-byte charge per colour is an average, not a worst case, so the comment now says so, and the point list the tree keeps gives back its doubling slack once the dedupe set is gone. Sample All Layers renders the composite inside compose, so the selection tools now pass an allocation checkpoint before it, like the other commands that hand big work to another crate, and a failure there keeps the selection. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/selection.rs | 125 +++++++++++++----- crates/algo/src/selection/tests.rs | 66 +++++++-- crates/engine/src/allocation.rs | 12 ++ crates/engine/src/cmd/selection_cmds.rs | 28 +++- crates/engine/src/cmd/selection_cmds/tests.rs | 44 ++++++ 5 files changed, 228 insertions(+), 47 deletions(-) diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index c31a883a19..8493f2c1e8 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -941,49 +941,79 @@ pub struct ColorMatcher { } #[derive(Clone, Debug)] -enum ColorBvh { - Empty, - Leaf(Vec<[f32; 4]>), - Node { min: [f32; 4], max: [f32; 4], left: Box, right: Box }, +struct ColorBvh { + points: Vec<[f32; 4]>, + nodes: Vec, +} + +#[derive(Clone, Debug)] +enum ColorNode { + Leaf { start: usize, end: usize }, + Branch { min: [f32; 4], max: [f32; 4], left: usize, right: usize }, } impl ColorBvh { - fn build(mut points: Vec<[f32; 4]>) -> Self { - if points.is_empty() { - return ColorBvh::Empty; + fn build(mut points: Vec<[f32; 4]>) -> Result { + let capacity = Self::node_count(points.len()); + let mut nodes = Self::reserve_nodes(capacity)?; + if !points.is_empty() { + Self::build_nodes(&mut points, 0, &mut nodes); } + Ok(Self { points, nodes }) + } + + fn node_count(len: usize) -> usize { + if len == 0 { + 0 + } else if len <= 16 { + 1 + } else { + 1 + Self::node_count(len / 2) + Self::node_count(len - len / 2) + } + } + + fn reserve_nodes(capacity: usize) -> Result, &'static str> { + let mut nodes = Vec::new(); + nodes.try_reserve_exact(capacity).map_err(|_| "not enough memory to index selection colours")?; + Ok(nodes) + } + + // Reserve the exact node count before recursion so pushes cannot allocate. + fn build_nodes(points: &mut [[f32; 4]], start: usize, nodes: &mut Vec) -> usize { if points.len() <= 16 { - return ColorBvh::Leaf(points); + let index = nodes.len(); + nodes.push(ColorNode::Leaf { start, end: start + points.len() }); + return index; } let (mut min, mut max) = ([f32::MAX; 4], [f32::MIN; 4]); - for p in &points { + for p in points.iter() { for c in 0..4 { min[c] = min[c].min(p[c]); max[c] = max[c].max(p[c]); } } - let mut best_axis = 0; - let mut max_spread = -1.0; - for c in 0..4 { - let spread = max[c] - min[c]; - if spread > max_spread { - max_spread = spread; - best_axis = c; + let mut axis = 0; + for c in 1..4 { + if max[c] - min[c] > max[axis] - min[axis] { + axis = c; } } let mid = points.len() / 2; - points.select_nth_unstable_by(mid, |a, b| a[best_axis].total_cmp(&b[best_axis])); - let right_pts = points.split_off(mid); - let left = ColorBvh::build(points); - let right = ColorBvh::build(right_pts); - ColorBvh::Node { min, max, left: Box::new(left), right: Box::new(right) } + points.select_nth_unstable_by(mid, |a, b| a[axis].total_cmp(&b[axis])); + let (left_points, right_points) = points.split_at_mut(mid); + let left = Self::build_nodes(left_points, start, nodes); + let right = Self::build_nodes(right_points, start + mid, nodes); + let index = nodes.len(); + nodes.push(ColorNode::Branch { min, max, left, right }); + index } - fn matches(&self, q: &[f32; 4], tol: f32) -> bool { - match self { - ColorBvh::Empty => false, - ColorBvh::Leaf(pts) => pts.iter().any(|p| (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6)), - ColorBvh::Node { min, max, left, right } => { + fn matches_node(&self, index: usize, q: &[f32; 4], tol: f32) -> bool { + match self.nodes.get(index) { + Some(ColorNode::Leaf { start, end }) => { + self.points.get(*start..*end).is_some_and(|pts| pts.iter().any(|p| (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6))) + } + Some(ColorNode::Branch { min, max, left, right }) => { for c in 0..4 { let d = if q[c] < min[c] { min[c] - q[c] @@ -996,15 +1026,39 @@ impl ColorBvh { return false; } } - left.matches(q, tol) || right.matches(q, tol) + self.matches_node(*left, q, tol) || self.matches_node(*right, q, tol) } + None => false, } } + + fn matches(&self, q: &[f32; 4], tol: f32) -> bool { + self.nodes.len().checked_sub(1).is_some_and(|root| self.matches_node(root, q, tol)) + } } +/// Bytes of memory the index is charged per distinct colour: the dedupe set entry and its +/// table slack, the colour in the point list, and its share of the tree nodes. It is an +/// approximate average, not a worst case: while the set rehashes or the point list doubles, +/// the peak runs to roughly 70–90 bytes per colour. +const COLOR_INDEX_BYTES: usize = 64; + +/// The most memory the index may be charged. It scales with the distinct colours rather than the +/// canvas, so a noisy 24 MP photo, or a float document where every pixel is its own colour, +/// still builds; it only keeps the index within the sampled pixels' own budget. On 32-bit +/// (the web build) 1 GiB still admits every distinct colour of a 24 MP photo. +const COLOR_INDEX_BUDGET: usize = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; + impl ColorMatcher { /// Creates a color matcher from selected RGBA colors and an $L_\\infty$ tolerance in $[0, 1]$. - pub fn new(selected_colors: impl IntoIterator, tol: f32) -> Self { + pub fn new(selected_colors: impl IntoIterator, tol: f32) -> Result { + Self::with_budget(selected_colors, tol, COLOR_INDEX_BUDGET) + } + + // Bound the index by `budget` bytes, charged at `COLOR_INDEX_BYTES` per distinct colour, and + // fail without altering the selection. + fn with_budget(selected_colors: impl IntoIterator, tol: f32, budget: usize) -> Result { + let limit = budget / COLOR_INDEX_BYTES; let mut has_transparent = false; let mut seen = std::collections::HashSet::new(); let mut opaque_or_semi = Vec::new(); @@ -1014,22 +1068,31 @@ impl ColorMatcher { has_transparent |= c[3] == 0.0; let norm = |x: f32| if x == 0.0 { 0.0f32.to_bits() } else { x.to_bits() }; let key = [norm(c[0]), norm(c[1]), norm(c[2]), norm(c[3])]; - if seen.insert(key) { + if !seen.contains(&key) { + if seen.len() >= limit { + return Err("the selection contains too many distinct colours to match; select a smaller area"); + } + seen.try_reserve(1).map_err(|_| "not enough memory to index selection colours")?; + opaque_or_semi.try_reserve(1).map_err(|_| "not enough memory to index selection colours")?; + seen.insert(key); opaque_or_semi.push(c); } } + drop(seen); opaque_or_semi.sort_unstable_by(|a, b| { a[0].total_cmp(&b[0]).then_with(|| a[1].total_cmp(&b[1])).then_with(|| a[2].total_cmp(&b[2])).then_with(|| a[3].total_cmp(&b[3])) }); opaque_or_semi.dedup_by(|a, b| a == b); + // The tree keeps this list; give back the slack its doubling left. + opaque_or_semi.shrink_to_fit(); - Self { has_transparent, tree: ColorBvh::build(opaque_or_semi), tol: tol.max(0.0) } + Ok(Self { has_transparent, tree: ColorBvh::build(opaque_or_semi)?, tol: tol.max(0.0) }) } /// Returns `true` if no colors (including transparent pixels) were provided. #[inline] pub fn is_empty(&self) -> bool { - !self.has_transparent && matches!(self.tree, ColorBvh::Empty) + !self.has_transparent && self.tree.nodes.is_empty() } /// Returns `true` if `q` falls within `tol` of any selected color. diff --git a/crates/algo/src/selection/tests.rs b/crates/algo/src/selection/tests.rs index 6976776af1..4e7bdcbe06 100644 --- a/crates/algo/src/selection/tests.rs +++ b/crates/algo/src/selection/tests.rs @@ -2,7 +2,7 @@ fn color_matcher_bvh_and_transparency() { let red = [1.0, 0.0, 0.0, 1.0]; let blue = [0.0, 0.0, 1.0, 1.0]; - let matcher = ColorMatcher::new([red, blue], 10.0 / 255.0); + let matcher = ColorMatcher::new([red, blue], 10.0 / 255.0).unwrap(); assert!(!matcher.is_empty()); assert!(matcher.matches([0.98, 0.02, 0.0, 1.0])); @@ -14,13 +14,13 @@ fn color_matcher_bvh_and_transparency() { assert!(!matcher.matches([0.5, 0.0, 0.5, 1.0])); assert!(!matcher.matches([0.0, 0.0, 0.0, 0.0])); - let matcher_trans = ColorMatcher::new([red, [0.0, 0.0, 0.0, 0.0]], 10.0 / 255.0); + let matcher_trans = ColorMatcher::new([red, [0.0, 0.0, 0.0, 0.0]], 10.0 / 255.0).unwrap(); assert!(matcher_trans.matches([0.0, 0.0, 0.0, 0.0])); assert!(matcher_trans.matches([0.5, 0.2, 0.1, 0.0])); assert!(matcher_trans.matches([0.98, 0.0, 0.0, 1.0])); assert!(!matcher_trans.matches([0.0, 1.0, 0.0, 1.0])); - let empty = ColorMatcher::new([], 10.0 / 255.0); + let empty = ColorMatcher::new([], 10.0 / 255.0).unwrap(); assert!(empty.is_empty()); assert!(!empty.matches(red)); @@ -28,7 +28,7 @@ fn color_matcher_bvh_and_transparency() { let faint = [0.0, 0.0, 0.0, 1e-6]; assert!(!matcher_trans.matches([0.5, 0.2, 0.1, 1e-6])); assert!(matcher_trans.matches(faint)); - let matcher_faint = ColorMatcher::new([faint], 10.0 / 255.0); + let matcher_faint = ColorMatcher::new([faint], 10.0 / 255.0).unwrap(); assert!(matcher_faint.matches([0.0, 0.0, 0.0, 0.0])); assert!(!matcher_faint.matches([0.5, 0.0, 0.0, 0.0])); } @@ -37,7 +37,7 @@ fn color_matcher_bvh_and_transparency() { fn color_matcher_preserves_distinct_faint_32f_colors_at_zero_tolerance() { let zero = [0.0, 0.0, 0.0, 0.0]; let faint = [0.0, 0.0, 0.0, 8e-6]; - let matcher = ColorMatcher::new([zero, faint], 0.0); + let matcher = ColorMatcher::new([zero, faint], 0.0).unwrap(); assert!(matcher.matches(zero)); assert!(matcher.matches(faint)); assert!(!matcher.matches([0.0, 0.0, 0.0, 2e-5])); @@ -48,7 +48,7 @@ fn color_matcher_coalesces_exact_repeated_colors() { let red = [1.0, 0.0, 0.0, 1.0]; let transparent = [0.0, 0.0, 0.0, 0.0]; let colors = std::iter::repeat_n(red, 10_000).chain(std::iter::repeat_n(transparent, 10_000)); - let matcher = ColorMatcher::new(colors, 0.0); + let matcher = ColorMatcher::new(colors, 0.0).unwrap(); assert!(matcher.matches(red)); assert!(matcher.matches(transparent)); assert!(!matcher.matches([0.0, 1.0, 0.0, 1.0])); @@ -64,8 +64,8 @@ fn color_matcher_splits_colors_that_are_all_nearly_equal() { [c, 0.25, 0.75, 1.0] }) .collect(); - let matcher = ColorMatcher::new(selected.iter().copied(), 0.0); - assert!(matches!(matcher.tree, ColorBvh::Node { .. }), "more than 16 colours must not share one leaf"); + let matcher = ColorMatcher::new(selected.iter().copied(), 0.0).unwrap(); + assert!(matcher.tree.nodes.iter().any(|node| matches!(node, ColorNode::Branch { .. })), "more than 16 colours must not share one leaf"); for p in &selected { assert!(matcher.matches(*p)); } @@ -82,7 +82,7 @@ fn color_matcher_tree_matches_brute_force() { }) .collect(); let tol = 0.05; - let matcher = ColorMatcher::new(selected.iter().copied(), tol); + let matcher = ColorMatcher::new(selected.iter().copied(), tol).unwrap(); for i in 0..2000 { let t = i as f32 / 1999.0; let q = [(t * 3.0).fract(), (t * 5.0).fract(), (t * 11.0).fract(), if i % 7 == 0 { 0.0 } else { 1.0 }]; @@ -747,6 +747,45 @@ fn combine_region_matches_combine() { assert!(combine_region(None, None, SelectionMode::Subtract).is_none()); } +#[test] +fn color_matcher_bounds_tightly_clustered_leaves() { + let colors: Vec<_> = (0..1000).map(|i| [i as f32 * 1e-10, 0.0, 0.0, 1.0]).collect(); + let matcher = ColorMatcher::new(colors.iter().copied(), 0.0).unwrap(); + for node in &matcher.tree.nodes { + if let ColorNode::Leaf { start, end } = node { + assert!(end - start <= 16); + } + } + assert!(matcher.matches(colors[999])); + assert!(!matcher.matches([0.1, 0.0, 0.0, 1.0])); +} + +#[test] +fn color_matcher_budget_fires_at_the_distinct_color_it_is_sized_for() { + let colors: Vec<_> = (0..5).map(|i| [i as f32 / 8.0, 0.0, 0.0, 1.0]).collect(); + // Room for exactly `n` distinct colours: the n-th still fits, the (n + 1)-th does not. + for n in 1..=5 { + let budget = n * COLOR_INDEX_BYTES; + assert!(ColorMatcher::with_budget(colors[..n].iter().copied(), 0.0, budget).is_ok(), "{n} colours in {budget} bytes"); + if n < 5 { + assert!(ColorMatcher::with_budget(colors[..=n].iter().copied(), 0.0, budget).is_err(), "{} colours in {budget} bytes", n + 1); + } + } + // A budget short of one colour's charge admits none, and duplicates are never charged. + assert!(ColorMatcher::with_budget([colors[0]], 0.0, COLOR_INDEX_BYTES - 1).is_err()); + let repeated = std::iter::repeat_n(colors[0], 1000); + assert!(ColorMatcher::with_budget(repeated, 0.0, COLOR_INDEX_BYTES).is_ok()); +} + +#[test] +fn color_matcher_default_budget_admits_a_noisy_float_canvas() { + // Every pixel of a 2000x1000 float canvas is its own colour: past the old 1M-colour cap. + let colors = (0..2_000_000u32).map(|i| [i as f32 * 1e-7, (i % 1013) as f32 * 1e-3, 0.5, 1.0]); + let matcher = ColorMatcher::new(colors, 0.0).unwrap(); + assert!(matcher.matches([1_999_999.0 * 1e-7, (1_999_999 % 1013) as f32 * 1e-3, 0.5, 1.0])); + assert!(!matcher.matches([0.9, 0.9, 0.9, 1.0])); +} + #[test] fn polygon_window_keeps_full_canvas_fractional_coverage() { for canvas in [Rect::new(0, 0, 110_000, 12), Rect::new(-123, -7, 109_877, 5)] { @@ -779,3 +818,12 @@ fn feather_leaves_everything_beyond_its_reach_at_zero() { assert!(out[reach + 1..].iter().all(|v| *v == 0.0), "radius {radius}: nonzero past reach {reach}"); } } + +#[test] +fn color_matcher_tree_reservation_failure_is_reported() { + assert!(ColorBvh::reserve_nodes(usize::MAX).is_err()); + for n in 0..1024 { + let tree = ColorBvh::build(vec![[0.0; 4]; n]).unwrap(); + assert_eq!(tree.nodes.len(), ColorBvh::node_count(n)); + } +} diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 5d58cfae88..bf5dc177f6 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -27,6 +27,18 @@ pub(crate) fn capacity(len: usize, purpose: &str) -> Result> { Ok(out) } +/// `Vec::push` that reports an allocation failure instead of aborting; each call is one +/// attempt for the test failpoints. +pub(crate) fn try_push(out: &mut Vec, value: T, purpose: &str) -> Result<()> { + #[cfg(test)] + if failpoint_hits() { + return Err(error(purpose, "not enough memory")); + } + out.try_reserve(1).map_err(|_| error(purpose, "the buffer could not grow"))?; + out.push(value); + Ok(()) +} + /// Check an operation boundary where downstream encoders/decoders allocate internally. /// The test-only failpoint lets command tests prove their transaction boundary. pub(crate) fn checkpoint(purpose: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 83317e2645..927beba121 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -70,7 +70,11 @@ fn sample_pixels(s: &Session, all_layers: bool, window: Option) -> Result< surf.read_rgba_into(area, &mut px); Ok((area, px)) } - _ => Ok((area, photocraft_compose::render(&d.doc, area).into_px())), + _ => { + // The composite allocates inside compose; report a failure at its boundary. + crate::allocation::checkpoint("sampling pixels for a selection")?; + Ok((area, photocraft_compose::render(&d.doc, area).into_px())) + } } } @@ -540,21 +544,31 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec { out } +/// Pushes a pixel onto the grow frontier, reporting an allocation failure instead of aborting. +fn try_push(stack: &mut Vec, i: usize) -> Result<()> { + crate::allocation::try_push(stack, i, "growing a selection") +} + /// Grow (contiguous) / Similar (anywhere): pixels whose colour lies within /// tolerance of any selected pixel (indexed via ColorMatcher BVH). fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { let tol = f(p, "tolerance", 32.0) / 255.0; let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?; let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); - let matcher = sel::ColorMatcher::new(px.iter().zip(&m).filter_map(|(q, v)| (*v >= 0.5).then_some(*q)), tol); + let matcher = + sel::ColorMatcher::new(px.iter().zip(&m).filter_map(|(q, v)| (*v >= 0.5).then_some(*q)), tol).map_err(|message| EngineError::Other(message.into()))?; if matcher.is_empty() { return Err(EngineError::Other("selection is empty".into())); } let (w, h) = (area.width() as usize, area.height() as usize); - let mut out: Vec = m.iter().map(|v| if *v >= 0.5 { 1.0 } else { 0.0 }).collect(); + let mut out = crate::allocation::filled(m.len(), 0.0f32, "growing a selection")?; + for (o, v) in out.iter_mut().zip(&m) { + *o = if *v >= 0.5 { 1.0 } else { 0.0 }; + } if contiguous { - // Frontier stack: only push selected pixels adjacent to at least one unselected pixel - let mut stack = Vec::new(); + // Frontier stack: only push selected pixels adjacent to at least one unselected pixel. + // Every push goes through `try_push` so a canvas-sized stack fails instead of aborting. + let mut stack: Vec = Vec::new(); for y in 0..h { for x in 0..w { let i = y * w + x; @@ -564,7 +578,7 @@ fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { let has_unselected_neighbor = (x > 0 && out[i - 1] == 0.0) || (x + 1 < w && out[i + 1] == 0.0) || (y > 0 && out[i - w] == 0.0) || (y + 1 < h && out[i + w] == 0.0); if has_unselected_neighbor { - stack.push(i); + try_push(&mut stack, i)?; } } } @@ -574,7 +588,7 @@ fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { for j in n.into_iter().flatten() { if out[j] == 0.0 && matcher.matches(px[j]) { out[j] = 1.0; - stack.push(j); + try_push(&mut stack, j)?; } } } diff --git a/crates/engine/src/cmd/selection_cmds/tests.rs b/crates/engine/src/cmd/selection_cmds/tests.rs index e0e93bcd57..b4e3e2999d 100644 --- a/crates/engine/src/cmd/selection_cmds/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/tests.rs @@ -79,6 +79,20 @@ fn selection_sampling_allocation_failure_keeps_the_selection() { assert_eq!(s.active().unwrap().history.entries(), history); } +#[test] +fn selection_sampling_all_layers_reports_a_failed_composite_and_keeps_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + // Sample All Layers renders the composite: the failure is reported there, before the mask. + crate::allocation::fail_next_for_test(); + let err = s.execute("select.similar", json!({"sampleAllLayers": true})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for sampling pixels for a selection"), "{err}"); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); +} + #[test] fn modify_rejects_oversized_canvases_before_reading_the_mask() { let mut s = Session::new(); @@ -674,6 +688,36 @@ fn grow_and_similar_multi_color_does_not_select_intermediate_hull() { assert_eq!(coverage(&s, 30, 10), 0.0, "disconnected blue rect must not be grown"); } +#[test] +fn grow_and_similar_report_a_failed_mask_allocation_and_keep_the_selection() { + for cmd in ["select.grow", "select.similar"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 4, "height": 4})).unwrap(); + // The first fallible allocation samples the layer; the second is the canvas-sized result. + crate::allocation::fail_after_for_test(1); + let err = s.execute(cmd, json!({"tolerance": 5})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for growing a selection"), "{cmd}: {err}"); + assert_eq!(coverage(&s, 7, 7), 1.0, "{cmd}: the selection must be untouched"); + assert_eq!(coverage(&s, 20, 20), 0.0, "{cmd}: the selection must be untouched"); + } +} + +#[test] +fn grow_reports_a_failed_frontier_push_mid_traversal_and_keeps_the_selection() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 4, "height": 4})).unwrap(); + // Two buffer allocations (sample, result), then the 12 ring pixels of the 4x4 selection seed the + // frontier; three more clean pushes happen while flood-filling outwards before the next one fails. + crate::allocation::fail_after_for_test(2 + 12 + 3); + let err = s.execute("select.grow", json!({"tolerance": 5})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for growing a selection"), "{err}"); + assert_eq!(coverage(&s, 7, 7), 1.0, "the selection must be untouched"); + assert_eq!(coverage(&s, 20, 20), 0.0, "the flood fill must not have been committed"); + assert_eq!(coverage(&s, 11, 7), 0.0, "the flood fill must not have been committed"); +} + #[test] fn lasso_polygon() { let mut s = session(); From f127459ca3cf1f6c33aa29f06497af3d75711135 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 02:34:08 +0530 Subject: [PATCH 52/63] perf(algo): build Remove Tool target masks with a bounded separable dilation (#10179) (#10194) * perf(algo): build Remove Tool target masks with a bounded separable dilation (#10179) * docs(algo): remove::dilate no longer always runs two full-grid passes * test(algo): shrink the square dilation tests Exhaustive masks up to 3x3 instead of 4x4, drop the 300x200 named grid (the smaller grids already clip at r = 200, so its oracle-cost guard goes too), and run the path-choice tests on 600x400 and 240x240 grids. * test(algo): a mask with more set cells than bounds keeps matches the oracle Covers the branch where bounds drops its cell list (more than KEEP set cells) and the direct path rescans the box. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/examples/bench_remove_targets.rs | 162 +++++++++ crates/algo/src/lib.rs | 1 + crates/algo/src/nonlocal.rs | 28 +- crates/algo/src/remove.rs | 29 +- crates/algo/src/square_dilate.rs | 337 +++++++++++++++++++ 5 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 crates/algo/examples/bench_remove_targets.rs create mode 100644 crates/algo/src/square_dilate.rs diff --git a/crates/algo/examples/bench_remove_targets.rs b/crates/algo/examples/bench_remove_targets.rs new file mode 100644 index 0000000000..780875a1dc --- /dev/null +++ b/crates/algo/examples/bench_remove_targets.rs @@ -0,0 +1,162 @@ +//! Timing of the Remove Tool's target-mask construction (the dilation of the hole by the patch +//! radius, once per pyramid level) and of the whole fill, on a 24 or 36 MP RGBA f32 image. One +//! JSON line per repetition. Uses only APIs that exist before and after the change, so the same +//! file builds in a base checkout. +//! `cargo run --release -p photocraft-algo --example bench_remove_targets -- --mode phase --mp 24 --hole small --reps 20` +//! +//! * `--mode phase|e2e`: `phase` times the masks alone (`old_loop`, a verbatim copy of the loop +//! the completion used to run, against `remove_dilate`); `e2e` times `remove_with`. +//! * `--hole small|scattered|large|dense`: a 48 px disc; about 20 single cells spread over the +//! image; a centred ellipse (`--large 900x600` sets its extent); half the cells of a 2000x2000 +//! square (`phase` only). +use std::time::Instant; + +use photocraft_algo::remove::{dilate, hole_radius, remove_with}; +use photocraft_raster::Interrupt; + +/// The patch radius of the completion's default parameters. +const R: usize = 3; + +fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +fn hash(i: u64) -> u64 { + let mut x = i.wrapping_add(0x9e37_79b9_7f4a_7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +fn fnv(bytes: impl Iterator) -> u64 { + bytes.fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)) +} + +fn texture(w: usize, h: usize) -> Vec { + let mut img = Vec::with_capacity(w * h * 4); + for i in 0..w * h { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let n = (hash(i as u64) >> 40) as f32 / (1u64 << 24) as f32; + let v = 0.5 + 0.2 * (x * 0.05).sin() * (y * 0.07).cos() + 0.15 * n; + img.extend_from_slice(&[v, v * 0.8 + 0.05 * (x * 0.01).cos(), v * 0.6, 1.0]); + } + img +} + +fn hole_mask(kind: &str, w: usize, h: usize, large: (usize, usize)) -> Vec { + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + let mut m = vec![false; w * h]; + match kind { + "small" => (0..w * h).for_each(|i| m[i] = ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < 24.0), + "scattered" => { + for k in 0..20u64 { + let (x, y) = (hash(k * 2) as usize % w, hash(k * 2 + 1) as usize % h); + m[y * w + x] = true; + } + } + "large" => { + let (a, b) = (large.0 as f32 / 2.0, large.1 as f32 / 2.0); + (0..w * h).for_each(|i| m[i] = (((i % w) as f32 - cx) / a).powi(2) + (((i / w) as f32 - cy) / b).powi(2) < 1.0); + } + "dense" => { + for y in h / 2 - 1000..h / 2 + 1000 { + for x in w / 2 - 1000..w / 2 + 1000 { + m[y * w + x] = hash((y * w + x) as u64) & 1 == 0; + } + } + } + _ => {} + } + m +} + +/// The pyramid levels the completion builds for this hole: the same count formula and the same +/// 2x2 OR of the hole. +fn hole_levels(w: usize, h: usize, hole: &[bool]) -> Vec<(usize, usize, Vec)> { + let n = 2 * R + 1; + let no = hole_radius(w, h, hole).min(1 << 20); + let want = (2.0 * f64::from(no) / n as f64).log2().ceil().max(1.0) as usize; + let (mut lw, mut lh, mut levels) = (w, h, 1); + while levels < want && lw / 2 >= 3 * n && lh / 2 >= 3 * n { + (lw, lh, levels) = (lw.div_ceil(2), lh.div_ceil(2), levels + 1); + } + let mut out = vec![(w, h, hole.to_vec())]; + while out.len() < levels { + let Some((pw, ph, prev)) = out.last() else { break }; + let (nw, nh) = (pw.div_ceil(2), ph.div_ceil(2)); + let next: Vec = (0..nw * nh) + .map(|i| { + let (fx, fy) = ((i % nw) * 2, (i / nw) * 2); + (0..2).any(|dy| (0..2).any(|dx| fx + dx < *pw && fy + dy < *ph && prev[(fy + dy) * pw + fx + dx])) + }) + .collect(); + out.push((nw, nh, next)); + } + out +} + +/// The loop the completion ran per level before the change, verbatim. +fn old_loop(lw: usize, lh: usize, hole: &[bool], r: usize) -> Vec { + let ri = r as i32; + let mut targets = vec![false; lw * lh]; + for (i, _) in hole.iter().enumerate().filter(|(_, m)| **m) { + let (x, y) = ((i % lw) as i32, (i / lw) as i32); + for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) { + for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) { + targets[ty as usize * lw + tx as usize] = true; + } + } + } + targets +} + +fn main() { + let args: Vec = std::env::args().collect(); + let mode = arg(&args, "--mode").unwrap_or("phase"); + let mp: usize = arg(&args, "--mp").and_then(|v| v.parse().ok()).unwrap_or(24); + let kind = arg(&args, "--hole").unwrap_or("small"); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5); + let large = arg(&args, "--large").and_then(|v| v.split_once('x')).and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?))).unwrap_or((900, 600)); + let (w, h) = if mp == 36 { (7360, 4912) } else { (6000, 4000) }; + let hole = hole_mask(kind, w, h, large); + let line = |rep: usize, variant: &str, ms: f64, hash: u64| { + println!("{{\"mode\":\"{mode}\",\"mp\":{mp},\"hole\":\"{kind}\",\"rep\":{rep},\"variant\":\"{variant}\",\"ms\":{ms:.3},\"hash\":\"{hash:016x}\"}}") + }; + match mode { + "phase" => { + let levels = hole_levels(w, h, &hole); + for rep in 0..reps { + let t = Instant::now(); + let old: Vec> = levels.iter().map(|(lw, lh, m)| old_loop(*lw, *lh, m, R)).collect(); + let old_ms = t.elapsed().as_secs_f64() * 1e3; + let t = Instant::now(); + let new: Vec> = levels.iter().map(|(lw, lh, m)| dilate(*lw, *lh, m, R)).collect(); + let new_ms = t.elapsed().as_secs_f64() * 1e3; + let (ho, hn) = (fnv(old.iter().flatten().map(|b| u8::from(*b))), fnv(new.iter().flatten().map(|b| u8::from(*b)))); + assert_eq!(ho, hn, "the masks differ"); + line(rep, "old_loop", old_ms, ho); + line(rep, "remove_dilate", new_ms, hn); + } + } + "e2e" => { + if kind == "dense" { + eprintln!("the dense hole is a phase-only scenario"); + std::process::exit(2); + } + let img = texture(w, h); + for rep in 0..reps { + let t = Instant::now(); + let Ok(out) = remove_with(w, h, 4, &img, &hole, 1, &Interrupt::NONE) else { + eprintln!("cancelled"); + std::process::exit(1); + }; + let ms = t.elapsed().as_secs_f64() * 1e3; + line(rep, "remove_with", ms, fnv(out.iter().flat_map(|v| v.to_bits().to_le_bytes()))); + } + } + _ => { + eprintln!("unknown --mode {mode}"); + std::process::exit(2); + } + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 5a4d3c008c..ad72954a22 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -78,6 +78,7 @@ mod selection_blur; pub mod separable; mod shape_blur; mod sharpen; +mod square_dilate; pub mod stack; mod stylize; mod stylize2; diff --git a/crates/algo/src/nonlocal.rs b/crates/algo/src/nonlocal.rs index 148a1bd32d..3bc34ceec0 100644 --- a/crates/algo/src/nonlocal.rs +++ b/crates/algo/src/nonlocal.rs @@ -614,7 +614,7 @@ impl Solver<'_> { // `FUSE_BAND` of a seam are solved for. const FUSE_BAND: usize = 16; let seams: Vec = (0..w * h).map(|i| div[i * ch..(i + 1) * ch].iter().any(|v| *v != 0.0)).collect(); - let band = crate::remove::dilate(w, h, &seams, FUSE_BAND); + let band = crate::square_dilate::dilate_square(w, h, &seams, FUSE_BAND); let halves: [Vec; 2] = [0, 1].map(|colour| copied.iter().map(|(p, _, _)| *p).filter(|&p| band[p] && (p % w + p / w) % 2 == colour).collect()); let mut delta = vec![0.0f32; w * h * ch]; let mut next: Vec = Vec::new(); @@ -846,15 +846,7 @@ pub fn complete_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], return Ok(None); } let ri = r as i32; - let mut targets = vec![false; lw * lh]; - for (i, _) in level.hole.iter().enumerate().filter(|(_, m)| **m) { - let (x, y) = ((i % lw) as i32, (i / lw) as i32); - for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) { - for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) { - targets[ty as usize * lw + tx as usize] = true; - } - } - } + let targets = crate::square_dilate::dilate_square(lw, lh, &level.hole, r); let mut s = Solver { w: lw, h: lh, cha, ch, r: ri, img: level.img.clone(), hole: &level.hole, valid, valid_list, targets, ctl }; let mut nnf = vec![(0i32, 0i32); lw * lh]; let mut cost = vec![f32::INFINITY; lw * lh]; @@ -1012,4 +1004,20 @@ mod tests { let progress = |_: f32| {}; assert_eq!(complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress)), Err(Cancelled)); } + + /// Every cancellation check point stops the completion: moving or dropping one is a change. + #[test] + fn cancelling_at_each_early_check_is_an_error() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (w, h) = (64, 64); + let img: Vec = (0..w * h).map(noise).collect(); + let hole: Vec = (0..w * h).map(|i| (20..40).contains(&(i % w)) && (20..40).contains(&(i / w))).collect(); + for k in 1..=6 { + let calls = AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::Relaxed) + 1 >= k; + let progress = |_: f32| {}; + let got = complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress)); + assert_eq!(got, Err(Cancelled), "cancelling at check {k}"); + } + } } diff --git a/crates/algo/src/remove.rs b/crates/algo/src/remove.rs index 550bda5809..16a9ae3825 100644 --- a/crates/algo/src/remove.rs +++ b/crates/algo/src/remove.rs @@ -54,34 +54,9 @@ pub fn close_loops(w: usize, h: usize, mask: &[bool]) -> Vec { outside.iter().map(|o| !*o).collect() } -/// `mask` grown by `r` cells (a square structuring element), in two separable passes. +/// `mask` grown by `r` cells (a square structuring element, clipped to the grid). pub fn dilate(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { - if r == 0 || mask.len() != w.saturating_mul(h) { - return mask.to_vec(); - } - // A pass sets each cell when any cell within `r` along one axis is set (a running count). - let pass = |src: &[bool], horizontal: bool| -> Vec { - let (lines, len) = if horizontal { (h, w) } else { (w, h) }; - let at = |line: usize, k: usize| if horizontal { line * w + k } else { k * w + line }; - let mut out = vec![false; src.len()]; - for line in 0..lines { - let mut count = 0usize; - for k in 0..len.min(r) { - count += usize::from(src[at(line, k)]); - } - for k in 0..len { - if k + r < len { - count += usize::from(src[at(line, k + r)]); - } - if k > r { - count -= usize::from(src[at(line, k - r - 1)]); - } - out[at(line, k)] = count > 0; - } - } - out - }; - pass(&pass(mask, true), false) + crate::square_dilate::dilate_square(w, h, mask, r) } /// The largest chessboard distance from a hole cell to the nearest cell outside the hole (cells diff --git a/crates/algo/src/square_dilate.rs b/crates/algo/src/square_dilate.rs new file mode 100644 index 0000000000..3781d71d99 --- /dev/null +++ b/crates/algo/src/square_dilate.rs @@ -0,0 +1,337 @@ +//! Grow a boolean mask by a clipped square: shared by the Remove Tool and the non-local completion. + +/// `mask` grown by `r` cells with a square structuring element, clipped to the `w × h` grid: a +/// cell is set when any mask cell lies within chessboard distance `r`. A mask of the wrong size +/// comes back unchanged. +/// +/// Work stays proportional to the mask's extent, not the grid's: a mask cell costs `(2r+1)²` +/// stores directly, which is the cheapest for a small or sparse mask; a bulky one is grown by two +/// running-count passes over its own expanded bounding box instead. +pub(crate) fn dilate_square(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { + if r == 0 || mask.len() != w.saturating_mul(h) { + return mask.to_vec(); + } + let Some(b) = bounds(w, mask) else { + return vec![false; mask.len()]; + }; + let mut out = vec![false; mask.len()]; + match choose(w, h, &b, r) { + Path::Direct => direct(w, h, mask, r, &b, &mut out), + Path::Separable => { + separable(w, h, mask, r, &b, &mut out); + } + } + out +} + +/// The tight box around the set cells (inclusive corners) and how many there are. A mask of at +/// most `KEEP` set cells also keeps their indices (`cells.len() == count`), so the direct path +/// needn't scan the grid a second time. +struct Bounds { + x0: usize, + y0: usize, + x1: usize, + y1: usize, + count: usize, + cells: Vec, +} + +/// Most set cells whose indices `bounds` keeps. +const KEEP: usize = 1 << 12; + +#[derive(Debug, PartialEq, Eq)] +enum Path { + Direct, + Separable, +} + +/// `None` when no cell is set. `mask` has `w` columns. +fn bounds(w: usize, mask: &[bool]) -> Option { + let mut b = Bounds { x0: usize::MAX, y0: usize::MAX, x1: 0, y1: 0, count: 0, cells: Vec::new() }; + // Most cells are unset: skip 64 at a time with a vectorised test before looking at any. + for (c, chunk) in mask.chunks(64).enumerate().filter(|(_, chunk)| chunk.iter().any(|m| *m)) { + for (j, _) in chunk.iter().enumerate().filter(|(_, m)| **m) { + let i = c * 64 + j; + let (x, y) = (i % w, i / w); + // Row-major order: `y` never decreases. + (b.x0, b.y0, b.x1, b.y1, b.count) = (b.x0.min(x), b.y0.min(y), b.x1.max(x), y, b.count + 1); + if b.count <= KEEP { + b.cells.push(i); + } else if !b.cells.is_empty() { + b.cells = Vec::new(); + } + } + } + (b.count > 0).then_some(b) +} + +/// The box around the set cells grown by `r` and clipped to the grid: `(x0, x1, y0, y1)`, inclusive. +fn expanded(w: usize, h: usize, b: &Bounds, r: usize) -> (usize, usize, usize, usize) { + (b.x0.saturating_sub(r), b.x1.saturating_add(r).min(w - 1), b.y0.saturating_sub(r), b.y1.saturating_add(r).min(h - 1)) +} + +/// Cells of scratch the separable path allocates: the rows that can hold set cells, across the +/// expanded box. Never more than the expanded box's area. +#[cfg(test)] +fn scratch_cells(w: usize, h: usize, b: &Bounds, r: usize) -> usize { + let (ex0, ex1, _, _) = expanded(w, h, b, r); + (b.y1 - b.y0 + 1).saturating_mul(ex1 - ex0 + 1) +} + +/// The cheaper way to grow the mask: the direct squares when they cost no more than two passes +/// over the expanded box. +fn choose(w: usize, h: usize, b: &Bounds, r: usize) -> Path { + let side = r.saturating_mul(2).saturating_add(1); + let direct = b.count.saturating_mul(side.saturating_mul(side)); + let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r); + let separable = (ex1 - ex0 + 1).saturating_mul(ey1 - ey0 + 1).saturating_mul(2); + if direct <= separable { Path::Direct } else { Path::Separable } +} + +/// Set the clipped `(2r+1)²` square around every set cell. +fn direct(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) { + let mut stamp = |x: usize, y: usize| { + let (sx, ex) = (x.saturating_sub(r), x.saturating_add(r).min(w - 1)); + for ty in y.saturating_sub(r)..=y.saturating_add(r).min(h - 1) { + out[ty * w + sx..=ty * w + ex].fill(true); + } + }; + if b.cells.len() == b.count { + b.cells.iter().for_each(|i| stamp(i % w, i / w)); + return; + } + for y in b.y0..=b.y1 { + for (dx, _) in mask[y * w + b.x0..=y * w + b.x1].iter().enumerate().filter(|(_, m)| **m) { + stamp(b.x0 + dx, y); + } + } +} + +/// Two running-count passes (a cell is set when any cell within `r` along one axis is set), +/// confined to the expanded box. Returns the scratch cells it allocated. +fn separable(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) -> usize { + let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r); + let (bw, eh, rows) = (ex1 - ex0 + 1, ey1 - ey0 + 1, b.y1 - b.y0 + 1); + // Horizontal: every set cell lies in `x0..=x1`, so clipping a row's window at the box instead + // of the grid only drops cells that are unset anyway. Only the rows that hold set cells. + let mut horiz = vec![false; rows * bw]; + for ry in 0..rows { + let src = &mask[(b.y0 + ry) * w + ex0..=(b.y0 + ry) * w + ex1]; + let dst = &mut horiz[ry * bw..(ry + 1) * bw]; + let mut count = 0usize; + for &s in &src[..bw.min(r)] { + count += usize::from(s); + } + for (k, d) in dst.iter_mut().enumerate() { + count += src.get(k.saturating_add(r)).map_or(0, |s| usize::from(*s)); + count -= k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(|j| src.get(j)).map_or(0, |s| usize::from(*s)); + *d = count > 0; + } + } + // Vertical, row-major: the expanded box's rows form a sequence of length `eh` whose element + // `k` is the scratch row of output row `ey0 + k`, or all unset above and below the set rows. + let row = |k: usize| if k < eh { (ey0 + k).checked_sub(b.y0).filter(|j| *j < rows).map(|j| &horiz[j * bw..(j + 1) * bw]) } else { None }; + let mut counts = vec![0usize; bw]; + for k in 0..eh.min(r) { + if let Some(src) = row(k) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s)); + } + } + for k in 0..eh { + if let Some(src) = row(k.saturating_add(r)) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s)); + } + if let Some(src) = k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(row) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c -= usize::from(*s)); + } + let dst = &mut out[(ey0 + k) * w + ex0..(ey0 + k) * w + ex1 + 1]; + dst.iter_mut().zip(&counts).for_each(|(d, c)| *d = *c > 0); + } + horiz.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The loop the non-local completion used before this module: the oracle. + fn reference(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { + let ri = r as i32; + let mut targets = vec![false; w * h]; + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let (x, y) = ((i % w) as i32, (i / w) as i32); + for ty in (y - ri).max(0)..=(y + ri).min(h as i32 - 1) { + for tx in (x - ri).max(0)..=(x + ri).min(w as i32 - 1) { + targets[ty as usize * w + tx as usize] = true; + } + } + } + targets + } + + fn forced(w: usize, h: usize, mask: &[bool], r: usize, path: &Path) -> Vec { + let mut out = vec![false; w * h]; + if let Some(b) = bounds(w, mask) { + match path { + Path::Direct => direct(w, h, mask, r, &b, &mut out), + Path::Separable => { + separable(w, h, mask, r, &b, &mut out); + } + } + } + out + } + + /// `dilate_square` and both paths, forced, against the oracle. + fn check(w: usize, h: usize, mask: &[bool], r: usize, what: &str) { + let want = reference(w, h, mask, r); + assert!(dilate_square(w, h, mask, r) == want, "{what}: dilate_square {w}x{h} r={r}"); + assert!(forced(w, h, mask, r, &Path::Direct) == want, "{what}: direct {w}x{h} r={r}"); + assert!(forced(w, h, mask, r, &Path::Separable) == want, "{what}: separable {w}x{h} r={r}"); + } + + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn mask(&mut self, n: usize, per_mille: u64) -> Vec { + (0..n).map(|_| self.next() % 1000 < per_mille).collect() + } + } + + #[test] + fn every_mask_on_small_grids_matches_the_oracle() { + for w in 1..=3usize { + for h in 1..=3usize { + for bits in 0..1u32 << (w * h) { + let mask: Vec = (0..w * h).map(|i| bits >> i & 1 == 1).collect(); + for r in [1, 2, 3, 5] { + check(w, h, &mask, r, "exhaustive"); + } + } + } + } + } + + fn cases(w: usize, h: usize) -> Vec<(&'static str, Vec)> { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ (w * 31 + h) as u64); + let at = |x: usize, y: usize| y * w + x; + let single = |x: usize, y: usize| { + let mut m = vec![false; w * h]; + m[at(x, y)] = true; + m + }; + let (cx, cy, mx, my) = (w / 2, h / 2, w - 1, h - 1); + let mut two = single(0, 0); + two[at(mx, my)] = true; + let blob = |x0: usize, y0: usize| -> Vec { + (0..w * h).map(|i| (x0..x0 + 3.min(w - x0)).contains(&(i % w)) && (y0..y0 + 3.min(h - y0)).contains(&(i / w))).collect() + }; + let mut corners = blob(0, 0); + corners.iter_mut().zip(blob(w.saturating_sub(3), h.saturating_sub(3))).for_each(|(a, b)| *a |= b); + vec![ + ("empty", vec![false; w * h]), + ("centre", single(cx, cy)), + ("top-left", single(0, 0)), + ("top-right", single(mx, 0)), + ("bottom-left", single(0, my)), + ("bottom-right", single(mx, my)), + ("top edge", single(cx, 0)), + ("bottom edge", single(cx, my)), + ("left edge", single(0, cy)), + ("right edge", single(mx, cy)), + ("sparse", rng.mask(w * h, 5)), + ("dense", rng.mask(w * h, 700)), + ("full", vec![true; w * h]), + ("two corners", two), + ("two blobs", corners), + ("edge blob", blob(0, cy.saturating_sub(1))), + ("horizontal line", (0..w * h).map(|i| i / w == cy).collect()), + ("vertical line", (0..w * h).map(|i| i % w == cx).collect()), + ] + } + + #[test] + fn named_cases_match_the_oracle() { + for (w, h) in [(64, 48), (1, 97), (97, 1), (7, 3)] { + for (what, mask) in cases(w, h) { + for r in [1, 2, 3, 5, 16, 200] { + check(w, h, &mask, r, what); + } + } + } + } + + /// More set cells than `bounds` keeps (`KEEP`): it drops its cell list, and the direct path + /// rescans the box instead. + #[test] + fn a_mask_with_more_cells_than_are_kept_matches_the_oracle() { + let (w, h) = (200, 200); + let mask = Rng(0x2545_f491_4f6c_dd1d).mask(w * h, 130); + let Some(b) = bounds(w, &mask) else { panic!("cells are set") }; + assert!(b.count > KEEP && b.cells.is_empty(), "{} cells, {} kept", b.count, b.cells.len()); + check(w, h, &mask, 1, "more than KEEP"); + } + + #[test] + fn the_remove_tool_dilate_is_unchanged() { + for (w, h) in [(64, 48), (1, 97), (7, 3)] { + for (what, mask) in cases(w, h) { + for r in [1, 3, 16] { + assert!(crate::remove::dilate(w, h, &mask, r) == reference(w, h, &mask, r), "{what} {w}x{h} r={r}"); + } + assert!(crate::remove::dilate(w, h, &mask, 0) == mask, "{what}: r = 0"); + } + } + assert_eq!(dilate_square(4, 4, &[true; 5], 2), vec![true; 5], "a mask of the wrong size"); + assert_eq!(dilate_square(0, 0, &[], 2), Vec::::new()); + } + + #[test] + fn a_small_or_sparse_mask_takes_the_direct_path_however_big_the_grid() { + let (w, h) = (600, 400); + let mut one = vec![false; w * h]; + one[200 * w + 300] = true; + let Some(b) = bounds(w, &one) else { panic!("one cell is set") }; + assert_eq!(choose(w, h, &b, 3), Path::Direct); + let mut two = vec![false; w * h]; + two[0] = true; + two[w * h - 1] = true; + let Some(b) = bounds(w, &two) else { panic!("two cells are set") }; + assert_eq!((b.x0, b.y0, b.x1, b.y1), (0, 0, w - 1, h - 1), "the box is the whole grid"); + assert_eq!(choose(w, h, &b, 3), Path::Direct); + } + + #[test] + fn a_bulky_mask_takes_the_separable_path_with_bounded_scratch() { + let (w, h) = (240, 240); + let mut rng = Rng(7); + let mut mask = vec![false; w * h]; + for y in 20..220 { + for x in 20..220 { + mask[y * w + x] = rng.next().is_multiple_of(2); + } + } + let Some(b) = bounds(w, &mask) else { panic!("the patch has set cells") }; + assert_eq!(choose(w, h, &b, 3), Path::Separable); + let (ex0, ex1, ey0, ey1) = expanded(w, h, &b, 3); + let scratch = scratch_cells(w, h, &b, 3); + assert!(scratch <= (ex1 - ex0 + 1) * (ey1 - ey0 + 1), "scratch stays inside the expanded box"); + assert!(scratch < w * h, "and is smaller than the grid when the mask is inside it"); + let mut out = vec![false; w * h]; + assert_eq!(separable(w, h, &mask, 3, &b, &mut out), scratch); + } + + #[test] + fn huge_radii_do_not_overflow() { + let mask = [false, true, false, false]; + assert_eq!(dilate_square(2, 2, &mask, usize::MAX), vec![true; 4]); + assert_eq!(forced(2, 2, &mask, usize::MAX, &Path::Separable), vec![true; 4]); + } +} From a3c0b7258c16b312b51bca82904be413bbafda8f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 02:34:32 +0530 Subject: [PATCH 53/63] Select Subject: keep parts that run off the image edge and detached parts near the subject (#10160) * Select Subject: keep parts that run off the image edge and detached parts near the subject The backdrop pass trained its colour model on the whole frame outside the first result's box and fixed it as background, so a subject cut off by the edge (a portrait's torso) taught the backdrop its own colour and was dropped. Frame pixels of a side that the rest of the frame cannot explain are now left out of the model and left to the cut; a floor or table running up the sides stays backdrop. The first pass also dropped every part under a fifth of the largest, losing a head behind a thin neck or a tail. Non-speck parts next to the main one are kept. * Select Subject: stray edge pixels stay backdrop; cache test checks the recompute A lone odd pixel in the frame (noise, a dead pixel) is not the subject running out of the picture: only patches of at least half a frame square are released. The #2865 cache test asserted a bit-identical selection after painting a corner pixel. One changed pixel can move the cut's edge slightly (the original algorithm already did on other noise), so it now checks that the mask was recomputed and still selects the subject. * Test subject selection against edge and nearby clutter * Keep narrow edge clutter out of subject selection * Preserve cropped subject components at image edges * Keep edge poles and diagonal streaks out of subject selection A detached part counted as near the subject when its bounding box overlapped the main part's margin box. A thin diagonal streak from a corner (a cable, a rail) has a box that reaches the margin without any of its pixels doing so, and was kept as part of the subject. A part is now near only when some of its pixels lie inside the margin box. A patch that runs along an image edge for two frame widths was released from the backdrop model whatever its colour, so a pole or door frame along a side edge next to the subject's shoulder was left to the cut, which could grow the selection along it. A wide patch is now released only where its extent along the edge overlaps the first-pass blob's margin box. Beside the subject that box is cut out of the frame, so the pole only remains above or below it and is not released, while a torso cut off below a head (the case the release exists for) still lies under the head's box. Requiring the subject's colour for wide patches too, or requiring them to touch the box, would drop exactly that torso: its colour is not in the head-only blob and it is far from the head's box. The margin box computation moves into margin_box so subject_at_edge and its tests share it. Tests pin the diagonal streak, the pole beside the shoulder (on all four edges, with the cropped torso still released) and clutter that is too far or too small. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/segment/subject.rs | 400 ++++++++++++++++-- crates/algo/src/segment/tests.rs | 142 +++++++ .../engine/src/cmd/smartselect_cmds/tests.rs | 7 +- 3 files changed, 520 insertions(+), 29 deletions(-) diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs index 75a1a47a56..8db2f1517d 100644 --- a/crates/algo/src/segment/subject.rs +++ b/crates/algo/src/segment/subject.rs @@ -13,20 +13,25 @@ //! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the //! lightly blurred CIELAB image from its mean colour. //! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels -//! are definite background); the result is cleaned (small islands dropped). +//! are definite background); the result is cleaned: small islands are dropped unless they lie +//! next to the main part (a head cut off by a neck too thin for the working resolution). //! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted //! to the image's frame marks every pixel it explains as probable background, and the rest //! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground. //! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white //! wall) have low saliency and in step 4 land in the background model, which then explains -//! them away; here they start as foreground and the cut keeps them (#2788). Only what -//! touches the first pass's result survives, so the pass cannot pull in unrelated things. +//! them away; here they start as foreground and the cut keeps them (#2788). Only components +//! touching the first pass's result survive, but attached clutter can survive with them. +//! Where the subject runs out of the picture (a torso cut off by the bottom edge), the frame +//! pixels the rest of the frame cannot explain are left out of the backdrop model and left to +//! the cut; otherwise they would teach it the shirt's colour and be fixed as background. //! 6. Holes are filled only when their colour fits the foreground model better than the //! background's, so a patch of backdrop enclosed by the subject (between an arm and the //! torso) stays open; specks still fill. Then the mask is re-cut at full resolution. //! //! Works well for a distinct object on a comparatively plain background; it is not a semantic -//! segmenter. +//! segmenter. Nearby clutter and detached subject parts with the same geometry cannot be +//! distinguished by the component filter. use std::cmp::Ordering; use std::collections::BinaryHeap; @@ -82,7 +87,7 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) } let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) }; let low: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect(); - let low = super::clean_mask(&low, w, h, 0.2, 0.0); + let low = keep_main_and_nearby(&low, w, h); if !low.iter().any(|v| *v) { return Ok(None); } @@ -94,39 +99,27 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) /// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model /// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the -/// subject runs out of the picture); the frame is definite background, every other pixel it +/// subject runs out of the picture, and minus what [`subject_at_edge`] finds of the subject +/// there); the rest of the frame is definite background, every other pixel it /// explains well is probable background, and the rest (the blob, and anything the backdrop /// cannot account for, such as a cream shirt on a near-white wall) starts as probable /// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that /// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass. fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result, Gmm, Gmm)>, Cancelled> { let (w, h) = (img.w, img.h); - let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); - for (i, on) in blob.iter().enumerate() { - if *on { - let (x, y) = (i % w, i / w); - (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); - } - } - if x1 < x0 || y1 < y0 { - return Ok(None); - } - let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); - let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin)); - let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1)); + let Some(near) = margin_box(blob, w, h) else { return Ok(None) }; + let (bx0, by0, bx1, by1) = near; let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2); let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h; let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y); - let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect(); - let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) }; - // What the frame itself costs under its own model bounds "explained well". - let mut frame_costs: Vec = samples.iter().map(|p| backdrop.neg_log(*p)).collect(); - frame_costs.sort_by(|a, b| a.total_cmp(b)); - let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) }; + let edge = |i: usize| in_frame(i % w, i / w) && !near_blob(i % w, i / w); + // The subject running out of the picture is neither backdrop to learn nor fixed background. + let released = subject_at_edge(img, blob, frame, near, &edge); + let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !released[*i]).map(|i| img.px[i]).collect(); + let Some((backdrop, limit)) = fit_backdrop(&samples) else { return Ok(None) }; let mut trimap: Vec = (0..w * h) .map(|i| { - let (x, y) = (i % w, i / w); - if in_frame(x, y) && !near_blob(x, y) { + if edge(i) && !released[i] { BG } else if blob[i] || backdrop.neg_log(img.px[i]) > limit { PR_FG @@ -141,6 +134,359 @@ fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Resul Ok(low.iter().any(|v| *v).then_some((low, fg, bg))) } +/// The box around the pixels `blob` marks in a `w` × `h` image, grown on every side by 15% of +/// its longer side (at least four pixels) and clipped to the image, as (x0, y0, x1, y1) +/// inclusive. `None` when `blob` marks nothing. +fn margin_box(blob: &[bool], w: usize, h: usize) -> Option<(usize, usize, usize, usize)> { + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, on) in blob.iter().enumerate() { + if *on { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + if x1 < x0 || y1 < y0 { + return None; + } + let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); + Some((x0.saturating_sub(margin), y0.saturating_sub(margin), (x1 + margin).min(w - 1), (y1 + margin).min(h - 1))) +} + +/// The connected parts of `mask` that make up the subject: those at least a fifth the size of +/// the largest, and smaller ones (not specks) with pixels close to it. A part of the subject the +/// cut separated from the rest, such as a head whose thin neck the working resolution lost, a +/// tail or something held, would otherwise go missing; clutter further away is still dropped. +fn keep_main_and_nearby(mask: &[bool], w: usize, h: usize) -> Vec { + let (lab, sizes) = super::components(mask, w, h); + let Some((main, largest)) = sizes.iter().copied().enumerate().skip(1).max_by_key(|(_, n)| *n) else { return vec![false; w * h] }; + // Bounding boxes per component: (x0, y0, x1, y1). + let mut bbox = vec![(usize::MAX, usize::MAX, 0usize, 0usize); sizes.len()]; + for (i, l) in lab.iter().enumerate() { + if let Some(b) = bbox.get_mut(*l as usize) { + let (x, y) = (i % w, i / w); + *b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + let Some(&(mx0, my0, mx1, my1)) = bbox.get(main) else { return vec![false; w * h] }; + let margin = ((mx1 - mx0 + 1).max(my1 - my0 + 1) as f32 * 0.15).ceil() as usize; + let (nx0, ny0, nx1, ny1) = (mx0.saturating_sub(margin), my0.saturating_sub(margin), mx1 + margin, my1 + margin); + // A part is near when some of its pixels lie within the margin, not merely its box: a thin + // diagonal streak from a corner (a cable, a rail) can have a box that reaches the margin. + let mut near = vec![false; sizes.len()]; + for y in ny0..=ny1.min(h - 1) { + for x in nx0..=nx1.min(w - 1) { + let label = lab.get(y * w + x).copied().unwrap_or(0) as usize; + if let Some(n) = near.get_mut(label) { + *n = true; + } + } + } + let (big, speck) = ((largest as f32 * 0.2).ceil() as usize, ((largest as f32 * 0.005).ceil() as usize).max(4)); + // Only small, nearby components can be vetoed as edge clutter. A strip must be + // narrow relative to the canvas and at least twice as long as it is wide. + let keep: Vec = sizes + .iter() + .zip(&bbox) + .enumerate() + .map(|(label, (&size, &(x0, y0, x1, y1)))| { + if label == 0 { + return false; + } + if label == main || size >= big { + return true; + } + let (width, height) = (x1 - x0 + 1, y1 - y0 + 1); + let nearby = near.get(label).copied().unwrap_or(false); + let horizontal_strip = (x0 == 0 || x1 + 1 == w) && height < h * 12 / 100 && width >= height * 2; + let vertical_strip = (y0 == 0 || y1 + 1 == h) && width < w * 12 / 100 && height >= width * 2; + let thin_edge = horizontal_strip || vertical_strip; + size >= speck && nearby && !thin_edge + }) + .collect(); + lab.iter().map(|l| keep.get(*l as usize).copied().unwrap_or(false)).collect() +} + +#[cfg(test)] +mod component_tests { + use super::{keep_main_and_nearby, margin_box, subject_at_edge}; + use crate::segment::RgbImage; + + #[test] + fn nearby_component_margin_and_size_boundaries() { + // The main 20x20 component has margin ceil(20 * .15) = 3 and a four-pixel floor. + // A 30x30 component raises the floor to ceil(900 * .005) = 5. + for (side, x, count, retained) in [(20, 42, 3, false), (20, 42, 4, true), (20, 43, 4, false), (30, 54, 4, false), (30, 54, 5, true), (30, 55, 5, false)] + { + let (w, h) = (80, 80); + let mut mask = vec![false; w * h]; + for y in 20..20 + side { + for x in 20..20 + side { + mask[y * w + x] = true; + } + } + for y in 25..25 + count { + mask[y * w + x] = true; + } + let result = keep_main_and_nearby(&mask, w, h); + assert!(result[20 * w + 20]); + assert_eq!(result[25 * w + x], retained, "side {side}, x {x}, size {count}"); + } + // A qualifying nearby island is kept whether it is a head or unrelated clutter. + } + + fn rotate(x: usize, y: usize, side: usize, turns: usize) -> (usize, usize) { + let (mut x, mut y) = (x, y); + for _ in 0..turns { + (x, y) = (side - 1 - y, x); + } + (x, y) + } + + #[test] + fn cropped_heads_and_large_limbs_survive_on_every_edge() { + let side = 240; + for turns in 0..4 { + for (main, part) in [((100, 20, 140, 70), (111, 0, 129, 18)), ((80, 70, 120, 120), (122, 90, 240, 100))] { + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [main, part] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + let (x, y) = rotate(part.0, part.1, side, turns); + assert!(result[y * side + x], "edge {turns}, part {part:?}"); + } + } + } + + #[test] + fn small_nearby_elongated_edge_strips_are_excluded() { + let side = 240; + for turns in 0..4 { + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [(70, 80, 170, 180), (112, 0, 120, 70)] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + let (x, y) = rotate(112, 0, side, turns); + assert!(!result[y * side + x], "edge {turns}"); + } + } + + #[test] + fn narrow_edge_parts_matching_subject_are_released_on_every_edge() { + let side = 240; + let frame = 12; + for turns in 0..4 { + for (colour, expected) in [([0.8, 0.2, 0.1], true), ([0.1, 0.2, 0.8], false)] { + let mut img = RgbImage::new(side, side); + img.px.fill([0.5, 0.5, 0.5]); + let mut blob = vec![false; side * side]; + for y in 60..100 { + for x in 100..140 { + let (x, y) = rotate(x, y, side, turns); + blob[y * side + x] = true; + img.px[y * side + x] = [0.8, 0.2, 0.1]; + } + } + for y in 0..frame { + for x in 110..120 { + let (x, y) = rotate(x, y, side, turns); + img.px[y * side + x] = colour; + } + } + let edge = |i: usize| { + let (x, y) = (i % side, i / side); + x < frame || y < frame || x + frame >= side || y + frame >= side + }; + let near = margin_box(&blob, side, side).unwrap(); + let released = subject_at_edge(&img, &blob, frame, near, &edge); + let (x, y) = rotate(110, 0, side, turns); + assert_eq!(released[y * side + x], expected, "edge {turns}, colour {colour:?}"); + } + } + } + + #[test] + fn wide_edge_parts_are_released_only_alongside_the_subject() { + // A dark pole along the left edge, next to the shoulder of a figure that runs off the + // bottom edge, stays backdrop although it runs a long way along that edge. A cream torso + // cut off by the bottom edge below a head (the first pass lost it, so the subject's + // colours do not explain it either) is still released. + let side = 240; + let frame = 12; + // (subject, part, part colour, released), boxes as (x0, y0, x1, y1) exclusive. + let cases = [((10, 100, 110, 240), (0, 0, 8, 240), [0.1, 0.1, 0.1], false), ((100, 40, 140, 80), (60, 228, 180, 240), [0.9, 0.85, 0.7], true)]; + for turns in 0..4 { + for (subject, part, colour, expected) in cases { + let mut img = RgbImage::new(side, side); + img.px.fill([0.5, 0.5, 0.5]); + let mut blob = vec![false; side * side]; + for (x0, y0, x1, y1) in [subject, part] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + let on = (x0, y0) == (subject.0, subject.1); + blob[y * side + x] = on; + img.px[y * side + x] = if on { [0.8, 0.2, 0.1] } else { colour }; + } + } + } + // As in `backdrop_pass`: the frame, minus the blob's box. + let (bx0, by0, bx1, by1) = margin_box(&blob, side, side).unwrap(); + let edge = |i: usize| { + let (x, y) = (i % side, i / side); + let in_frame = x < frame || y < frame || x + frame >= side || y + frame >= side; + in_frame && !((bx0..=bx1).contains(&x) && (by0..=by1).contains(&y)) + }; + let released = subject_at_edge(&img, &blob, frame, (bx0, by0, bx1, by1), &edge); + let (x, y) = rotate(part.0, part.1, side, turns); + assert_eq!(released[y * side + x], expected, "edge {turns}, part {part:?}"); + } + } + } + + #[test] + fn nearby_means_pixels_within_the_margin_not_just_the_box() { + // The main part covers 100..140 × 20..60, so its margin box is 94..=145 × 14..=65. A + // one-pixel diagonal streak from the corner (a cable, a rail) to (100, 100) has a box + // that overlaps the margin box, but none of its pixels lie in it: it is not kept. A + // compact part whose pixels lie inside the margin is. + let side = 240; + let mut mask = vec![false; side * side]; + for y in 20..60 { + for x in 100..140 { + mask[y * side + x] = true; + } + } + for k in 0..=100 { + mask[k * side + k] = true; + } + for y in 30..35 { + for x in 142..146 { + mask[y * side + x] = true; + } + } + let result = keep_main_and_nearby(&mask, side, side); + assert!(result[20 * side + 100]); + assert!(!result[0], "the corner of the streak is kept"); + assert!(!result[100 * side + 100], "the end of the streak is kept"); + assert!(result[30 * side + 142], "the compact part inside the margin is dropped"); + } + + #[test] + fn clutter_away_from_the_subject_or_too_small_is_dropped() { + // The main 40x40 part has margin ceil(40 * .15) = 6 and a speck floor of + // ceil(1600 * .005) = 8 pixels. A 5x5 object just past the margin and a 2x3 one inside + // it are clutter; a 3x3 one inside it is kept. + let side = 240; + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [(100, 100, 140, 140), (147, 110, 152, 115), (110, 143, 112, 146), (120, 143, 123, 146)] { + for y in y0..y1 { + for x in x0..x1 { + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + assert!(result[100 * side + 100]); + assert!(!result[110 * side + 147], "clutter past the margin is kept"); + assert!(!result[143 * side + 110], "a speck inside the margin is kept"); + assert!(result[143 * side + 120], "a part inside the margin is dropped"); + } +} + +/// A colour model of `samples` and the cost above which a pixel is not explained by it: what +/// the samples themselves cost under it (99th percentile), plus a margin. `None` without samples. +fn fit_backdrop(samples: &[[f32; 3]]) -> Option<(Gmm, f32)> { + let model = Gmm::fit(&super::subsample(samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG)?; + let mut costs: Vec = samples.iter().map(|p| model.neg_log(*p)).collect(); + costs.sort_by(|a, b| a.total_cmp(b)); + let limit = costs.get(costs.len() * 99 / 100)? + 4.0; + Some((model, limit)) +} + +/// The pixels of the frame (those `edge` accepts, `frame` wide) that belong to the subject +/// running out of the picture, such as a torso cut off by the bottom edge: pixels of a side +/// (its corners included) that the colours of the frame off that side cannot explain. Without +/// this, such a part trains the backdrop model and is fixed as background, so a cream shirt on a +/// near-white wall is lost. A floor or a table runs up the sides too, so the frame off the +/// bottom knows its colour and it stays backdrop; only a band confined to one side's strip is +/// released (and is then left to the cut). Small patches stay backdrop. Narrow patches are +/// released only if their colour is also explained by the retained subject; wide ones whatever +/// their colour, but only where they run alongside `near` (the blob's box with its margin, as +/// [`margin_box`] gives it): a pole or a door frame along a side next to the subject's shoulder +/// stays backdrop, while a torso the first pass lost still lies below the head. +fn subject_at_edge(img: &super::RgbImage, blob: &[bool], frame: usize, near: (usize, usize, usize, usize), edge: &dyn Fn(usize) -> bool) -> Vec { + let (w, h) = (img.w, img.h); + // Top, bottom, left, right. + let on = |i: usize, s: usize| { + let (x, y) = (i % w, i / w); + [y < frame, y + frame >= h, x < frame, x + frame >= w].get(s).copied().unwrap_or(false) + }; + let subject_samples: Vec<[f32; 3]> = img.px.iter().zip(blob).filter_map(|(pixel, on)| on.then_some(*pixel)).collect(); + let subject_model = fit_backdrop(&subject_samples); + let mut odd = vec![false; w * h]; + for s in 0..4 { + let others: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !on(*i, s)).map(|i| img.px[i]).collect(); + let Some((model, limit)) = fit_backdrop(&others) else { continue }; + for (i, o) in odd.iter_mut().enumerate() { + if !*o && edge(i) && on(i, s) && model.neg_log(img.px[i]) > limit { + *o = true; + } + } + } + // A stray pixel (noise, a dead pixel) is not a subject: only patches of some size count. + let (lab, sizes) = super::components(&odd, w, h); + let min = (frame * frame / 2).max(4); + let mut bounds = vec![(w, h, 0usize, 0usize); sizes.len()]; + for (i, &label) in lab.iter().enumerate() { + if label != 0 + && let Some(b) = bounds.get_mut(label as usize) + { + let (x, y) = (i % w, i / w); + *b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + // Count matching pixels per component so a single coincidental pixel cannot release it. + let mut matching = vec![0usize; sizes.len()]; + if let Some((model, limit)) = subject_model { + for (i, &label) in lab.iter().enumerate() { + if label != 0 + && model.neg_log(img.px[i]) <= limit + && let Some(count) = matching.get_mut(label as usize) + { + *count += 1; + } + } + } + lab.iter() + .map(|l| { + let i = *l as usize; + let Some((&size, &(x0, y0, x1, y1))) = sizes.get(i).zip(bounds.get(i)) else { return false }; + if *l == 0 || size < min { + return false; + } + // Along the edge, a wide patch must overlap the blob's box: beside the subject, `edge` + // leaves out that box, so a pole along a side shows up only above or below it. + let (nx0, ny0, nx1, ny1) = near; + let wide_on_horizontal_edge = (y0 == 0 || y1 + 1 == h) && x1 - x0 + 1 >= 2 * frame && x0 <= nx1 && x1 >= nx0; + let wide_on_vertical_edge = (x0 == 0 || x1 + 1 == w) && y1 - y0 + 1 >= 2 * frame && y0 <= ny1 && y1 >= ny0; + let matches_subject = matching.get(i).is_some_and(|count| *count >= size.div_ceil(2)); + wide_on_horizontal_edge || wide_on_vertical_edge || matches_subject + }) + .collect() +} + /// Per-pixel saliency in `0..=1` (see the module docs). pub fn saliency(img: &super::RgbImage) -> Vec { let (w, h) = (img.w, img.h); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 07f52b815b..6a36bb53cd 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -962,6 +962,148 @@ fn select_subject_leaves_a_background_coloured_gap_open() { assert!(v > 0.9, "IoU {v}"); } +/// A portrait on a light wall shading down the picture: dark hair, a face, a neck and a +/// torso of colour `shirt` running off the bottom edge (as most portraits are cropped). With +/// `floor`, the torso ends on a dark table that fills the bottom quarter, the full width (so it +/// runs up the picture's sides too). Returns (image, truth, the torso). +fn portrait_scene(seed: u64, shirt: [f32; 3], floor: bool) -> (RgbImage, Vec, Vec) { + let (w, h) = (240usize, 200usize); + let mut rng = Rng::new(seed); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut torso = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32, y as f32); + let hair = ((fx - 120.0) / 30.0).powi(2) + ((fy - 52.0) / 27.0).powi(2) <= 1.0; + let face = ((fx - 120.0) / 26.0).powi(2) + ((fy - 66.0) / 32.0).powi(2) <= 1.0; + let neck = (109..131).contains(&x) && (90..112).contains(&y); + // Shoulders widening towards the bottom, wider than the head's box. + let half = 40.0 + (fy - 110.0) * 0.8; + let body = y >= 110 && (fx - 120.0).abs() <= half && (!floor || y < 150); + let table = floor && y >= 150; + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = hair || face || neck || body; + torso[i] = body && !neck; + let wall = 0.95 - 0.07 * fy / h as f32; + img.px[i] = if table { + [0.35, 0.22, 0.12] + } else if face { + [0.88, 0.71, 0.59] + } else if hair { + [0.24, 0.16, 0.12] + } else if neck { + [0.84, 0.67, 0.55] + } else if body { + shirt + } else { + [wall, wall - 0.01, wall - 0.04] + } + .map(|v| (v + n).clamp(0.0, 1.0)); + } + } + (img, truth, torso) +} + +#[test] +fn select_subject_keeps_a_low_contrast_part_that_runs_off_the_edge() { + // A cream shirt on a near-white wall, cut off by the bottom edge: it used to train the + // backdrop model and be fixed as background there, so only the head was selected. + let (img, truth, torso) = portrait_scene(3, [0.92, 0.85, 0.72], false); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let c = coverage(®, &torso, 240, 200); + assert!(c > 0.9, "the torso is part of the subject: covered {c}"); + let v = iou(®, &truth, 240, 200); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_table_along_the_bottom_edge_out() { + // The same figure behind a table that fills the bottom of the picture: the table also runs + // off the edge, but it is not the subject. + let (img, truth, _) = portrait_scene(3, [0.12, 0.2, 0.45], true); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let table: Vec = (0..240 * 200).map(|i| i / 240 >= 150).collect(); + let t = coverage(®, &table, 240, 200); + assert!(t < 0.1, "the table stays unselected: covered {t}"); + let v = iou(®, &truth, 240, 200); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_nearby_single_edge_object_out() { + // A horizontal door-frame piece enters only from the left and stops just short of the + // shoulder. It is large enough to be considered by the edge release heuristic. + let (mut img, truth, _) = low_contrast_figure(7); + let (w, h) = (240, 200); + let mut frame = vec![false; w * h]; + for y in 48..56 { + for x in 0..77 { + frame[y * w + x] = true; + img.px[y * w + x] = [0.32, 0.24, 0.16]; + } + } + let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let subject_coverage = coverage(®, &truth, w, h); + let frame_coverage = coverage(®, &frame, w, h); + assert!(subject_coverage > 0.85, "subject coverage {subject_coverage}"); + assert!(frame_coverage < 0.1, "door-frame coverage {frame_coverage}"); +} + +#[test] +fn select_subject_ignores_a_stray_pixel_at_the_edge() { + // One white pixel in the corner (a dead pixel, a speck of dust) is not the subject running + // out of the picture: it stays unselected and the subject is found as without it. + let canvas = Rect::new(0, 0, 200, 160); + for seed in 1..=8 { + let (mut img, truth) = disc_scene(200, 160, 100.0, 80.0, 45.0, seed); + img.px[0] = [1.0, 1.0, 1.0]; + let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, canvas).unwrap(); + assert!(reg.at(0, 0) < 0.5, "seed {seed}: the stray pixel is selected"); + let v = iou(®, &truth, 200, 160); + assert!(v > 0.9, "seed {seed}: IoU {v}"); + } +} + +#[test] +fn select_subject_keeps_a_small_detached_part_next_to_the_subject() { + // A standing figure whose head is apart from its body (the neck too thin to survive the + // working resolution): the head is under a fifth of the body's size but part of the subject. + let (w, h) = (300usize, 200usize); + let mut rng = Rng::new(9); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut head = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32, y as f32); + let is_head = ((fx - 150.0) / 9.0).powi(2) + ((fy - 40.0) / 11.0).powi(2) <= 1.0; + let body = (128..172).contains(&x) && (55..170).contains(&y); + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = is_head || body; + head[i] = is_head; + img.px[i] = if is_head { + [0.88, 0.7, 0.58] + } else if body { + [0.75, 0.15, 0.15] + } else { + [0.55, 0.72, 0.9] + } + .map(|v| (v + n).clamp(0.0, 1.0)); + } + } + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let c = coverage(®, &head, w, h); + assert!(c > 0.9, "the head is part of the subject: covered {c}"); + let v = iou(®, &truth, w, h); + assert!(v > 0.9, "IoU {v}"); +} + #[test] fn antialias_blends_edge_pixels_with_their_neighbourhood() { // A lone selected pixel keeps most of its coverage and its neighbours get half of a ninth; diff --git a/crates/engine/src/cmd/smartselect_cmds/tests.rs b/crates/engine/src/cmd/smartselect_cmds/tests.rs index a50b079456..823e0883b5 100644 --- a/crates/engine/src/cmd/smartselect_cmds/tests.rs +++ b/crates/engine/src/cmd/smartselect_cmds/tests.rs @@ -272,7 +272,7 @@ fn select_subject_samples_the_selected_layer_else_the_composite() { /// after Deselect); sampling something else or a pixel edit computes it again. #[test] fn select_subject_repeat_reuses_the_mask_until_pixels_change() { - let (mut s, _) = disc(8); + let (mut s, inside) = disc(8); let sel = |s: &Session| { let mut v = Vec::new(); s.active().unwrap().doc.selection.as_ref().unwrap().read_region_into(Rect::new(0, 0, 200, 160), &mut v); @@ -298,7 +298,10 @@ fn select_subject_repeat_reuses_the_mask_until_pixels_change() { .unwrap(); assert!(s.subject_cache.region.is_none()); s.execute("select.subject", json!({})).unwrap(); - assert_eq!(sel(&s), first); + // Recomputed on the edited pixels (one changed pixel may move the edge a little). + assert!(!std::sync::Arc::ptr_eq(&mask, &cached(&s))); + let v = sel_iou(&s, 200, 160, inside); + assert!(v > 0.9, "IoU {v}"); } /// A vertical Gaussian-blurred edge at x = 40 (warm left, cool right) with the left half selected. From 6326117c5394826a07afc1e897b9650699499381 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 02:34:59 +0530 Subject: [PATCH 54/63] Web build: big operations that won't fit fail with an error instead of trapping, and a trap says so (#10129) * Raster: ask the allocator whether a big operation fits; PSD save reserves its whole output New photocraft_raster::headroom: ensure(need) tries to reserve the need plus a margin and frees it at once (web only; native always passes), below(bytes) tells whether less than that can be allocated, and simulate_available fakes the answer for tests and diagnostics. PsdFile::to_bytes reserved only the pixel data, so embedded smart-object data and other tagged blocks grew the output by doubling, which on the web is one unrecoverable huge realloc. It now reserves the exact encoded length. * Big operations that won't fit in memory fail with an error instead of trapping Place, Image Size, a smart-object re-render and Save now ask the allocator whether their estimated need can be allocated before they start (photocraft_engine::memory_gate, estimates per operation, photocraft_io::estimate_export_bytes for saves). A refusal changes nothing and says what the operation needed and what to do (save, Edit > Purge > All, close documents). A standing notice warns when less than 512 MiB can be allocated, with hysteresis, so the user can save before anything traps. Notices can now be sticky, which keeps them past the auto-hide delay. * Web build: a trap says so instead of freezing the page, and ?free-mb simulates low memory index.html holds a hidden, static 'PhotoCraft stopped' message with a Reload link. web.rs reveals it when the module traps (the window error event, or an unhandled rejection from a spawned future), tells running out of memory from an internal error by the panic message a chained panic hook kept, and skips the Leave site prompt once stopped. ?free-mb=N makes the memory gates and the low-memory warning see only N MiB, for checking them in a browser. * Docs: web build memory handling in the gaps list and the parity assessment * Engine: use is_multiple_of in the megabyte formatter * Cargo.lock: the web shell depends on photocraft-raster * Web boot page: carry the stopped message too, as the test that keeps the two pages equal requires * Save check counts one copy of the document, and runs once per save The encoded file's buffer is reserved fallibly, so a save it doesn't fit in fails with an error instead of trapping; counting it as well refused saves that would have fitted. On the background-save path the check ran in write_document and again in start_save, which other callers reach directly; it now runs only in start_save there. * Review fixes: warning check allocates nothing, PSD merged image counted, automation saves checked The low-memory warning's periodic check reads the size of linear memory instead of reserving a block: wasm memory never shrinks, so a trial every few seconds raised the high-water mark by up to 768 MiB for nothing. The operation checks keep their trial reservation. PSD and PSB saves always build a canvas-sized merged image (a placeholder without maximum compatibility) and hold it raw and encoded; the save estimate now counts it. The synchronous automation save checks memory before encoding, as File > Save does, and its test asserts the memory error. The trap listeners go in before the app is built, so a trap while building it shows the stopped message too, and a memory access out of bounds reads as unexpected rather than as running out of memory. * Cover layered PSD preflight limitation * Fix layered PSD test iteration * Docs: layered PSD preflight limitation in the gaps list * Web OOM gate: gate direct exports, truthful advice for unbounded PSD sources (#10024) * Web OOM gate: estimate layered PSDs per layer instead of refusing every real PSD The PSD check admitted a file only when its color-mode data, image resources and layer section were all empty. Every Photoshop file has image resources (at least the resolution), so on the web nearly every real PSD was refused for Place, PSD smart objects could not render, and Image Size was refused for documents holding one, whatever the free memory (u64::MAX never fits). The color-mode data and image resources hold no pixels, so they are now only skipped. A layered file reads its layer count from the layer info (signed, absolute value) and adds a canvas-sized surface and mask per layer to the flat estimate. Files that are not 8-bit RGB or grayscale, keep their layers in a Layr tagged block, or whose sections can't be read stay unbounded and refused, as before. Co-Authored-By: Claude Opus 5.5 * Web stopped page: match trap messages ignoring case Safari reports the allocation-failure abort as "Unreachable code should not be executed" and Firefox says "out of memory", which the case-sensitive matching missed: the trap was not recognised, or was shown as an unexpected stop instead of running out of memory. Co-Authored-By: Claude Opus 5.5 * Export estimate: count the merged image OpenRaster writes The ORA writer keeps the layers but also flattens the document for mergedimage.png, a 16 B/px composite of the canvas, which the estimate left out. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/photocraft-web-boot/index.html | 14 + apps/photocraft-web/Cargo.toml | 3 +- apps/photocraft-web/index.html | 14 + apps/photocraft-web/src/diagnostics.rs | 148 ++++++++ apps/photocraft-web/src/main.rs | 25 +- apps/photocraft-web/src/web.rs | 81 +++- crates/engine/src/cmd/file_cmds/open.rs | 2 + crates/engine/src/cmd/image_cmds.rs | 2 + crates/engine/src/cmd/smart_cmds/source.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/memory_gate.rs | 346 ++++++++++++++++++ crates/engine/tests/memory_gate.rs | 261 +++++++++++++ crates/io/src/export_estimate.rs | 118 ++++++ crates/io/src/lib.rs | 2 + crates/psd/tests/it/roundtrip.rs | 18 + crates/raster/src/headroom.rs | 167 +++++++++ crates/raster/src/lib.rs | 1 + crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/files.rs | 12 + crates/ui-egui/src/app/frame.rs | 1 + crates/ui-egui/src/dialogs/export_dialog.rs | 3 + crates/ui-egui/src/jobs_ui.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/memory_warning.rs | 137 +++++++ crates/ui-egui/src/menus/view_cmds/dialogs.rs | 1 + crates/ui-egui/src/notices.rs | 19 +- crates/ui-egui/tests/save_memory_gate.rs | 108 ++++++ docs/development.md | 4 +- docs/gaps.md | 13 + docs/target-app-parity.md | 3 +- 31 files changed, 1503 insertions(+), 9 deletions(-) create mode 100644 apps/photocraft-web/src/diagnostics.rs create mode 100644 crates/engine/src/memory_gate.rs create mode 100644 crates/engine/tests/memory_gate.rs create mode 100644 crates/io/src/export_estimate.rs create mode 100644 crates/raster/src/headroom.rs create mode 100644 crates/ui-egui/src/memory_warning.rs create mode 100644 crates/ui-egui/tests/save_memory_gate.rs diff --git a/Cargo.lock b/Cargo.lock index f2ebdb2198..499f8a4694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5629,6 +5629,7 @@ dependencies = [ "photocraft-io", "photocraft-paint", "photocraft-psd", + "photocraft-raster", "photocraft-ui-egui", "rfd", "serde_json", diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html index 8ed9a6961e..7d15b18daa 100644 --- a/apps/photocraft-web-boot/index.html +++ b/apps/photocraft-web-boot/index.html @@ -16,6 +16,15 @@ this page, no Rust runs to say so, so the page itself shows what to do after a while. */ #photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; } @keyframes photocraft-stalled { to { visibility: visible; } } + /* Revealed by the app when the module traps (an allocation it could not satisfy, or a panic): + the page would otherwise sit on the last frame, frozen. Static text, so it shows even if Rust + can no longer build any. */ + #photocraft_stopped { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 0 16px; text-align: center; color: #e0e0e0; background: rgba(38, 38, 38, 0.92); font: 14px system-ui, sans-serif; pointer-events: auto; } + #photocraft_stopped[hidden] { display: none; } + #photocraft_stopped p { margin: 0 0 8px; max-width: 460px; line-height: 1.5; } + #photocraft_stopped a { color: #ffffff; } + #photocraft_stopped_detail { color: #b8b8b8; } @@ -26,5 +35,10 @@ download or come from a different version of this page. Reload to try again; the browser console lists what failed.

+ diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 0176e0fe84..1811aea283 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -24,6 +24,7 @@ photocraft-io = { workspace = true } photocraft-codecs = { workspace = true } photocraft-doc = { workspace = true } photocraft-ui-egui = { workspace = true } +photocraft-raster = { workspace = true } egui = { workspace = true } log = { workspace = true } @@ -34,7 +35,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator", "ErrorEvent", "PromiseRejectionEvent"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/index.html b/apps/photocraft-web/index.html index d090cf47da..30ea522b66 100644 --- a/apps/photocraft-web/index.html +++ b/apps/photocraft-web/index.html @@ -16,6 +16,15 @@ this page, no Rust runs to say so, so the page itself shows what to do after a while. */ #photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; } @keyframes photocraft-stalled { to { visibility: visible; } } + /* Revealed by the app when the module traps (an allocation it could not satisfy, or a panic): + the page would otherwise sit on the last frame, frozen. Static text, so it shows even if Rust + can no longer build any. */ + #photocraft_stopped { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 0 16px; text-align: center; color: #e0e0e0; background: rgba(38, 38, 38, 0.92); font: 14px system-ui, sans-serif; pointer-events: auto; } + #photocraft_stopped[hidden] { display: none; } + #photocraft_stopped p { margin: 0 0 8px; max-width: 460px; line-height: 1.5; } + #photocraft_stopped a { color: #ffffff; } + #photocraft_stopped_detail { color: #b8b8b8; } @@ -26,5 +35,10 @@ download or come from a different version of this page. Reload to try again; the browser console lists what failed.

+ diff --git a/apps/photocraft-web/src/diagnostics.rs b/apps/photocraft-web/src/diagnostics.rs new file mode 100644 index 0000000000..134e0e4035 --- /dev/null +++ b/apps/photocraft-web/src/diagnostics.rs @@ -0,0 +1,148 @@ +//! What the page says when the module stops, and the `?free-mb` diagnostic flag (#10024). +//! +//! A failed allocation on `wasm32-unknown-unknown` aborts the module: the page keeps the last +//! frame and nothing says why. `web.rs` listens for the trap in the page and shows the static +//! `#photocraft_stopped` element; the decisions that need no browser live here, so native tests +//! cover them. + +use std::sync::{Mutex, PoisonError}; + +/// The message of the last panic, kept for the trap that follows it (a panic aborts the module). +static LAST_PANIC: Mutex = Mutex::new(String::new()); + +/// The most characters of a panic message the page shows. +const MAX_PANIC_CHARS: usize = 300; + +/// Truncates `message` to [`MAX_PANIC_CHARS`] characters, on a character boundary. +pub fn truncate(message: &str) -> String { + match message.char_indices().nth(MAX_PANIC_CHARS) { + Some((end, _)) => format!("{}\u{2026}", message.get(..end).unwrap_or_default()), + None => message.to_owned(), + } +} + +/// Remembers a panic's message (and where it happened) for [`take_panic`]. +pub fn record_panic(message: &str) { + *LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner) = truncate(message); +} + +/// The message [`record_panic`] kept, if any. +pub fn take_panic() -> Option { + let mut last = LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner); + (!last.is_empty()).then(|| std::mem::take(&mut *last)) +} + +/// `true` when an uncaught error, by its `name` and `message`, is the module trapping or the +/// browser refusing it memory, rather than some other script error or failed request. Browsers +/// word and capitalize these differently (Safari: `Unreachable code should not be executed`), so +/// the message is matched ignoring case. +pub fn is_trap(name: &str, message: &str) -> bool { + let message = message.to_ascii_lowercase(); + name == "RuntimeError" + || ["unreachable", "memory access out of bounds", "maximum call stack", "could not allocate memory", "out of memory"] + .iter() + .any(|m| message.contains(m)) +} + +/// Trap messages (lowercase, matched ignoring case) that mean an allocation failed: +/// `unreachable` with no panic before it is the allocation-failure abort (the only one that runs +/// no Rust), the others are the browser refusing to grow the memory. `memory access out of +/// bounds` is not among them: it is a bug. +const OUT_OF_MEMORY: [&str; 3] = ["unreachable", "could not allocate memory", "out of memory"]; + +/// The line under "PhotoCraft stopped": a captured panic means an internal error; a trap with no +/// panic before it is out of memory when its message says so (or says nothing), and otherwise +/// unexpected. +pub fn detail(panic: Option<&str>, trap_message: &str) -> String { + if let Some(p) = panic { + return format!("It hit an internal error: {p}"); + } + let lower = trap_message.to_ascii_lowercase(); + if lower.is_empty() || OUT_OF_MEMORY.iter().any(|m| lower.contains(m)) { + "It ran out of memory.".to_owned() + } else { + format!("It stopped unexpectedly: {}", truncate(trap_message)) + } +} + +/// `?free-mb=N` from a URL query (`?cpu&free-mb=300`): pretend only N MiB can be allocated, so +/// the memory gates and the low-memory warning can be seen without gigabytes of images. +pub fn free_mb(query: &str) -> Option { + query.trim_start_matches('?').split('&').find_map(|pair| pair.strip_prefix("free-mb=")?.parse().ok()) +} + +/// The bytes `free-mb` asks for, without overflowing. +pub fn free_bytes(mb: u64) -> u64 { + mb.saturating_mul(1 << 20) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panic_message_is_shown_as_an_internal_error() { + assert_eq!(detail(Some("index out of bounds"), "unreachable"), "It hit an internal error: index out of bounds"); + } + + #[test] + fn a_trap_with_no_panic_is_an_out_of_memory_abort() { + assert_eq!(detail(None, "unreachable"), "It ran out of memory."); + assert_eq!(detail(None, "RuntimeError: unreachable executed"), "It ran out of memory."); + assert_eq!(detail(None, ""), "It ran out of memory."); + assert_eq!(detail(None, "WebAssembly.Memory(): could not allocate memory"), "It ran out of memory."); + // Safari and Firefox word and capitalize theirs differently. + assert_eq!(detail(None, "Unreachable code should not be executed"), "It ran out of memory."); + assert_eq!(detail(None, "out of memory"), "It ran out of memory."); + } + + #[test] + fn another_kind_of_trap_is_not_blamed_on_memory() { + let text = detail(None, "divide by zero"); + assert!(text.starts_with("It stopped unexpectedly: divide by zero"), "{text}"); + // A bad memory access is a bug, not memory running out. + let text = detail(None, "memory access out of bounds"); + assert!(text.starts_with("It stopped unexpectedly: memory access out of bounds"), "{text}"); + } + + #[test] + fn only_traps_count() { + assert!(is_trap("RuntimeError", "unreachable")); + assert!(is_trap("RuntimeError", "")); + assert!(is_trap("RangeError", "Maximum call stack size exceeded")); + assert!(is_trap("RangeError", "WebAssembly.Memory(): could not allocate memory")); + // Matched ignoring case: Safari's wording, reported without the RuntimeError name. + assert!(is_trap("Error", "Unreachable code should not be executed")); + assert!(is_trap("Error", "Out of memory")); + assert!(!is_trap("TypeError", "Failed to fetch")); + assert!(!is_trap("Error", "ResizeObserver loop completed")); + } + + #[test] + fn long_messages_are_cut_on_a_character_boundary() { + let long = "\u{e9}".repeat(1000); + let cut = truncate(&long); + assert_eq!(cut.chars().count(), MAX_PANIC_CHARS + 1); + assert!(cut.ends_with('\u{2026}')); + assert_eq!(truncate("short"), "short"); + assert_eq!(truncate(&"x".repeat(MAX_PANIC_CHARS)).chars().count(), MAX_PANIC_CHARS); + } + + #[test] + fn the_recorded_panic_is_taken_once() { + record_panic("boom at a.rs:1"); + assert_eq!(take_panic().as_deref(), Some("boom at a.rs:1")); + assert_eq!(take_panic(), None); + } + + #[test] + fn free_mb_is_read_from_the_query() { + assert_eq!(free_mb(""), None); + assert_eq!(free_mb("?cpu"), None); + assert_eq!(free_mb("?free-mb=300"), Some(300)); + assert_eq!(free_mb("?cpu&free-mb=400&webgl"), Some(400)); + assert_eq!(free_mb("?free-mb=lots"), None); + assert_eq!(free_bytes(300), 300 << 20); + assert_eq!(free_bytes(u64::MAX), u64::MAX); + } +} diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 0802a02ebf..60846196f1 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -12,10 +12,15 @@ //! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`. //! //! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`); -//! `?webgl` forces the WebGL2 backend instead of WebGPU. +//! `?webgl` forces the WebGL2 backend instead of WebGPU; `?free-mb=N` pretends only N MiB can be +//! allocated, so the memory gates and the low-memory warning can be seen without gigabytes of +//! images (diagnostics only). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod diagnostics; + #[cfg(any(target_arch = "wasm32", test))] mod preset_bridge; @@ -48,4 +53,22 @@ mod tests { assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay"); assert!(!html.contains("").expect("the element is closed"); + assert!(element.trim_start().starts_with("hidden"), "hidden until a trap: {element}"); + assert!(element.contains("role=\"alert\""), "{element}"); + assert!(element.contains("Reload"), "{element}"); + assert!(element.contains("can't be recovered"), "says what is lost: {element}"); + assert!(element.contains("id=\"photocraft_stopped_detail\""), "a line for the cause: {element}"); + assert!(html.contains("#photocraft_stopped[hidden]"), "display: flex must not defeat the hidden attribute"); + assert!(html.contains("pointer-events: auto"), "the link works over the canvas"); + assert!(!html.contains(" photocr /// Closing or reloading the tab while a document has unsaved changes asks first, as closing the /// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is -/// refreshed every frame by [`WebShell`]. +/// refreshed every frame by [`WebShell`]. Once the module has stopped ([`STOPPED`]) the prompt is +/// skipped: the document can't be saved any more, and Reload is the only way on. fn guard_unload(unsaved: Arc) { use wasm_bindgen::closure::Closure; let Some(window) = web_sys::window() else { return }; let cb = Closure::::new(move |e: web_sys::BeforeUnloadEvent| { + if STOPPED.load(Ordering::Relaxed) { + return; + } if unsaved.load(Ordering::Relaxed) { e.prevent_default(); // Older browsers show the prompt only when a return value is set. @@ -196,6 +212,67 @@ fn guard_unload(unsaved: Arc) { } } +/// Keeps the message of a panic, then calls the handler that was installed before (the web +/// runner's, which logs it). A panic aborts the module, so the trap that follows has no message of +/// its own; this is what lets the stopped page tell an internal error from running out of memory. +fn keep_panic_message() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + crate::diagnostics::record_panic(&info.to_string()); + previous(info); + })); +} + +/// Shows the page's static `#photocraft_stopped` message when the module traps. The page learns of +/// a trap as an uncaught `WebAssembly.RuntimeError` (the `error` event) or, inside a spawned +/// future, an unhandled rejection. The module may be mid-call and its runner still borrowed, so +/// the handler touches no app state: it sets [`STOPPED`] and the element, nothing more. +fn listen_for_trap() { + use wasm_bindgen::closure::Closure; + let Some(window) = web_sys::window() else { return }; + let on_error = Closure::::new(move |e: web_sys::ErrorEvent| { + let error = e.error(); + let (name, message) = error_parts(&error, &e.message()); + if crate::diagnostics::is_trap(&name, &message) { + show_stopped(&message); + } + }); + if window.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref()).is_ok() { + on_error.forget(); + } + let on_rejection = Closure::::new(move |e: web_sys::PromiseRejectionEvent| { + let (name, message) = error_parts(&e.reason(), ""); + if crate::diagnostics::is_trap(&name, &message) { + show_stopped(&message); + } + }); + if window.add_event_listener_with_callback("unhandledrejection", on_rejection.as_ref().unchecked_ref()).is_ok() { + on_rejection.forget(); + } +} + +/// The name and message of a thrown value; `fallback` is the event's own message when the value +/// is not an `Error`. +fn error_parts(value: &wasm_bindgen::JsValue, fallback: &str) -> (String, String) { + match value.dyn_ref::() { + Some(error) => (String::from(error.name()), String::from(error.message())), + None => (String::new(), value.as_string().unwrap_or_else(|| fallback.to_owned())), + } +} + +/// Reveals `#photocraft_stopped` with the cause. Does as little as possible. +fn show_stopped(trap_message: &str) { + STOPPED.store(true, Ordering::Relaxed); + let detail = crate::diagnostics::detail(crate::diagnostics::take_panic().as_deref(), trap_message); + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + if let Some(el) = document.get_element_by_id("photocraft_stopped_detail") { + el.set_text_content(Some(&detail)); + } + if let Some(el) = document.get_element_by_id("photocraft_stopped") { + let _ = el.remove_attribute("hidden"); + } +} + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 8c10907f5e..f1a01d0afe 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -330,6 +330,8 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option false, Some(Value::Bool(b)) => *b, diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 152f4bdccf..6de4bf6f70 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -263,6 +263,8 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { + // Before the sampler map or the document change: a resize that doesn't fit would trap on the web (#10024). + crate::memory_gate::ensure(&format!("resize the image to {nw} x {nh} px"), crate::memory_gate::image_size_need(&d.doc, nw, nh))?; // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); s.set_sampler_map(photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); diff --git a/crates/engine/src/cmd/smart_cmds/source.rs b/crates/engine/src/cmd/smart_cmds/source.rs index 7385c21da9..56e936004c 100644 --- a/crates/engine/src/cmd/smart_cmds/source.rs +++ b/crates/engine/src/cmd/smart_cmds/source.rs @@ -357,6 +357,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result Result<()> { + check(what, need, "Save your work, close other documents or use Edit › Purge › All, then try again.") +} + +/// [`ensure`] for saving and exporting: the user must be able to keep their work, so the advice +/// leads with freeing memory. The document stays open and unsaved. +pub fn ensure_for_save(what: &str, need: u64) -> Result<()> { + check(what, need, "Use Edit › Purge › All or close other documents to free memory, then save again. The document is still open and unchanged.") +} + +fn check(what: &str, need: u64, advice: &str) -> Result<()> { + photocraft_raster::headroom::ensure(need).map_err(|_| { + if need == u64::MAX { + // Freeing memory cannot satisfy an unbounded need, so the retry advice would mislead. + EngineError::Other(format!( + "Not enough memory to safely {what} in the browser: the file has a format whose memory use cannot be bounded in advance. Open it in the desktop app, or flatten it to a plain 8-bit RGB or grayscale image first." + )) + } else { + EngineError::Other(format!("Not enough memory to {what} (it needs about {} MB). {advice}", mb(need))) + } + }) +} + +/// `bytes` in whole megabytes, rounded up, with thousands separators. +fn mb(bytes: u64) -> String { + let n = bytes.div_ceil(1 << 20).to_string(); + let mut out = String::with_capacity(n.len() + n.len() / 3); + for (i, c) in n.chars().enumerate() { + if i > 0 && (n.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// Pixel count of an image file read from its header, without decoding it. Formats without a +/// header reader here fall back to a size-based guess, so the answer is always an upper-bound +/// style estimate rather than an error. +pub(crate) fn source_pixels(bytes: &[u8]) -> u64 { + header_pixels(bytes).unwrap_or_else(|| (bytes.len() as u64).saturating_mul(FALLBACK_PX_PER_FILE_BYTE)) +} + +fn header_pixels(bytes: &[u8]) -> Option { + if let Ok((w, h)) = photocraft_codecs::probe_size(bytes) { + return Some(u64::from(w) * u64::from(h)); + } + let be32 = |at: usize| -> Option { Some(u64::from(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?))) }; + let be16 = |at: usize| -> Option { Some(u64::from(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?))) }; + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + // IHDR: width, height. + return Some(be32(16)?.saturating_mul(be32(20)?)); + } + if bytes.starts_with(b"8BPS") { + // PSD / PSB header: height then width. + return Some(be32(14)?.saturating_mul(be32(18)?)); + } + if bytes.starts_with(&[0xff, 0xd8]) { + // JPEG: the first start-of-frame marker holds height then width. + let mut at = 2usize; + while at.checked_add(4)? <= bytes.len() { + if *bytes.get(at)? != 0xff { + at += 1; + continue; + } + let marker = *bytes.get(at + 1)?; + match marker { + 0xff => at += 1, + 0xc0..=0xcf if !matches!(marker, 0xc4 | 0xc8 | 0xcc) => return Some(be16(at + 5)?.saturating_mul(be16(at + 7)?)), + 0xd0..=0xd9 | 0x01 | 0x00 => at += 2, + _ => at = at.checked_add(2)?.checked_add(usize::try_from(be16(at + 2)?).ok()?)?, + } + } + } + None +} + +/// The number of layers a PSD/PSB file decodes to, read from its sections without decoding: 0 +/// for a flat file. The color-mode data and image resources (resolution, ICC profile, thumbnail +/// and the like) hold no layer pixels and are only skipped. `None` when the header or a section +/// can't be read, or the file isn't 8-bit RGB or grayscale with at most 4 channels: those have +/// no safe bound. +fn psd_layer_count(bytes: &[u8]) -> Option { + let header = bytes.get(..26)?; + let version = u16::from_be_bytes([header[4], header[5]]); + let channels = u16::from_be_bytes([header[12], header[13]]); + let depth = u16::from_be_bytes([header[22], header[23]]); + let mode = u16::from_be_bytes([header[24], header[25]]); + if !matches!(version, 1 | 2) || !(1..=4).contains(&channels) || depth != 8 || !matches!(mode, 1 | 3) { + return None; + } + // A big-endian length field of `width` bytes at `at`. + let length = |at: usize, width: usize| -> Option { + let field = bytes.get(at..at.checked_add(width)?)?; + usize::try_from(field.iter().fold(0u64, |n, &b| (n << 8) | u64::from(b))).ok() + }; + let mut at = 26usize; + for _ in 0..2 { + // Color-mode data, then image resources. + at = at.checked_add(4)?.checked_add(length(at, 4)?)?; + } + let width = if version == 1 { 4 } else { 8 }; + let layers_and_masks = length(at, width)?; + let start = at.checked_add(width)?; + // The merged image's compression field follows the section. + if bytes.len().saturating_sub(start.checked_add(layers_and_masks)?) < 2 { + return None; + } + if layers_and_masks == 0 { + return Some(0); + } + // The layer info opens with its own length, then the layer count (negative when the first + // alpha channel holds the merged image's transparency). + let layer_info = length(start, width)?; + if layer_info == 0 { + // Some writers keep the layers in a `Layr` tagged block instead, which isn't counted here. + let section = bytes.get(start..start.checked_add(layers_and_masks)?)?; + return if section.windows(8).any(|w| w == b"8BIMLayr" || w == b"8B64Layr") { None } else { Some(0) }; + } + if layer_info.checked_add(width)? > layers_and_masks { + return None; + } + let count_at = start.checked_add(width)?; + let count = <[u8; 2]>::try_from(bytes.get(count_at..count_at.checked_add(2)?)?).ok()?; + Some(u64::from(i16::from_be_bytes(count).unsigned_abs())) +} + +/// Bytes per source pixel a file keeps beyond the flat estimate: a canvas-sized surface per layer +/// of a layered PSD/PSB, nothing for other files. `None` for a PSD/PSB with no safe bound, which +/// the web gate then refuses before decoding; native behavior is unchanged. +fn layer_bytes_per_px(bytes: &[u8]) -> Option { + if !bytes.starts_with(b"8BPS") { + return Some(0); + } + Some(psd_layer_count(bytes)?.saturating_mul(PSD_LAYER_BYTES_PER_PX)) +} + +/// Place: the file's bytes, plus the decoded file, its composite and the placed pixels, which +/// are alive together (`bytes_per_px` is the document's pixel size, at least 4), plus the layers +/// of a layered PSD/PSB. +pub(crate) fn place_need(bytes: &[u8], bytes_per_px: usize) -> u64 { + let Some(layers) = layer_bytes_per_px(bytes) else { return u64::MAX }; + let px = source_pixels(bytes); + let per_px = (bytes_per_px.max(4) as u64).saturating_mul(3).saturating_add(COMPOSITE_BYTES_PER_PX).saturating_add(layers); + (bytes.len() as u64).saturating_add(px.saturating_mul(per_px)) +} + +/// A smart object's re-render from `bytes`: its source pixels at the measured peak, plus the +/// layers of a layered PSD/PSB. +pub(crate) fn smart_render_need(bytes: &[u8]) -> u64 { + let Some(layers) = layer_bytes_per_px(bytes) else { return u64::MAX }; + source_pixels(bytes).saturating_mul(SMART_RENDER_BYTES_PER_PX.saturating_add(layers)) +} + +/// Image Size to `nw` x `nh`: the resampled pixel surfaces and masks (each layer's tiles scaled +/// by the change of area, capped at a full canvas) in the document's pixel size, plus every +/// distinct embedded smart object re-rendered from its source at once. +pub(crate) fn image_size_need(doc: &Document, nw: u32, nh: u32) -> u64 { + let bpp = doc.pixel_format().bytes_per_pixel().max(1) as u64; + let old_px = (u64::from(doc.size.width) * u64::from(doc.size.height)).max(1); + let new_px = u64::from(nw) * u64::from(nh); + let canvas_bytes = new_px.saturating_mul(bpp); + let tile_bytes = (TILE_SIZE as u64) * (TILE_SIZE as u64) * bpp; + let resized = |tiles: usize| -> u64 { + let old = (tiles as u64).saturating_mul(tile_bytes); + let scaled = (old as u128 * u128::from(new_px) / u128::from(old_px)).min(u128::from(u64::MAX)) as u64; + scaled.max(tile_bytes.min(canvas_bytes)).min(canvas_bytes) + }; + let mut need = 0u64; + let mut seen = std::collections::HashSet::new(); + for (_, _, layer) in doc.walk() { + if let Some(s) = layer.surface() { + need = need.saturating_add(resized(s.tile_count())); + } + if let Some(m) = &layer.mask { + need = need.saturating_add(resized(m.surface.tile_count())); + } + if let LayerContent::Smart(so) = &layer.content { + let embedded = match &so.source { + SmartSource::Embedded { bytes, .. } => Some(bytes.as_slice()), + SmartSource::Linked { .. } => None, + }; + if let Some(bytes) = embedded + && seen.insert(so.contents_id) + { + need = need.saturating_add(smart_render_need(bytes)); + } + } + } + need +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn megabytes_are_rounded_up_and_grouped() { + assert_eq!(mb(0), "0"); + assert_eq!(mb(1), "1"); + assert_eq!(mb(1240 << 20), "1,240"); + assert_eq!(mb(1_000 << 20), "1,000"); + assert_eq!(mb(12_345_678 << 20), "12,345,678"); + } + + #[test] + fn headers_give_pixel_counts() { + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend_from_slice(&300u32.to_be_bytes()); + png.extend_from_slice(&200u32.to_be_bytes()); + assert_eq!(source_pixels(&png), 60_000); + let mut psd = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec(); + psd.extend_from_slice(&50u32.to_be_bytes()); + psd.extend_from_slice(&40u32.to_be_bytes()); + assert_eq!(source_pixels(&psd), 2_000); + // SOI, an APP0 segment of length 4, then SOF0: precision, height 20, width 30. + let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 30, 1]; + assert_eq!(source_pixels(&jpeg), 600); + } + + #[test] + fn malformed_psd_sections_are_refused_without_panicking() { + let mut flat = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + flat.extend_from_slice(&[0; 14]); + assert_eq!(psd_layer_count(&flat), Some(0)); + for end in 4..flat.len() { + assert_eq!(place_need(&flat[..end], 4), u64::MAX); + assert_eq!(smart_render_need(&flat[..end]), u64::MAX); + } + flat[26..30].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(place_need(&flat, 4), u64::MAX); + } + + /// A 2 x 2 RGB PSD with a resolution resource (as every Photoshop file has) and, when + /// `layers` is set, a layer info section holding only that layer count. + fn psd_with_resources(layers: Option) -> Vec { + let mut psd = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + psd.extend_from_slice(&[0; 4]); + // ResolutionInfo (0x03ED): signature, id, empty name padded to even, 16 bytes of data. + let mut resource = b"8BIM\x03\xed\0\0".to_vec(); + resource.extend_from_slice(&16u32.to_be_bytes()); + resource.extend_from_slice(&[0, 0x48, 0, 0, 0, 1, 0, 1, 0, 0x48, 0, 0, 0, 1, 0, 1]); + psd.extend_from_slice(&(resource.len() as u32).to_be_bytes()); + psd.extend_from_slice(&resource); + match layers { + None => psd.extend_from_slice(&[0; 4]), + Some(n) => { + psd.extend_from_slice(&6u32.to_be_bytes()); + psd.extend_from_slice(&2u32.to_be_bytes()); + psd.extend_from_slice(&n.to_be_bytes()); + } + } + psd.extend_from_slice(&[0; 2]); + psd.extend_from_slice(&[128; 12]); + psd + } + + #[test] + fn image_resources_do_not_make_a_psd_unbounded() { + let psd = psd_with_resources(None); + assert_eq!(psd_layer_count(&psd), Some(0)); + assert_eq!(place_need(&psd, 4), psd.len() as u64 + 4 * (4 * 3 + COMPOSITE_BYTES_PER_PX)); + assert_eq!(smart_render_need(&psd), 4 * SMART_RENDER_BYTES_PER_PX); + } + + #[test] + fn a_layered_psd_counts_a_canvas_sized_surface_per_layer() { + // A negative count (merged transparency in the first alpha channel) is still 3 layers. + for n in [3i16, -3] { + let psd = psd_with_resources(Some(n)); + assert_eq!(psd_layer_count(&psd), Some(3), "{n}"); + assert_eq!(place_need(&psd, 4), psd.len() as u64 + 4 * (4 * 3 + COMPOSITE_BYTES_PER_PX + 3 * PSD_LAYER_BYTES_PER_PX)); + assert_eq!(smart_render_need(&psd), 4 * (SMART_RENDER_BYTES_PER_PX + 3 * PSD_LAYER_BYTES_PER_PX)); + } + // A layer info length that runs past its section can't be trusted. + let mut psd = psd_with_resources(Some(3)); + let info_at = psd.len() - 14 - 6; + psd[info_at..info_at + 4].copy_from_slice(&100u32.to_be_bytes()); + assert_eq!(place_need(&psd, 4), u64::MAX); + // Layers kept in a tagged block instead of the layer info aren't counted, so refused. + let mut tagged = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + tagged.extend_from_slice(&[0; 8]); + tagged.extend_from_slice(&20u32.to_be_bytes()); + tagged.extend_from_slice(&[0; 8]); + tagged.extend_from_slice(b"8BIMLayr"); + tagged.extend_from_slice(&[0; 4 + 2 + 12]); + assert_eq!(place_need(&tagged, 4), u64::MAX); + // Cut anywhere before the merged image's compression field, it's refused. + let psd = psd_with_resources(Some(3)); + for end in 4..psd.len() - 12 { + assert_eq!(place_need(&psd[..end], 4), u64::MAX, "{end}"); + } + } + + #[test] + fn unreadable_headers_fall_back_to_a_size_guess_without_panicking() { + assert_eq!(source_pixels(b""), 0); + assert_eq!(source_pixels(&[0u8; 100]), 400); + for cut in 0..40 { + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x10\0\0\0\x10".to_vec(); + png.truncate(cut); + let _ = source_pixels(&png); + let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 30, 1]; + let _ = source_pixels(&jpeg[..cut.min(jpeg.len())]); + } + // A segment length that runs off the end of the file ends the scan. + let _ = source_pixels(&[0xff, 0xd8, 0xff, 0xe0, 0xff, 0xff]); + } +} diff --git a/crates/engine/tests/memory_gate.rs b/crates/engine/tests/memory_gate.rs new file mode 100644 index 0000000000..a740012c76 --- /dev/null +++ b/crates/engine/tests/memory_gate.rs @@ -0,0 +1,261 @@ +//! On the web build a failed allocation aborts the module and freezes the page. The big +//! operations therefore ask the allocator first (`photocraft_raster::headroom`) and refuse with +//! an error when their estimated need won't fit (#10024). The simulation switch is process-wide, +//! so every test here holds `LOCK` and resets it when it ends. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_engine::Session; +use photocraft_raster::headroom; +use serde_json::json; + +static LOCK: Mutex<()> = Mutex::new(()); + +/// Holds the lock and the simulated room; resets the simulation when dropped. +struct Room(#[allow(dead_code)] MutexGuard<'static, ()>); + +impl Room { + fn new(bytes: Option) -> Room { + let guard = Room(LOCK.lock().unwrap_or_else(PoisonError::into_inner)); + headroom::simulate_available(bytes); + guard + } + fn set(&self, bytes: Option) { + headroom::simulate_available(bytes); + } +} + +impl Drop for Room { + fn drop(&mut self) { + headroom::simulate_available(None); + } +} + +const MIB: u64 = 1 << 20; + +/// Enough for the reserve and a small operation, far too little for a big one. +const SMALL: u64 = headroom::RESERVE + 4 * MIB; + +fn png(dir: &std::path::Path, name: &str, w: u32, h: u32, color: &str) -> String { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": color})).unwrap(); + let st = s.active().unwrap(); + let bytes = photocraft_io::export(&st.doc, "png", &Default::default()).unwrap().bytes; + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() +} + +/// What an operation must not change when the gate refuses it. +fn state(s: &Session) -> (u64, usize, usize, (u32, u32)) { + let st = s.active().unwrap(); + (st.revision, st.history.past_len(), st.doc.layers.len(), (st.doc.size.width, st.doc.size.height)) +} + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + s +} + +#[test] +fn place_that_does_not_fit_fails_and_leaves_the_document_alone() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + // 2400 x 1800 px: about 4 MP, about 190 MB by the estimate. + let path = png(dir.path(), "big \u{e9}.png", 2400, 1800, "#ff0000"); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(SMALL)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + let err = s.execute(cmd, json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to place \u{201c}big \u{e9}.png\u{201d}"), "{err}"); + assert!(err.contains("MB"), "names the need: {err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "says what to do: {err}"); + assert_eq!(state(&s), before, "{cmd}"); + } + room.set(None); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1); +} + +#[test] +fn image_size_that_does_not_fit_fails_and_leaves_the_document_alone() { + let room = Room::new(None); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(SMALL)); + let err = s.execute("image.imageSize", json!({"width": 12000, "height": 9000})).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to resize the image to 12000 x 9000 px"), "{err}"); + assert_eq!(state(&s), before); + // Only changing the resolution allocates nothing, so it still goes through. + s.execute("image.imageSize", json!({"resolution": 150, "resample": "none"})).unwrap(); + room.set(None); + s.execute("image.imageSize", json!({"width": 800, "height": 600})).unwrap(); + assert_eq!(state(&s).3, (800, 600)); +} + +/// A smart-object re-render that doesn't fit is refused by name (its own error); the commands +/// that re-render after an edit keep the layer's last appearance instead of failing. +#[test] +fn smart_object_render_that_does_not_fit_is_refused_by_name() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + let path = png(dir.path(), "src.png", 1500, 1500, "#12ab34"); + let bytes = std::fs::read(&path).unwrap(); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + room.set(Some(SMALL)); + let err = photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to render the smart object \u{201c}src.png\u{201d}"), "{err}"); + room.set(None); + assert!(photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).is_ok()); +} + +#[test] +fn layered_psd_is_refused_before_decode_without_changing_the_document() { + use photocraft_psd::{Compression, LayerSpec, PixelData, PsdBuilder}; + + let room = Room::new(None); + const SIDE: u32 = 256; + const LAYERS: usize = 24; + let pixels = u64::from(SIDE) * u64::from(SIDE); + let mut builder = PsdBuilder::new(SIDE, SIDE).compression(Compression::Rle); + for layer in 0..LAYERS { + // Nonuniform, opaque pixels populate each full-canvas surface while remaining compressible. + let rgba: Vec = (0..SIDE * SIDE).flat_map(|i| [((i / SIDE + layer as u32) % 256) as u8, 80, 160, 255]).collect(); + builder.push_layer(LayerSpec::new(format!("layer {layer}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba))); + } + let bytes = builder.to_bytes().unwrap(); + let budget = pixels * photocraft_engine::memory_gate::SMART_RENDER_BYTES_PER_PX + bytes.len() as u64; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layers.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(headroom::RESERVE)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory")); + assert_eq!(state(&s), before); + } + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err()); + + // The layers make the need bigger but bounded, so the error names it. + let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("MB"), "{err}"); + + // This budget passed the old canvas-only estimate even though the layers retain more. + room.set(Some(headroom::RESERVE + budget)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory")); + assert_eq!(state(&s), before); + } + assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err()); +} + +#[test] +fn small_flat_psd_still_places_and_renders() { + let room = Room::new(Some(SMALL)); + // Two-by-two RGB PSD: empty color, resource and layer sections, raw planar pixels. + let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + bytes.extend_from_slice(&[0; 12]); + bytes.extend_from_slice(&[0, 0]); + bytes.extend_from_slice(&[128; 12]); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flat.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + assert!(photocraft_engine::smart_cmds::source_image("flat.psd", &bytes, fmt).is_ok()); + drop(room); +} + +/// Every Photoshop file has image resources (resolution, ICC profile, thumbnail); they hold no +/// pixels, so a PSD that has them, flat or with a few layers, still places and renders when the +/// memory is there. +#[test] +fn a_psd_with_image_resources_places_and_renders_when_it_fits() { + use photocraft_psd::{LayerSpec, PixelData, PsdBuilder}; + + let room = Room::new(Some(headroom::RESERVE + 256 * MIB)); + const SIDE: u32 = 64; + let rgba = |r: u8| -> Vec { (0..SIDE * SIDE).flat_map(|_| [r, 80, 160, 255]).collect() }; + let mut flat = PsdBuilder::new(SIDE, SIDE).resolution(72.0); + flat.composite(PixelData::Rgba8(rgba(10))); + let mut layered = PsdBuilder::new(SIDE, SIDE).resolution(150.0); + for (i, r) in [20u8, 30].into_iter().enumerate() { + layered.push_layer(LayerSpec::new(format!("layer {i}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba(r)))); + } + let dir = tempfile::tempdir().unwrap(); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let mut s = new_doc(); + for (name, builder) in [("resources.psd", flat), ("resources-layers.psd", layered)] { + let bytes = builder.to_bytes().unwrap(); + // No color-mode data for RGB, then a non-empty image resources section. + assert_ne!(&bytes[30..34], &[0u8; 4][..], "{name} has image resources"); + assert!(photocraft_engine::smart_cmds::source_image(name, &bytes, fmt).is_ok(), "{name}"); + let path = dir.path().join(name); + std::fs::write(&path, &bytes).unwrap(); + let before = state(&s); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1, "{name}"); + } + drop(room); +} + +/// A PSD with no safe bound (here 16-bit) is refused whatever the free memory, so the error +/// doesn't suggest freeing some. +#[test] +fn a_psd_without_a_bound_says_to_flatten_it() { + let room = Room::new(Some(headroom::RESERVE + 1024 * MIB)); + let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x10\0\x03".to_vec(); + bytes.extend_from_slice(&[0; 14]); + bytes.extend_from_slice(&[128; 24]); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("deep.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string(); + assert!(!err.contains("Purge"), "freeing memory cannot help an unbounded source: {err}"); + assert!(err.contains("flatten"), "{err}"); + assert_eq!(state(&s), before); + drop(room); +} + +#[test] +fn a_smart_object_edit_under_low_memory_keeps_its_appearance() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + let path = png(dir.path(), "edit.png", 1500, 1500, "#a1b2c3"); + let mut s = new_doc(); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + let before = state(&s); + room.set(Some(SMALL)); + s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50})).unwrap(); + assert_eq!(state(&s).2, before.2, "the layer is still there"); +} + +#[test] +fn everything_passes_without_a_simulation() { + let _room = Room::new(None); + assert!(photocraft_engine::memory_gate::ensure("do anything", 64 << 30).is_ok()); + assert!(photocraft_engine::memory_gate::ensure_for_save("save", 64 << 30).is_ok()); +} + +#[test] +fn the_save_wording_leads_with_freeing_memory_and_keeps_the_document() { + let room = Room::new(Some(SMALL)); + let err = photocraft_engine::memory_gate::ensure_for_save("save \u{201c}a.psd\u{201d}", 900 * MIB).unwrap_err().to_string(); + assert!(err.starts_with("Not enough memory to save \u{201c}a.psd\u{201d} (it needs about 900 MB)."), "{err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "{err}"); + assert!(err.contains("still open"), "{err}"); + drop(room); +} diff --git a/crates/io/src/export_estimate.rs b/crates/io/src/export_estimate.rs new file mode 100644 index 0000000000..61836b07f8 --- /dev/null +++ b/crates/io/src/export_estimate.rs @@ -0,0 +1,118 @@ +//! A rough upper bound of the memory an export needs, so a caller can refuse a save that cannot +//! fit before starting it (#10024): on the web a failed allocation aborts the module and the +//! document is lost. + +use std::collections::HashSet; + +use photocraft_doc::{Document, LayerContent, SmartSource}; +use photocraft_geom::TILE_SIZE; + +/// Bytes per pixel of the compositor's working buffer (`RGBA`, `f32`), filled when a flat format +/// is written. +const COMPOSITE_BYTES_PER_PX: u64 = 16; + +/// Formats that keep the layers: their model is built from the layer pixels, not a composite. +fn keeps_layers(ext: &str) -> bool { + matches!(ext, "psd" | "psb" | "pcraft" | "ora" | "kra" | "xcf") +} + +/// Formats whose writer fills a composite of the canvas: every flat format, and OpenRaster for +/// its `mergedimage.png`. +fn fills_composite(ext: &str) -> bool { + !keeps_layers(ext) || ext == "ora" +} + +/// The extension of `name_or_ext` (a file name, path or bare extension), lowercase. +fn extension(name_or_ext: &str) -> String { + let file = name_or_ext.rsplit(['/', '\\']).next().unwrap_or(name_or_ext); + file.rsplit_once('.').map_or(file, |(_, ext)| ext).to_ascii_lowercase() +} + +/// The bytes exporting `doc` to `name_or_ext` may need before anything it allocates can fail +/// cleanly: the output model, about one copy of every layer's and mask's pixel tiles plus the +/// embedded smart-object files (each distinct one counted once); a flat format and OpenRaster +/// also fill a composite of the canvas, and PSD/PSB their merged image ([`merged_image_bytes`]). +/// The encoded file is not counted: its buffer is reserved fallibly (`PsdFile::to_bytes`), so if +/// it doesn't fit the save fails with an error rather than trapping, and counting it would refuse +/// saves that fit. Saturates; never panics. +pub fn estimate_export_bytes(doc: &Document, name_or_ext: &str) -> u64 { + let tile_bytes = (TILE_SIZE as u64) * (TILE_SIZE as u64) * doc.pixel_format().bytes_per_pixel().max(1) as u64; + let mut seen = HashSet::new(); + let mut total = 0u64; + for (_, _, layer) in doc.walk() { + let tiles = layer.surface().map_or(0, |s| s.tile_count()) + layer.mask.as_ref().map_or(0, |m| m.surface.tile_count()); + total = total.saturating_add((tiles as u64).saturating_mul(tile_bytes)); + if let LayerContent::Smart(so) = &layer.content + && let SmartSource::Embedded { bytes, .. } = &so.source + && seen.insert(so.contents_id) + { + total = total.saturating_add(bytes.len() as u64); + } + } + let mut need = total; + let ext = extension(name_or_ext); + if fills_composite(&ext) { + need = need.saturating_add(canvas_px(doc).saturating_mul(COMPOSITE_BYTES_PER_PX)); + } + if matches!(ext.as_str(), "psd" | "psb") { + need = need.saturating_add(merged_image_bytes(doc)); + } + need +} + +fn canvas_px(doc: &Document) -> u64 { + u64::from(doc.size.width).saturating_mul(u64::from(doc.size.height)) +} + +/// PSD and PSB always write a canvas-sized merged image: the real composite with maximum +/// compatibility, a white placeholder without it. Its planes (colour channels plus alpha, in the +/// document's sample size) and their encoded copy are held together while the file is built. +fn merged_image_bytes(doc: &Document) -> u64 { + let fmt = doc.pixel_format(); + let per_px = (fmt.mode.color_channels() as u64 + 1).saturating_mul(fmt.sample.bytes() as u64); + canvas_px(doc).saturating_mul(per_px).saturating_mul(2) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Layer, SmartObject}; + use photocraft_geom::Affine; + use std::sync::Arc; + + fn doc_with_smart_object(len: usize) -> Document { + let mut doc = Document::new("t", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let source = SmartSource::Embedded { file_name: "a.png".into(), bytes: Arc::new(vec![0; len]) }; + doc.layers.push(Layer::new("so", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, None)))); + doc + } + + #[test] + fn counts_embedded_files_once_and_adds_a_composite_for_flat_formats() { + let doc = doc_with_smart_object(10 << 20); + // RGB 8-bit: three colour planes and alpha, held raw and encoded. + let merged = 64 * 64 * 4 * 2; + assert_eq!(estimate_export_bytes(&doc, "x.psd"), (10 << 20) + merged); + assert_eq!(estimate_export_bytes(&doc, "/a/b/x.PSD"), (10 << 20) + merged); + assert_eq!(estimate_export_bytes(&doc, "x.pcraft"), 10 << 20); + assert_eq!(estimate_export_bytes(&doc, "png"), (10 << 20) + 64 * 64 * 16); + // OpenRaster keeps the layers but also writes a merged image, flattened first. + assert_eq!(estimate_export_bytes(&doc, "x.ora"), (10 << 20) + 64 * 64 * 16); + } + + #[test] + fn a_shared_source_is_counted_once() { + let mut doc = doc_with_smart_object(1 << 20); + let dup = doc.layers[0].clone(); + doc.layers.push(dup); + assert_eq!(estimate_export_bytes(&doc, "psd"), (1 << 20) + 64 * 64 * 4 * 2); + } + + #[test] + fn an_empty_document_needs_next_to_nothing() { + let doc = Document::new("t", photocraft_geom::Size::new(10, 10), ColorMode::Rgb, SampleType::U8); + assert_eq!(estimate_export_bytes(&doc, "psd"), 10 * 10 * 4 * 2, "only the merged image"); + assert_eq!(estimate_export_bytes(&doc, "pcraft"), 0); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6cb0aafc84..688aa1f9ff 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -45,6 +45,7 @@ mod channel_map; pub mod comps_map; pub mod doc_path; pub mod effects_map; +pub mod export_estimate; #[cfg(any(unix, windows))] mod exr_native; pub mod exr_parts; @@ -86,6 +87,7 @@ pub mod vector_preview; mod xcf; pub mod xml; +pub use export_estimate::estimate_export_bytes; use photocraft_codecs::{CodecError, EncodeOptions}; pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; diff --git a/crates/psd/tests/it/roundtrip.rs b/crates/psd/tests/it/roundtrip.rs index 49ad412e7c..1823900372 100644 --- a/crates/psd/tests/it/roundtrip.rs +++ b/crates/psd/tests/it/roundtrip.rs @@ -313,3 +313,21 @@ fn spec_order_density_only_mask_round_trips_as_a_model() { assert_eq!(g.layers()[0].decode_channel(-2, 8, Version::Psd).unwrap(), vec![64]); assert_eq!(g.to_bytes().unwrap(), b, "byte stability"); } + +/// Embedded data (smart-object files, and the like) is large next to the headers, so the output +/// is reserved at its exact size up front: no growth by doubling, which on the web is a single +/// unrecoverable multi-gigabyte `realloc` (#10024). +#[test] +fn to_bytes_reserves_the_whole_output_up_front() { + for version in [Version::Psd, Version::Psb] { + let mut f = testgen::small(version, Compression::Rle); + f.global_blocks.push(TaggedBlock::new(*b"Q3Dx", vec![0x5a; 3 << 20])); + f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"Vid!", vec![0xab; 1 << 20])); + let b = f.to_bytes().unwrap(); + assert_eq!(b.capacity(), b.len(), "{version:?}: the output buffer grew past its reserve"); + assert_eq!(b.len() as u64, f.encoded_len().unwrap()); + let p = PsdFile::from_bytes(&b).unwrap(); + assert_eq!(p, f); + assert_eq!(p.to_bytes().unwrap(), b); + } +} diff --git a/crates/raster/src/headroom.rs b/crates/raster/src/headroom.rs new file mode 100644 index 0000000000..9e38b58f31 --- /dev/null +++ b/crates/raster/src/headroom.rs @@ -0,0 +1,167 @@ +//! How much memory can still be allocated, asked of the allocator itself. +//! +//! On the web (`wasm32-unknown-unknown`) a failed infallible allocation aborts the module and the +//! page freezes with no message, and stable Rust cannot turn that into an error. The allocations +//! made with `try_reserve*` do survive, though: the allocator hands back null when the browser +//! refuses to grow the linear memory, and the caller gets an error. So before a big operation +//! (place, resize, re-render a smart object, save) the operation asks [`ensure`] whether its +//! estimated need can be allocated right now, and fails with a message the user can act on +//! instead of trapping part-way. +//! +//! The answer is a trial reservation: one `Vec::try_reserve_exact`, freed at once. It sees the +//! real ceiling of this browser and device (a phone may stop well below 4 GiB) and the +//! fragmentation of the heap, costs nothing per allocation, and needs no allocator wrapper. The +//! freed block returns to the allocator's free list, so the operation reuses it. A reservation +//! is not written to, so the browser commits no physical pages for it. +//! +//! [`below`], which the low-memory warning asks every few seconds, does not reserve anything: +//! wasm linear memory never shrinks, so a periodic trial would raise the module's high-water +//! mark by the amount it asks about, for nothing. It reads the size of linear memory instead. +//! +//! Native targets overcommit, so the question has no useful answer there: every check passes and +//! desktop behaviour is unchanged. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::AllocationError; + +/// Room kept free after an operation's own need: frames, egui, notices and the error path. +pub const RESERVE: u64 = 128 << 20; + +/// Marks [`simulate_available`] as off. +const OFF: u64 = u64::MAX; + +static SIMULATED: AtomicU64 = AtomicU64::new(OFF); + +/// Pretends that only `bytes` can be allocated (`None` stops pretending). The simulation is +/// process-wide and applies on every target. It is for tests and the web build's `?free-mb` +/// diagnostic flag only, so the gates and the low-memory warning can be seen without allocating +/// gigabytes. +pub fn simulate_available(bytes: Option) { + SIMULATED.store(bytes.unwrap_or(OFF), Ordering::SeqCst); +} + +fn simulated() -> Option { + match SIMULATED.load(Ordering::SeqCst) { + OFF => None, + n => Some(n), + } +} + +/// The most linear memory a wasm32 module can address. +#[cfg(target_arch = "wasm32")] +const WASM32_CEILING: u64 = 4 << 30; + +/// `true` when `bytes` can be allocated in one piece right now. Always `true` off the web. +fn can_allocate(bytes: u64) -> bool { + if let Some(available) = simulated() { + return bytes <= available; + } + trial(bytes) +} + +#[cfg(target_arch = "wasm32")] +fn trial(bytes: u64) -> bool { + let Ok(bytes) = usize::try_from(bytes) else { return false }; + let mut probe: Vec = Vec::new(); + probe.try_reserve_exact(bytes).is_ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn trial(_bytes: u64) -> bool { + true +} + +/// `Ok` when `need` bytes (plus [`RESERVE`]) can be allocated right now. On the web, this tries +/// to reserve that much and frees it at once. On other targets it always passes (unless a +/// simulation is set). +pub fn ensure(need: u64) -> Result<(), AllocationError> { + let total = need.saturating_add(RESERVE); + let bytes = usize::try_from(total).map_err(|_| AllocationError::SizeOverflow)?; + if can_allocate(total) { Ok(()) } else { Err(AllocationError::NotEnoughMemory { bytes }) } +} + +/// `true` while the module's linear memory is within `bytes` of the 4 GiB wasm32 ceiling (web +/// only; always `false` elsewhere unless a simulation is set). Allocates nothing, unlike +/// [`ensure`]. Linear memory is a high-water mark, so this errs towards `true`: room freed inside +/// the allocator after a peak doesn't count. A browser that stops growing memory below 4 GiB isn't +/// seen either; the operation checks still are. +pub fn below(bytes: u64) -> bool { + if let Some(available) = simulated() { + return bytes > available; + } + linear_memory_left().is_some_and(|left| left < bytes) +} + +#[cfg(target_arch = "wasm32")] +fn linear_memory_left() -> Option { + let used = (core::arch::wasm32::memory_size::<0>() as u64).saturating_mul(64 << 10); + Some(WASM32_CEILING.saturating_sub(used)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn linear_memory_left() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static LOCK: Mutex<()> = Mutex::new(()); + + /// Serializes the tests (the simulation is global) and resets it when each one ends. + struct Guard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + + impl Guard { + fn new() -> Self { + let guard = Guard(LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + simulate_available(None); + guard + } + } + + impl Drop for Guard { + fn drop(&mut self) { + simulate_available(None); + } + } + + const MIB: u64 = 1 << 20; + + #[test] + fn passes_on_native_without_simulation() { + let _g = Guard::new(); + assert!(ensure(10 << 30).is_ok()); + assert!(!below(64 << 30)); + } + + #[test] + fn simulated_room_counts_the_reserve() { + let _g = Guard::new(); + // 300 MiB of room: a 10 MiB need plus the 128 MiB reserve fits, a 200 MiB need does not. + simulate_available(Some(300 * MIB)); + assert!(ensure(10 * MIB).is_ok()); + assert!(matches!(ensure(200 * MIB), Err(AllocationError::NotEnoughMemory { .. }))); + assert!(!below(300 * MIB)); + assert!(below(301 * MIB)); + } + + #[test] + fn huge_needs_do_not_overflow() { + let _g = Guard::new(); + let _ = ensure(u64::MAX); + simulate_available(Some(100 * MIB)); + assert!(ensure(u64::MAX).is_err()); + } + + #[test] + fn simulation_can_be_switched_off() { + let _g = Guard::new(); + simulate_available(Some(0)); + assert!(ensure(0).is_err()); + simulate_available(None); + assert!(ensure(0).is_ok()); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 01b8c7d504..63b55617a7 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod headroom; mod interrupt; pub use interrupt::{Cancelled, Interrupt}; pub mod scratch; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index caf9a90314..34c2ef16e1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,6 +13,7 @@ mod clipboard; mod commands; mod control; mod files; +pub(crate) use files::ensure_memory_to_save; mod frame; mod gpu; mod ime; @@ -89,6 +90,7 @@ impl PhotocraftApp { live_adjust: None, frame: 0, notices_hovered: false, + low_memory: crate::memory_warning::Watch::default(), styled: false, integrated_titlebar: false, custom_titlebar: false, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 9833be7e1c..e106a2ecfb 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -538,9 +538,11 @@ impl PhotocraftApp { if self.background_jobs && let Some(save) = self.services.save_file.clone() { + // `start_save` checks the memory itself: other callers reach it directly. return jobs_ui::start_save(self, path, settings.clone(), copy, save, false); } let st = self.session.active().ok_or("no document")?; + ensure_memory_to_save(&st.doc, &path)?; let (doc, written, previous) = (st.doc.id, st.revision, st.saved_revision); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&st.doc, &path, settings)?; @@ -782,6 +784,7 @@ impl PhotocraftApp { }; } let state = self.session.active().ok_or("no document")?; + ensure_memory_to_save(&state.doc, &target)?; let (doc, revision) = (state.doc.id, state.revision); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&state.doc, &target, &settings)?; @@ -850,6 +853,15 @@ impl PhotocraftApp { } } +/// Refuses a save that won't fit in memory, before any encoding starts (#10024): on the web a +/// failed allocation aborts the module and the unsaved document is lost with it. The document +/// stays open and unchanged, and the message says how to free memory. +pub(crate) fn ensure_memory_to_save(doc: &photocraft_engine::doc::Document, path: &str) -> Result<(), String> { + let name = file_open::display_name(path); + photocraft_engine::memory_gate::ensure_for_save(&format!("save \u{201c}{name}\u{201d}"), photocraft_io::estimate_export_bytes(doc, path)) + .map_err(|e| e.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 2d4c074365..2f04ddbd89 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -53,6 +53,7 @@ impl PhotocraftApp { } self.last_frame_time = now; self.sync_views(); + crate::memory_warning::tick(self, now); crate::type_font_preview::follow_hover(self, ctx); self.check_gpu(ctx); #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 68f46af65f..acfaa16db1 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -216,6 +216,7 @@ pub fn quick_export_selected_layers_png(app: &mut PhotocraftApp) -> Result) -> Result, path: &str, source_profile: &Profile) -> Result { let out = export_document(doc, f, None, source_profile)?; + crate::app::ensure_memory_to_save(&out, path)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&out, path, &settings(f))?; let write = app.services.write.as_mut().ok_or("no writer configured")?; @@ -1111,6 +1113,7 @@ fn confirm_layers(app: &mut PhotocraftApp, f: &Map, ids: &[photoc let mut exported = Vec::new(); for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) { let out = export_document(doc, &f, None, &source_profile)?; + crate::app::ensure_memory_to_save(&out, &path)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&out, &path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 620574f909..9855b4f36b 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -286,6 +286,7 @@ pub fn start_save( // The engine's wording for a command refused while a job runs. return Err(format!("“{}” is still running on this document; wait for it to finish or cancel it (Esc)", j.label)); } + crate::app::ensure_memory_to_save(&st.doc, &path)?; let name = crate::file_open::display_name(&path); // One writer per file: an older snapshot must not land after a newer one. if app.jobs.saves.iter().any(|s| s.path == path) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 54edda6264..b51f8990ba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,7 @@ pub mod icons; pub mod jobs_ui; pub(crate) mod last_brush; pub mod links; +pub mod memory_warning; pub mod menu_catalog; pub mod menus; pub mod monitor_status; @@ -456,6 +457,8 @@ pub struct PhotocraftApp { /// the frame the pointer leaves, so a long stationary hover never counts as elapsed time /// (#2022); set by `notices::show`. pub(crate) notices_hovered: bool, + /// The low-memory warning's schedule and state (`memory_warning`). + pub(crate) low_memory: memory_warning::Watch, /// Apply theme on first frame. styled: bool, /// Whether the window uses an integrated (transparent) macOS title bar. diff --git a/crates/ui-egui/src/memory_warning.rs b/crates/ui-egui/src/memory_warning.rs new file mode 100644 index 0000000000..366656d33e --- /dev/null +++ b/crates/ui-egui/src/memory_warning.rs @@ -0,0 +1,137 @@ +//! A standing warning while the web build's memory is nearly full (#10024). +//! +//! On the web a failed allocation aborts the module and the page freezes without a message. The +//! big operations refuse up front when they won't fit (`photocraft_engine::memory_gate`); this +//! tells the user earlier, while there is still room to save: when the module's memory is within +//! [`WARN_BELOW`] of the wasm32 ceiling, a notice that stays until dismissed asks them to save and +//! free memory. The answer comes from [`photocraft_raster::headroom::below`], which reads the +//! size of linear memory and allocates nothing. Linear memory never shrinks, so on the web the +//! warning doesn't re-arm once shown (only `?free-mb` or a test can make memory recover). It is always "plenty" on +//! the desktop, so the warning never shows there. + +use photocraft_raster::headroom; + +use crate::PhotocraftApp; + +/// Warn when less than this can be allocated. +pub const WARN_BELOW: u64 = 512 << 20; + +/// Warn again only after this much (or more) was available again, so a figure hovering around +/// the threshold doesn't flicker the notice on and off. +pub const REARM_ABOVE: u64 = 768 << 20; + +/// Seconds between checks. +const CHECK_EVERY: f64 = 2.0; + +/// The notice's title (an English source string, translated when drawn). +pub const TITLE: &str = "Memory is nearly full"; + +/// What a check decided. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step { + /// Nothing to do. + Idle, + /// Memory just ran low: post the notice. + Warn, + /// Memory recovered: the warning is armed again, and the old notice is stale. + Rearm, +} + +/// The warning's memory: when it last looked, and whether it has warned and not yet re-armed. +#[derive(Clone, Debug, Default)] +pub struct Watch { + next_check: f64, + warned: bool, +} + +impl Watch { + /// `true` when it is time to look again at `now` (seconds); then schedules the next look. + pub fn due(&mut self, now: f64) -> bool { + if now < self.next_check { + return false; + } + self.next_check = now + CHECK_EVERY; + true + } + + /// `true` between a warning and its re-arming. + pub fn warned(&self) -> bool { + self.warned + } + + /// Advances with what a check found: `below_warn` is "less than [`WARN_BELOW`] is available", + /// `below_rearm` is "less than [`REARM_ABOVE`] is available". + pub fn step(&mut self, below_warn: bool, below_rearm: bool) -> Step { + if !self.warned { + if below_warn { + self.warned = true; + return Step::Warn; + } + return Step::Idle; + } + if below_rearm { + return Step::Idle; + } + self.warned = false; + Step::Rearm + } +} + +/// Run once per frame: at most every two seconds, and only while a document is open, posts the +/// warning when memory is nearly full, and takes it down again once memory has recovered. +pub fn tick(app: &mut PhotocraftApp, now: f64) { + if app.session.active().is_none() || !app.low_memory.due(now) { + return; + } + let below_warn = headroom::below(WARN_BELOW); + let below_rearm = if app.low_memory.warned() { headroom::below(REARM_ABOVE) } else { below_warn }; + match app.low_memory.step(below_warn, below_rearm) { + Step::Idle => {} + Step::Warn => post(app), + Step::Rearm => app.ui.notices.retain(|n| n.title != TITLE), + } +} + +fn post(app: &mut PhotocraftApp) { + if app.ui.notices.iter().any(|n| n.title == TITLE) { + return; + } + let lines = vec![ + "Save your work now: if the browser runs out of memory this page stops, and changes since the last save can't be recovered.".to_owned(), + "To make room, close documents you don't need or use Edit › Purge › All.".to_owned(), + ]; + let id = crate::notices::post(app, TITLE, lines, true, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|n| n.id == id) { + notice.sticky = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warns_once_then_waits_for_the_higher_threshold() { + let mut w = Watch::default(); + assert_eq!(w.step(false, false), Step::Idle); + assert_eq!(w.step(true, true), Step::Warn); + // Staying low, or hovering between the two thresholds, doesn't warn again. + assert_eq!(w.step(true, true), Step::Idle); + assert_eq!(w.step(false, true), Step::Idle); + assert_eq!(w.step(true, true), Step::Idle); + // Rising above the higher threshold re-arms; dropping again warns again. + assert_eq!(w.step(false, false), Step::Rearm); + assert_eq!(w.step(false, false), Step::Idle); + assert_eq!(w.step(true, true), Step::Warn); + } + + #[test] + fn checks_are_spaced_two_seconds_apart() { + let mut w = Watch::default(); + assert!(w.due(0.0)); + assert!(!w.due(1.0)); + assert!(!w.due(1.99)); + assert!(w.due(2.0)); + assert!(!w.due(3.0)); + } +} diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 5c501fc2e9..02bf041185 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -656,6 +656,7 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { /// are any. pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result { let settings = choices.apply(app.save_settings()); + crate::app::ensure_memory_to_save(doc, path)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(doc, path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 4b3ae45cac..d34044d73c 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -35,6 +35,10 @@ pub struct Notice { /// and is reset while the pointer rests on the stack (issue #2022). `None` until first shown. #[serde(default, skip_serializing_if = "Option::is_none")] pub shown_at: Option, + /// Stays until dismissed (or until whatever it warns of has passed), whatever the auto-hide + /// setting: for a warning the user must not miss, such as nearly full memory. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub sticky: bool, } impl Notice { @@ -48,14 +52,23 @@ impl Notice { /// Show a notice (newest last); returns its id. pub fn post(app: &mut PhotocraftApp, title: impl Into, lines: Vec, error: bool, dismiss_pref: Option<&str>) -> u64 { let id = app.ui.alloc_id(); - app.ui.notices.push(Notice { id, title: title.into(), lines, error, dismiss_pref: dismiss_pref.map(str::to_owned), args: Vec::new(), shown_at: None }); + app.ui.notices.push(Notice { + id, + title: title.into(), + lines, + error, + dismiss_pref: dismiss_pref.map(str::to_owned), + args: Vec::new(), + shown_at: None, + sticky: false, + }); cap_notices(app); id } fn cap_notices(app: &mut PhotocraftApp) { while app.ui.notices.len() > MAX_NOTICES { - let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none()).unwrap_or(0); + let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none() && !notice.sticky).unwrap_or(0); app.ui.notices.remove(oldest_temporary); } } @@ -186,7 +199,7 @@ fn expire(app: &mut PhotocraftApp, now: f64, hovered: bool, just_left: bool, aut } let mut expired = Vec::new(); let mut next = f64::INFINITY; - for n in &mut app.ui.notices { + for n in app.ui.notices.iter_mut().filter(|n| !n.sticky) { if n.shown_at.is_none() { n.shown_at = Some(now); } diff --git a/crates/ui-egui/tests/save_memory_gate.rs b/crates/ui-egui/tests/save_memory_gate.rs new file mode 100644 index 0000000000..fcaa2a056c --- /dev/null +++ b/crates/ui-egui/tests/save_memory_gate.rs @@ -0,0 +1,108 @@ +//! A save that won't fit in memory is refused before any encoding starts, with a message that +//! says how to free memory; the document stays open and unsaved (#10024). Its own test binary: +//! the memory simulation is process-wide. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Mutex; + +use photocraft_engine::Session; +use photocraft_raster::headroom; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use serde_json::json; + +/// The simulation is process-wide: the tests take turns. +static LOCK: Mutex<()> = Mutex::new(()); + +fn app(writes: Rc>) -> PhotocraftApp { + let mut app = PhotocraftApp::new( + Session::new(), + Services { + export: Some(Box::new(|doc, path, _| { + photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) + })), + write: Some(Box::new(move |_, _| { + *writes.borrow_mut() += 1; + Ok(()) + })), + ..Default::default() + }, + ); + app.run("file.new", json!({"width": 8, "height": 8, "name": "Untitled-1"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn identity(app: &PhotocraftApp) -> (String, Option, u64, u64) { + let st = app.session.active().unwrap(); + (st.doc.name.clone(), st.path.clone(), st.revision, st.saved_revision) +} + +#[test] +fn a_save_that_does_not_fit_fails_and_leaves_the_document_unsaved() { + let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let writes = Rc::new(RefCell::new(0)); + let mut app = app(writes.clone()); + let before = identity(&app); + headroom::simulate_available(Some(1 << 20)); + let refused = app.save_as(Some("big.psd".into())); + let refused_automation = app.save_automation(Some("auto.psd".into())); + headroom::simulate_available(None); + let err = refused.unwrap_err(); + assert!(err.contains("Not enough memory to save \u{201c}big.psd\u{201d}"), "{err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "{err}"); + let automation_err = refused_automation.unwrap_err(); + assert!(automation_err.contains("Not enough memory to save \u{201c}auto.psd\u{201d}"), "{automation_err}"); + assert_eq!(*writes.borrow(), 0, "nothing was encoded or written"); + assert_eq!(identity(&app), before, "the document is unsaved and unchanged"); + app.save_as(Some("big.psd".into())).unwrap(); + assert_eq!(*writes.borrow(), 1); +} + +fn warnings(app: &PhotocraftApp) -> usize { + app.ui.notices.iter().filter(|n| n.title == photocraft_ui_egui::memory_warning::TITLE).count() +} + +/// Going low posts one notice that stays; hovering low doesn't post again; recovering past the +/// higher threshold takes it down and arms the next warning. +#[test] +fn the_low_memory_warning_posts_once_and_rearms_with_hysteresis() { + let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + use photocraft_ui_egui::memory_warning::{REARM_ABOVE, WARN_BELOW, tick}; + let mut app = app(Rc::new(RefCell::new(0))); + let mut now = 0.0; + let mut look = |app: &mut PhotocraftApp, available: Option| { + headroom::simulate_available(available); + now += 2.5; + tick(app, now); + }; + look(&mut app, None); + assert_eq!(warnings(&app), 0, "plenty of memory (and always so on the desktop)"); + look(&mut app, Some(WARN_BELOW - 1)); + assert_eq!(warnings(&app), 1); + assert!(app.ui.notices.iter().all(|n| n.sticky), "stays until dismissed"); + look(&mut app, Some(WARN_BELOW - 1)); + look(&mut app, Some(WARN_BELOW + 1)); + assert_eq!(warnings(&app), 1, "no second notice while it stays low"); + look(&mut app, Some(REARM_ABOVE)); + assert_eq!(warnings(&app), 0, "recovered: the stale notice is taken down"); + look(&mut app, Some(WARN_BELOW - 1)); + assert_eq!(warnings(&app), 1, "low again: warned again"); + // Checks are two seconds apart at most. + headroom::simulate_available(None); + app.ui.notices.clear(); + headroom::simulate_available(Some(0)); + tick(&mut app, now + 0.5); + assert_eq!(warnings(&app), 0); + headroom::simulate_available(None); +} + +#[test] +fn no_warning_without_a_document() { + let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut app = PhotocraftApp::new(Session::new(), Services::default()); + headroom::simulate_available(Some(0)); + photocraft_ui_egui::memory_warning::tick(&mut app, 100.0); + headroom::simulate_available(None); + assert_eq!(warnings(&app), 0); +} diff --git a/docs/development.md b/docs/development.md index 0d73368277..18a7610ee0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -600,7 +600,9 @@ trunk serve --release # dev server on http://127.0.0.1:8765 Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Some static hosts refuse a file that large (Cloudflare's per-file cap is 25 MiB), so the release site doesn't hold it as one file: `packaging/web/package.sh` gzips it, cuts it into parts of at most 20 MiB under `app/`, and ships the page of `apps/photocraft-web-boot`, a small module that fetches the parts, has the browser inflate them (or inflates them itself where the browser can't) and passes the stream to the app's own loader (issue #5302). The script fails if any file in the site is over 24 MiB; the wasm's own size is no longer limited, but it is still what every visitor downloads, so watch it. Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and file-size checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `apps/photocraft-web-boot`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the wasm's size and the site's largest file in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). -URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. +URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. `?free-mb=N` is a diagnostic: the memory gates and the low-memory warning behave as if only N MiB could be allocated, so they can be checked in a browser without gigabytes of images (for example `?free-mb=300`, then Place a large image, or `?free-mb=400` for the warning). + +Running out of memory in the browser: a failed allocation aborts the WebAssembly module, and stable Rust can't turn that into an error, so the page froze on its last frame. Three layers now cover it (#10024). **Gate:** Place, Image Size, a smart-object re-render and every save or export (Save, Save a Copy, Export As, layer and batch exports) ask `photocraft_raster::headroom::ensure` first (a trial `try_reserve_exact` of the operation's estimated need plus 128 MiB, freed at once; always passes off the web), and refuse with an error naming the operation and the need when it won't fit (`photocraft_engine::memory_gate`, estimates per operation; `photocraft_io::estimate_export_bytes` for saves). **Warn:** `memory_warning` posts a sticky notice when linear memory is within 512 MiB of the 4 GiB wasm32 ceiling (`headroom::below` reads the memory size and allocates nothing, since a periodic trial reservation would grow memory that never shrinks), and re-arms at or above 768 MiB in simulations. Real wasm linear memory normally does not shrink, so the warning cannot normally re-arm. A PSD/PSB source's estimate adds a canvas-sized surface per layer (the layer count is read from its layer info, without decoding); one that isn't 8-bit RGB or grayscale, or whose sections can't be read, has no safe bound and is refused before Place or smart-object rendering on the web. **Backstop:** if the module still traps, `index.html` holds a hidden `#photocraft_stopped` message that `web.rs` reveals (the `error` and `unhandledrejection` events), saying whether it ran out of memory or hit an internal error (a panic's message is kept by a chained panic hook), with a Reload link; the "Leave site?" prompt is skipped then. To build and serve the web app entirely in Docker, run from the repository root: diff --git a/docs/gaps.md b/docs/gaps.md index 7184efed49..29b4dd8ed5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -444,12 +444,25 @@ what an agent can't finish alone. launch, #5485), in-app bug report (DIST-220-3), Flathub (#173), starting without a GPU adapter (#2519, #2412), Windows 7 (#2310, out of scope), Intel Macs with OpenCore (#1859). +- **Progress (2026-10-29):** the web build no longer freezes silently when it runs out of memory + (#10024). A failed allocation aborts the WebAssembly module and can't be caught, so Place, Image + Size, a smart-object re-render and Save first ask the allocator whether their estimated need + fits (`photocraft_raster::headroom`, `photocraft_engine::memory_gate`) and refuse with an error + that says what to do; a standing warning shows below 512 MiB; and a trap that still happens + shows a "PhotoCraft stopped" message with a Reload link. Still open: the decoded originals that + accumulate (#10030), Place decoding on the UI thread (#10031), Image Size re-rendering every + smart object at once (#10032), and recovering the document after a crash (#6615). Not every + allocation is gated, so a trap is still possible. + A layered PSD/PSB source is estimated as a canvas-sized surface per layer (the decoder keeps + one per layer; layers larger than the canvas are not counted), and one that isn't 8-bit RGB or + grayscale is refused on the web before Place or smart-object rendering. - **Estimate:** 30–60 h. **Human:** signing certificates. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | G17: web build memory handling (#10024): up-front memory checks for Place, Image Size, smart-object re-render and Save; a low-memory warning; a stopped message when the module traps; PSD save reserves its whole output; no estimate changed | | 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged | | 2026-11-02 | minor | Action recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and places point text by canvas percentage; paragraph boxes remain absolute (AUTO-219-1, #4514); parity percentages unchanged | | 2026-11-01 | minor | Action recording stores portable layer targets and whole-text Type steps where unambiguous (AUTO-219-1, #4514); duplicate names remain ambiguous; parity percentages unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 90717b7417..539dbc7e59 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -14,6 +14,7 @@ the measured checklists are [`scorecard.md`](scorecard.md) (`cargo xtask scoreca | Number | Value | Kind | |---|---|---| +| 2026-10-29 | minor | Stability row: web build memory handling (#10024), memory checks before big operations, a low-memory warning and a stopped page for a trap; no percentage or estimate changed | | **Feature breadth** (does each Photoshop feature exist?) | **~76%** | partly measured (menus, tools, panels, formats), weights below | | **Ready for real work** (could a professional replace Photoshop with it?) | **~45%** (range 40–50%) | estimated, weights and evidence below | | **Mainstream practitioner** (typical pro, weekly areas only) | **~43%** (38–48%) | estimated, [method](#mainstream-practitioner-43) | @@ -79,7 +80,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 11 of 183 preferences do nothing (scorecard, 2026-11-01; was 12 of 183); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | -| Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | +| Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; web build: Place, Image Size, smart-object re-render and Save are checked against available memory, a low-memory warning and a "stopped" page replace a silent freeze (2026-10-29, #10024); but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); Help › Check for Updates plus an opt-out check once per launch (DIST-220-1 partial, no periodic recheck) | [scorecard.md](scorecard.md#distribution) | From c441fd350c1f9880922ffeaddffa54bbd8764adb Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 03:10:33 +0530 Subject: [PATCH 55/63] perf(paint): sampled, textured and wet-edge dabs rasterize on all cores (#11369) * perf(paint): sampled, textured and wet-edge dabs rasterize on all cores (#11350) The general dab loop runs its rows on the thread pool, with the same pixels as serially. It reaches the pool from about 16,000 pixels, a quarter of the round path's threshold. * test(paint): the parallel-vs-serial depth test checks colour, not alpha, for painted pixels The opaque background's alpha (1.0) is 0.5 away from 0.5, so the check passed even if nothing was painted (CodeRabbit on #11369). Co-Authored-By: Claude Opus 5.5 * test(paint): read pixels with as_chunks (clippy chunks_exact_to_as_chunks) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/paint/examples/bench_brush.rs | 5 +- crates/paint/src/brush_tests.rs | 38 ++++ crates/paint/src/render.rs | 261 +++++++++++++++++++-------- crates/paint/src/render/reference.rs | 147 +++++++++++++++ 4 files changed, 371 insertions(+), 80 deletions(-) diff --git a/crates/paint/examples/bench_brush.rs b/crates/paint/examples/bench_brush.rs index efd9c010f1..d6d5567188 100644 --- a/crates/paint/examples/bench_brush.rs +++ b/crates/paint/examples/bench_brush.rs @@ -29,6 +29,7 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { let start = Instant::now(); let mut r = StrokeRenderer::new(brush, Some(fmt), 1.0); let mut worst = 0.0f64; + let (mut worst_push, mut worst_comp) = (0.0f64, 0.0f64); let mut chunks = 0; let (mut t_push, mut t_comp) = (0.0f64, 0.0f64); for ch in pts.chunks(16) { @@ -40,13 +41,15 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { t_push += tp; t_comp += tt - tp; worst = worst.max(tt); + worst_push = worst_push.max(tp); + worst_comp = worst_comp.max(tt - tp); chunks += 1; } r.finish(); r.composite(&pre, &mut s, None, false, false); let total = start.elapsed().as_secs_f64() * 1000.0; println!( - "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms", + "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms worst push/chunk {worst_push:>6.2} ms worst composite/chunk {worst_comp:>6.2} ms", r.dab_count(), total / chunks as f64 ); diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 8b024de5a6..268ca52b95 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1874,6 +1874,44 @@ fn large_tips_render_the_same_in_parallel_and_serially() { } } +/// The general loop (sampled tips, per-tip textures) paints the same pixels on the thread pool +/// and serially, at 8-bit, 16-bit and float depth. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_strokes_render_the_same_in_parallel_and_serially_at_every_depth() { + let tip = GrayTile::from_fn(16, 16, |x, y| if (x + y) % 3 == 0 { 1.0 } else { 0.4 }); + let mut textured = BrushSettings { size: 120.0, hardness: 0.3, ..brush() }; + textured.texture = Texture { enabled: true, pattern: Pattern::Tile(tip.clone()), each_tip: true, ..Default::default() }; + let strokes = [BrushSettings { size: 120.0, tip: TipShape::Sampled(tip), color: [0.9, 0.2, 0.1, 1.0], ..brush() }, textured]; + let full = Rect::new(0, 0, 400, 200); + let formats = [PixelFormat::RGBA8, PixelFormat::RGBA8.with_sample(SampleType::U16), PixelFormat::RGBA32F]; + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS); + } + } + let _restore = Restore; + for fmt in formats { + for (i, b) in strokes.iter().enumerate() { + let render = |parallel_from: usize| -> Vec { + crate::render::set_parallel_min_pixels(parallel_from); + let mut s = Surface::new(fmt); + s.fill_rect(full, &[0.5, 0.5, 0.5, 1.0]); + render_stroke(&mut s, b, &line(80.0, 320.0, 100.0), None, false, 1.0); + s.read_region(full) + }; + let parallel = render(0); + let serial = render(usize::MAX); + assert_eq!(parallel.len(), serial.len()); + let diff = parallel.iter().zip(&serial).filter(|(a, b)| a.to_bits() != b.to_bits()).count(); + assert_eq!(diff, 0, "stroke {i} at {fmt:?}: {diff} pixels differ between the parallel and the serial render"); + // Colour channels only: the opaque background's alpha (1.0) is always 0.5 away from 0.5. + assert!(parallel.as_chunks::<4>().0.iter().any(|px| px[..3].iter().any(|v| (*v - 0.5).abs() > 0.1)), "stroke {i} at {fmt:?} painted something"); + } + } +} + /// A CMYK document with its own profile: blend modes other than Normal convert each pixel through /// that profile, which is active per thread, so a stroke composited in parallel gives the pixels the /// serial path gives (the workers enter the caller's CMYK space too). diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 6a679decf6..66cd382876 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -48,6 +48,11 @@ pub const COV_TILE: i32 = 64; #[cfg(not(target_arch = "wasm32"))] pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256; +/// The general loop (sampled tips, noise, textures, wet edges) reaches the thread pool at a quarter +/// of the pixels: at about 9-12 ns a pixel, threads already halve a dab of 16,000 pixels or more. +#[cfg(not(target_arch = "wasm32"))] +const GENERAL_LOOP_COST: usize = 4; + /// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it). #[cfg(not(target_arch = "wasm32"))] static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS); @@ -325,16 +330,6 @@ impl BrushContext { } None => (0, 1.0, 1.0, 1.0), }; - // A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. - let to_tip = |dx: f32, dy: f32| { - let (mut ux, mut uy) = (dx, -dy); - if let Some((ax, ay, k)) = proj { - let t = (ux * ax + uy * ay) * k; - ux += t * ax; - uy += t * ay; - } - ((ux * cs + uy * sn) * fx, (-ux * sn + uy * cs) * fy) - }; // A tip a few pixels across takes each pixel's covered area (a 4×4 grid inside it): // sampling only the pixel centre made a tiny dab's total coverage swing with its sub-pixel // position, and thin lines beaded. @@ -360,75 +355,59 @@ impl BrushContext { rasterize_round(d, rect, out, profile, aliased); return; } - for yy in 0..h { - let y = rect.y0 + yy as i32; - let dy = y as f32 + 0.5 - cy; - for xx in 0..w { - let x = rect.x0 + xx as i32; - let dx = x as f32 + 0.5 - cx; - let (u, v) = to_tip(dx, dy); - let (mut val, rn) = match mips { - // Block: the whole-pixel square, hard (the Eraser's Block mode). - _ if aliased && b.square => { - if dx.abs() >= r || dy.abs() >= r { - continue; - } - (1.0, 0.0) - } - None if small => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= small_reach2 { - continue; - } - let mut sum = 0.0; - for sy in SUBPIXEL { - for sx in SUBPIXEL { - let (su, sv) = to_tip(dx + sx, dy + sy); - let sd = ((su * ro).powi(2) + sv * sv).sqrt(); - // Each sample takes the tip's own edge, so the covered area - // changes smoothly with the dab's position. - sum += profile.falloff(sd); - } - } - (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) - } - None => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= reach2 { - continue; - } - let dd = d2.sqrt(); - let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; - (val, dd / rm) - } - Some(m) => { - let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); - if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { - continue; - } - let s = m.sample_clamped(level, px / tw, py / th); - let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; - (val, 1.0 - s) - } - }; - if val <= 0.0 { - continue; - } - if noise && val < 1.0 { - let n = hash2(x, y, NOISE_SALT); - let hard = if n < val { 1.0 } else { 0.0 }; - val += (hard - val) * 0.7; - } - if tex_tip { - val = texture_combine(b.texture.mode, val, self.texture_at(x, y), d.depth); - } - if wet { - val *= wet_factor(rn); - } - if let Some(slot) = depth.as_deref_mut().and_then(|dp| dp.get_mut(yy * w + xx)) { - *slot = (1.0 - rn).clamp(0.0, 1.0); - } - out[yy * w + xx] = val * d.alpha; + if w == 0 { + return; + } + let tip = GeneralTip { + ctx: self, + d, + mips, + x0: rect.x0, + y0: rect.y0, + aliased, + small, + noise, + tex_tip, + wet, + cx, + cy, + sn, + cs, + fx, + fy, + proj, + r, + ro, + rm, + profile, + reach2, + small_reach2, + level, + inv_scale, + tw, + th, + }; + // Rows are independent (noise is a pure hash of position, everything else is read-only), + // so the pixels are the same whether they run in parallel or not. The serial path stays a + // plain loop over an inlined row (a closure called from several sites cost a small dab a + // tenth of its time). + #[cfg(not(target_arch = "wasm32"))] + if parallel(out.len().saturating_mul(GENERAL_LOOP_COST)) { + use rayon::prelude::*; + if let Some(dp) = depth.as_deref_mut() { + out.par_chunks_exact_mut(w).zip(dp.par_chunks_exact_mut(w)).enumerate().for_each(|(yy, (row, drow))| tip.row(yy, row, Some(drow))); + } else { + out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row, None)); + } + return; + } + if let Some(dp) = depth { + for (yy, (row, drow)) in out.chunks_exact_mut(w).zip(dp.chunks_exact_mut(w)).enumerate() { + tip.row(yy, row, Some(drow)); + } + } else { + for (yy, row) in out.chunks_exact_mut(w).enumerate() { + tip.row(yy, row, None); } } } @@ -1361,6 +1340,130 @@ impl RoundTip { } } +/// The general rasterizer's per-dab state, one row at a time (see [`BrushContext::rasterize_into`]). +#[derive(Clone, Copy)] +struct GeneralTip<'a> { + ctx: &'a BrushContext, + d: &'a Dab, + mips: Option<&'a Mips>, + x0: i32, + y0: i32, + aliased: bool, + small: bool, + noise: bool, + tex_tip: bool, + wet: bool, + cx: f32, + cy: f32, + sn: f32, + cs: f32, + fx: f32, + fy: f32, + proj: Option<(f32, f32, f32)>, + r: f32, + ro: f32, + rm: f32, + profile: TipProfile, + reach2: f32, + small_reach2: f32, + level: usize, + inv_scale: f32, + tw: f32, + th: f32, +} + +impl GeneralTip<'_> { + /// A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. + #[inline(always)] + fn to_tip(self, dx: f32, dy: f32) -> (f32, f32) { + let (mut ux, mut uy) = (dx, -dy); + if let Some((ax, ay, k)) = self.proj { + let t = (ux * ax + uy * ay) * k; + ux += t * ax; + uy += t * ay; + } + ((ux * self.cs + uy * self.sn) * self.fx, (-ux * self.sn + uy * self.cs) * self.fy) + } + + /// Row `yy` of the dab's rectangle into `row` (and its depth into `depth_row`). + #[inline(always)] + fn row(self, yy: usize, row: &mut [f32], mut depth_row: Option<&mut [f32]>) { + let Self { + ctx, d, mips, x0, y0, aliased, small, noise, tex_tip, wet, cx, cy, r, ro, rm, profile, reach2, small_reach2, level, inv_scale, tw, th, .. + } = self; + let b = &ctx.brush; + let y = y0 + yy as i32; + let dy = y as f32 + 0.5 - cy; + for (xx, dst) in row.iter_mut().enumerate() { + let x = x0 + xx as i32; + let dx = x as f32 + 0.5 - cx; + let (u, v) = self.to_tip(dx, dy); + let (mut val, rn) = match mips { + // Block: the whole-pixel square, hard (the Eraser's Block mode). + _ if aliased && b.square => { + if dx.abs() >= r || dy.abs() >= r { + continue; + } + (1.0, 0.0) + } + None if small => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= small_reach2 { + continue; + } + let mut sum = 0.0; + for sy in SUBPIXEL { + for sx in SUBPIXEL { + let (su, sv) = self.to_tip(dx + sx, dy + sy); + let sd = ((su * ro).powi(2) + sv * sv).sqrt(); + // Each sample takes the tip's own edge, so the covered area + // changes smoothly with the dab's position. + sum += profile.falloff(sd); + } + } + (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) + } + None => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= reach2 { + continue; + } + let dd = d2.sqrt(); + let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; + (val, dd / rm) + } + Some(m) => { + let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); + if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { + continue; + } + let s = m.sample_clamped(level, px / tw, py / th); + let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; + (val, 1.0 - s) + } + }; + if val <= 0.0 { + continue; + } + if noise && val < 1.0 { + let n = hash2(x, y, NOISE_SALT); + let hard = if n < val { 1.0 } else { 0.0 }; + val += (hard - val) * 0.7; + } + if tex_tip { + val = texture_combine(b.texture.mode, val, ctx.texture_at(x, y), d.depth); + } + if wet { + val *= wet_factor(rn); + } + if let Some(slot) = depth_row.as_deref_mut().and_then(|dp| dp.get_mut(xx)) { + *slot = (1.0 - rn).clamp(0.0, 1.0); + } + *dst = val * d.alpha; + } + } +} + #[cfg(test)] mod reference; diff --git a/crates/paint/src/render/reference.rs b/crates/paint/src/render/reference.rs index e10fdd099f..c7dc366bb2 100644 --- a/crates/paint/src/render/reference.rs +++ b/crates/paint/src/render/reference.rs @@ -322,6 +322,153 @@ fn cached_profiles_match_scalar_for_small_and_transformed_dabs() { } } +/// Dabs that take the general loop, each with the context and flags that select a feature. +#[cfg(not(target_arch = "wasm32"))] +fn general_loop_cases() -> Vec<(&'static str, BrushContext, Dab, bool, bool)> { + let tip = GrayTile::from_fn(16, 12, |x, y| ((x + 2 * y) % 7) as f32 / 6.0 + 0.1); + let base = || BrushSettings { hardness: 0.25, ..Default::default() }; + let dab = |angle: f32, roundness: f32, proj_scale: f32| { + let mut d = Dab::round(Point::new(50.3, 60.7), 40.0, 0.8); + d.angle = angle; + d.roundness = roundness; + d.proj_scale = proj_scale; + d.proj_angle = 0.3; + d + }; + let ctx = |b: BrushSettings| BrushContext::new(&b); + vec![ + ("sampled", ctx(BrushSettings { tip: TipShape::Sampled(tip.clone()), ..base() }), dab(0.4, 1.0, 1.0), false, false), + ( + "per-tip texture", + ctx({ + let mut b = base(); + b.texture.enabled = true; + b.texture.each_tip = true; + b.texture.pattern = Pattern::Tile(tip.clone()); + b + }), + dab(0.0, 1.0, 1.0), + false, + false, + ), + ("noise", ctx(BrushSettings { noise: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges with depth", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, true), + ("rotated ellipse", ctx(base()), dab(0.7, 0.4, 1.0), false, false), + ("projection", ctx(base()), dab(0.0, 1.0, 0.6), false, false), + ( + "dual tip", + ctx({ + let mut b = base(); + b.dual_brush.enabled = true; + b.dual_brush.tip = TipShape::Sampled(tip.clone()); + b + }), + dab(0.2, 1.0, 1.0), + true, + false, + ), + ] +} + +#[cfg(not(target_arch = "wasm32"))] +struct RestoreThreshold; + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for RestoreThreshold { + fn drop(&mut self) { + set_parallel_min_pixels(DEFAULT_PARALLEL_MIN_PIXELS); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn bits(v: &[f32]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() +} + +/// Rows of the general loop run on the thread pool from a quarter of the pixels the round path +/// needs, and give the bits the serial loop gives. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_rows_match_serially_and_in_parallel() { + let _restore = RestoreThreshold; + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + assert!(rect.height() > 8, "{label}: many rows"); + let run = |threshold: usize| { + set_parallel_min_pixels(threshold); + let (mut out, mut depth) = (vec![0.75; 7], vec![0.25; 5]); + ctx.rasterize_into(&d, dual, rect, &mut out, with_depth.then_some(&mut depth)); + if !with_depth { + depth.clear(); + } + (out, depth) + }; + let (par, par_depth) = run(0); + let (ser, ser_depth) = run(usize::MAX); + assert_eq!(bits(&par), bits(&ser), "{label}: coverage differs between parallel and serial"); + assert_eq!(bits(&par_depth), bits(&ser_depth), "{label}: depth differs between parallel and serial"); + assert!(par.iter().any(|v| *v != 0.0), "{label}: painted something"); + if with_depth { + assert!(par_depth.iter().any(|v| *v != 0.0), "{label}: depth written"); + } + } +} + +/// The parallel general loop gives the bits of the frozen serial loop. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn parallel_general_loop_matches_the_frozen_reference() { + let _restore = RestoreThreshold; + set_parallel_min_pixels(0); + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + let (mut actual, mut expected) = (Vec::new(), Vec::new()); + let (mut actual_depth, mut expected_depth) = (Vec::new(), Vec::new()); + ctx.rasterize_into(&d, dual, rect, &mut actual, with_depth.then_some(&mut actual_depth)); + ctx.rasterize_reference(&d, dual, rect, &mut expected, with_depth.then_some(&mut expected_depth)); + assert_eq!(bits(&actual), bits(&expected), "{label}: coverage differs from the reference"); + assert_eq!(bits(&actual_depth), bits(&expected_depth), "{label}: depth differs from the reference"); + assert!(actual.iter().any(|v| *v != 0.0), "{label}: painted something"); + } +} + +/// Empty rects and non-finite dabs return normally from the general loop at either threshold. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_survives_empty_rects_and_nan_dabs() { + let _restore = RestoreThreshold; + let tip = GrayTile::from_fn(8, 5, |x, y| ((x + y) % 7) as f32 / 6.0); + let sampled = BrushContext::new(&BrushSettings { tip: TipShape::Sampled(tip), ..Default::default() }); + let plain = BrushContext::new(&BrushSettings::default()); + for threshold in [0, usize::MAX] { + set_parallel_min_pixels(threshold); + for ctx in [&sampled, &plain] { + for rect in [Rect::new(0, 0, 0, 5), Rect::new(0, 0, 5, 0), Rect::new(0, 0, 0, 0)] { + for with_depth in [false, true] { + let (mut out, mut depth) = (vec![0.5; 9], vec![0.5; 9]); + ctx.rasterize_into(&Dab::round(Point::new(2.0, 2.0), 6.0, 1.0), false, rect, &mut out, with_depth.then_some(&mut depth)); + assert!(out.is_empty()); + if with_depth { + assert!(depth.is_empty()); + } + } + } + } + for field in 0..2 { + let mut d = Dab::round(Point::new(0.0, 0.0), 6.0, 1.0); + if field == 0 { + d.radius = f32::NAN; + } else { + d.angle = f32::NAN; + } + let mut out = Vec::new(); + sampled.rasterize_into(&d, false, Rect::new(-4, -4, 4, 4), &mut out, None); + assert_eq!(out.len(), 64); + } + } +} + // Keep the pre-cache scalar coverage equations independent of TipProfile. fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { let (r0, s) = tip_shape(radius, hardness); From 5c157e8400874ef4a7b234ae55b1fe5af7a1b944 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 03:19:12 +0530 Subject: [PATCH 56/63] Build the GPU compositor off the UI thread (#9969) * Prepare GPU compositor in background * Add compositor startup performance scenarios * Generate scorecard in scratch CI * Fix pipeline build type inference * Address compositor startup review and document measurements * Run startup performance checks on scratch CI * Remove scratch performance job * Count failed GPU attempts in frame timing * Make shader fingerprint test portable * Address review: run first-document scenario as a guarded group; share COMPOSITOR_BUILDING constant * Handle compositor startup benchmark failures * Fix frame timing after upstream merge * Keep GPU startup failures on CPU fallback * Record the failed GPU attempt of a deferred redraw, and bound --reps A redraw deferred after a failed GPU composite carries that attempt's time to its end, where it is recorded once beside the CPU time. Perf::record clears the attempt so a direct CPU refresh doesn't inherit the last one's. perf_scenarios caps --reps. * Refresh cached canvases after compositor build * Expose canvas cache key to GPU completion handler * ci: drop the GPU compositor probe job AGENTS.md: feature and fix PRs don't add or change CI, and app workflows only call the shared craft-common-actions workflows. The probe example stays as a manual tool (cargo run -p photocraft-gpu --example compositor_build_probe). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/photocraft-web/src/web.rs | 3 + apps/photocraft/src/main.rs | 1 + apps/photocraft/src/services.rs | 43 ++++ crates/gpu/examples/compositor_build_probe.rs | 189 ++++++++++++++++++ crates/gpu/src/lib.rs | 16 ++ crates/gpu/src/setup.rs | 94 ++++++++- crates/gpu/src/tests.rs | 6 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/examples/perf_scenarios.rs | 91 ++++++++- crates/ui-egui/src/app/gpu.rs | 75 +++++++ crates/ui-egui/src/canvas.rs | 1 - .../ui-egui/src/canvas/background_redraw.rs | 24 ++- .../src/canvas/background_redraw/tests.rs | 28 +++ crates/ui-egui/src/canvas/gpu_canvas.rs | 110 +++++++++- .../ui-egui/src/canvas/gpu_canvas/budget.rs | 3 + .../src/canvas/gpu_canvas/compositor_build.rs | 76 +++++++ .../src/canvas/gpu_canvas/mip_tests.rs | 34 ++++ crates/ui-egui/src/canvas/gpu_canvas/perf.rs | 13 ++ crates/ui-egui/src/canvas/gpu_canvas/setup.rs | 2 + crates/ui-egui/src/canvas/gpu_canvas/tests.rs | 27 +++ crates/ui-egui/src/canvas/textures.rs | 2 +- crates/ui-egui/src/gpu_memo.rs | 178 +++++++++++++++++ crates/ui-egui/src/gpu_status.rs | 11 +- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/notices.rs | 14 +- crates/ui-egui/src/render_path.rs | 7 + crates/ui-egui/src/services.rs | 5 + docs/gaps.md | 4 +- docs/hardware-parity.md | 4 +- docs/scorecard.md | 4 +- perf/budgets.toml | 22 ++ 53 files changed, 1105 insertions(+), 27 deletions(-) create mode 100644 crates/gpu/examples/compositor_build_probe.rs create mode 100644 crates/ui-egui/src/canvas/gpu_canvas/compositor_build.rs create mode 100644 crates/ui-egui/src/gpu_memo.rs diff --git a/Cargo.lock b/Cargo.lock index 499f8a4694..dc725c05f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5564,6 +5564,7 @@ name = "photocraft-ui-egui" version = "0.20.1" dependencies = [ "base64 0.23.1", + "chrono", "craft-print", "craft-ui", "craft-update", diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 806a275509..54cf56adc5 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -542,6 +542,9 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), + load_gpu_memo: None, + save_gpu_memo: None, + shader_compiler: None, ..Default::default() } } diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 75eb0ea83a..41d2faedee 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -588,6 +588,7 @@ fn main() -> eframe::Result { && app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu { app.set_wgpu(rs); + app.start_compositor(&cc.egui_ctx); } else { // The window still draws with wgpu: record its errors instead of panicking, // and notice when the device is lost (#3438). diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 7741a8d8a3..545fc86eec 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -341,6 +341,41 @@ pub fn prefs_file() -> Option { config_dir().map(|d| d.join("preferences.json")) } +fn gpu_memo_file() -> Option { + config_dir().map(|d| d.join("gpu-compositor.json")) +} + +fn load_gpu_memo_text() -> Option { + use std::io::Read; + let file = std::fs::File::open(gpu_memo_file()?).ok()?; + let cap = photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES; + let mut bytes = Vec::new(); + file.take((cap + 1) as u64).read_to_end(&mut bytes).ok()?; + if bytes.len() > cap { + log::info!("ignoring oversized GPU compositor memo"); + return None; + } + String::from_utf8(bytes).ok() +} + +fn shader_compiler_identity() -> String { + #[cfg(target_os = "windows")] + { + let exe = std::env::current_exe().ok(); + let exe_dir = exe.as_deref().and_then(Path::parent); + match crate::gpu_startup::dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref(), exe_dir) { + eframe::wgpu::Dx12Compiler::Fxc => "fxc".into(), + eframe::wgpu::Dx12Compiler::DynamicDxc { dxc_path } => format!("dxc:{dxc_path}"), + eframe::wgpu::Dx12Compiler::StaticDxc => "static-dxc".into(), + eframe::wgpu::Dx12Compiler::Auto => "auto".into(), + } + } + #[cfg(not(target_os = "windows"))] + { + String::new() + } +} + /// The brush preset store (one file per preset group plus tip bitmaps; see /// `photocraft_engine::preset_store`). pub fn presets_dir() -> Option { @@ -791,6 +826,14 @@ pub fn native(automation: Option) -> load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), + load_gpu_memo: Some(Box::new(load_gpu_memo_text)), + save_gpu_memo: Some(Box::new(|text: &str| { + if text.len() > photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES { + return Err("GPU compositor memo exceeds 64 KiB".into()); + } + write_atomic(&gpu_memo_file().ok_or("no config directory")?, text.as_bytes()) + })), + shader_compiler: Some(shader_compiler_identity()), system_theme: crate::appearance::service(), append_text: Some(Box::new(|path: &str, text: &str| { use std::io::Write; diff --git a/crates/gpu/examples/compositor_build_probe.rs b/crates/gpu/examples/compositor_build_probe.rs new file mode 100644 index 0000000000..932057f1fc --- /dev/null +++ b/crates/gpu/examples/compositor_build_probe.rs @@ -0,0 +1,189 @@ +//! Measure compositor pipeline construction and, optionally, device work during a background build. +//! +//! `cargo run --release -p photocraft-gpu --example compositor_build_probe -- [dx12|vulkan|metal|gl] [fxc|dxc] [rounds=3] [threads=N] [--background] [--json out.json]` +//! +//! The explicit thread count is passed to the dedicated Rayon build pool. + +use photocraft_gpu::Compositor; +use std::fs; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Sample { + build_ms: f64, + ok: bool, + error: Option, +} + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let backend = args.first().map(String::as_str).unwrap_or("dx12"); + let compiler = args.get(1).map(String::as_str).unwrap_or("fxc"); + let rounds = args.iter().find_map(|a| a.strip_prefix("rounds=").and_then(|s| s.parse::().ok())).unwrap_or(3).max(1); + let default_threads = std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)); + let threads = args.iter().find_map(|a| a.strip_prefix("threads=").and_then(|s| s.parse::().ok())).unwrap_or(default_threads).max(1); + let background = args.iter().any(|a| a == "--background"); + let json_path = args.iter().position(|a| a == "--json").and_then(|i| args.get(i + 1)); + let backends = match backend { + "vulkan" => wgpu::Backends::VULKAN, + "metal" => wgpu::Backends::METAL, + "gl" => wgpu::Backends::GL, + _ => wgpu::Backends::DX12, + }; + let shader_compiler = match compiler { + "dxc" => wgpu::Dx12Compiler::default_dynamic_dxc(), + _ => wgpu::Dx12Compiler::Fxc, + }; + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); + descriptor.backends = backends; + descriptor.flags = wgpu::InstanceFlags::empty(); + descriptor.backend_options.dx12.shader_compiler = shader_compiler; + let instance = wgpu::Instance::new(descriptor); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { force_fallback_adapter: true, ..Default::default() }))?; + let info = adapter.get_info(); + println!("adapter={} backend={:?} driver={} {} threads={threads}", info.name, info.backend, info.driver, info.driver_info); + + let mut samples = Vec::with_capacity(rounds); + let mut stall = Vec::new(); + let mut cpu_idle_ms = None; + let mut cpu_during_ms = None; + for round in 0..rounds { + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))?; + let format = Compositor::preferred_acc_format(&adapter); + if background { + if round == 0 { + let doc = photocraft_doc::Document::with_background( + "compositor build probe", + photocraft_geom::Size::new(1920, 1080), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::WHITE, + ); + let start = Instant::now(); + let _ = photocraft_compose::render(&doc, doc.bounds()); + cpu_idle_ms = Some(start.elapsed().as_secs_f64() * 1000.0); + } + let (build_tx, build_rx) = std::sync::mpsc::channel(); + let start = Instant::now(); + std::thread::scope(|scope| { + scope.spawn(|| { + let build_start = Instant::now(); + let result = Compositor::try_new_with_format_threads(&device, format, threads); + let elapsed = build_start.elapsed().as_secs_f64() * 1000.0; + let error = result.err().map(|e| e.to_string()); + let _ = build_tx.send((error, elapsed)); + }); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("compositor probe UI target"), + size: wgpu::Extent3d { width: 256, height: 256, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let mut last_cpu = start; + loop { + match build_rx.try_recv() { + Ok((error, build_ms)) => { + samples.push(Sample { build_ms, ok: error.is_none(), error }); + break; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + samples.push(Sample { build_ms: start.elapsed().as_secs_f64() * 1000.0, ok: false, error: Some("build thread terminated".into()) }); + break; + } + Err(std::sync::mpsc::TryRecvError::Empty) => (), + } + let tick = Instant::now(); + let mut encoder = device.create_command_encoder(&Default::default()); + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("probe frame"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + drop(_pass); + queue.submit([encoder.finish()]); + let _ = device.poll(wgpu::PollType::Poll); + stall.push(tick.elapsed().as_secs_f64() * 1000.0); + if round == 0 && cpu_during_ms.is_none() && last_cpu.elapsed() >= Duration::from_millis(100) { + let doc = photocraft_doc::Document::with_background( + "compositor build probe", + photocraft_geom::Size::new(1920, 1080), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::WHITE, + ); + let cpu_start = Instant::now(); + let _ = photocraft_compose::render(&doc, doc.bounds()); + cpu_during_ms = Some(cpu_start.elapsed().as_secs_f64() * 1000.0); + last_cpu = Instant::now(); + } + std::thread::sleep(Duration::from_millis(16)); + } + }); + } else { + let start = Instant::now(); + let result = Compositor::try_new_with_format_threads(&device, format, threads); + let build_ms = start.elapsed().as_secs_f64() * 1000.0; + let error = result.err().map(|e| e.to_string()); + samples.push(Sample { build_ms, ok: error.is_none(), error }); + } + let s = samples.last().ok_or("missing compositor build sample")?; + println!("round={round} build_ms={:.3} ok={} error={}", s.build_ms, s.ok, s.error.as_deref().unwrap_or("")); + } + + stall.sort_by(f64::total_cmp); + let p95 = stall.get((stall.len() * 95 / 100).min(stall.len().saturating_sub(1))).copied().unwrap_or(0.0); + let max = stall.last().copied().unwrap_or(0.0); + println!("ui_stall_p95_ms={p95:.3} ui_stall_max_ms={max:.3} cpu_idle_ms={cpu_idle_ms:?} cpu_during_ms={cpu_during_ms:?}"); + if let Some(path) = json_path { + let rounds_json = samples + .iter() + .map(|s| { + format!("{{\"build_ms\":{:.3},\"ok\":{},\"error\":{}}}", s.build_ms, s.ok, s.error.as_deref().map(json_string).unwrap_or_else(|| "null".into())) + }) + .collect::>() + .join(","); + let json = format!( + "{{\"backend\":{},\"compiler\":{},\"adapter\":{},\"threads\":{},\"rounds\":[{}],\"stall\":{{\"p95_ms\":{p95:.3},\"max_ms\":{max:.3}}},\"cpu_composite_ms\":{{\"idle\":{},\"during_build\":{}}}}}", + json_string(backend), + json_string(compiler), + json_string(&info.name), + threads, + rounds_json, + optional_number(cpu_idle_ms), + optional_number(cpu_during_ms) + ); + fs::write(path, json)?; + } + Ok(()) +} + +fn json_string(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", u32::from(c))), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn optional_number(n: Option) -> String { + n.map(|v| format!("{v:.3}")).unwrap_or_else(|| "null".into()) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ddeaa7a52d..e4f0890884 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -141,6 +141,7 @@ mod paging; #[cfg(not(target_arch = "wasm32"))] pub mod readback; mod setup; +pub use setup::{BuildFailure, BuildFailureKind}; mod textures; #[cfg(test)] @@ -167,5 +168,20 @@ use textures::TexKind; use textures::{CachedPattern, FxEntry, FxKey, ResKey, Resident, Tex}; pub use textures::{ChunkOut, Stats}; +/// Stable fingerprint of the compositor WGSL source, for invalidating cached build failures. +/// Uses 64-bit FNV-1a over the exact embedded source bytes. +pub fn shader_fingerprint() -> u64 { + fnv1a(SHADER.as_bytes()) +} + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x00000100000001b3); + } + hash +} + #[cfg(test)] mod tests; diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index f0527c8156..e84b2571e4 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -7,6 +7,40 @@ use super::{ Compositor, DeviceHealth, DocId, Document, Fault, HashMap, Kernel, LayerContent, Plan, Rect, Role, TILE_SIZE, Unsupported, fx, plan, prefix, timing, }; +/// Classification of a synchronous compositor pipeline build failure. +/// +/// Only a recognized shader compiler diagnostic is considered deterministic. Every other +/// error, including generic validation, internal, and out-of-memory errors, is conservative +/// and must be treated as transient or unknown by callers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildFailureKind { + /// A known compiler diagnostic means the same shader is expected to fail on relaunch. + DeterministicShaderCompilation, + /// The failure may depend on device state, resource pressure, or driver behavior. + TransientOrUnknown, +} + +/// A pipeline build error with a conservative retry classification. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildFailure { + pub kind: BuildFailureKind, + pub message: String, +} + +impl std::fmt::Display for BuildFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for BuildFailure {} + +fn classify_build_diagnostic(diagnostic: &str) -> BuildFailureKind { + // FXC's X3507 diagnostic is a concrete shader compilation failure observed when + // compiling generated compositor shaders. Do not classify generic validation text. + if diagnostic.contains("X3507") { BuildFailureKind::DeterministicShaderCompilation } else { BuildFailureKind::TransientOrUnknown } +} + impl Compositor { /// Create a compositor with the default accumulation format ([`ACC_FORMAT`]). pub fn new(device: &wgpu::Device) -> Self { @@ -26,17 +60,53 @@ impl Compositor { /// to the CPU compositor. Where errors only arrive asynchronously (WebGPU), creation is assumed /// to have worked. pub fn try_new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Result { - match first_error(device, || Self::build(device, acc_format)) { + match first_error(device, || Self::build(device, acc_format, false)) { ((comp, None), None) => Ok(comp), ((_, Some(e)), _) | (_, Some(e)) => Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))), } } + /// Build pipelines using a dedicated Rayon pool instead of the process-wide pool. + /// + /// This keeps shader compilation from occupying workers needed by CPU compositing. + /// If the pool cannot be created, pipeline enumeration runs sequentially on the calling + /// thread. The outer and per-pipeline wgpu error scopes are kept on their respective + /// build threads. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_new_with_format_threads(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result { + Self::try_new_with_format_threads_classified(device, acc_format, threads).map_err(|e| Unsupported(e.message)) + } + + /// Build the compositor pipelines with an explicit failure classification for callers + /// that cache deterministic failures across launches. Only recognized shader compiler + /// diagnostics are marked deterministic; all other wgpu errors are transient or unknown. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_new_with_format_threads_classified(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads.max(1)).thread_name(|index| format!("photocraft-gpu-build-{index}")).build(); + let built = match pool { + Ok(pool) => pool.install(|| first_error(device, || Self::build(device, acc_format, false))), + Err(e) => { + log::warn!("GPU compositor: couldn't create dedicated build pool; building sequentially: {e}"); + first_error(device, || Self::build(device, acc_format, true)) + } + }; + match built { + ((comp, None), None) => Ok(comp), + ((_, Some(e)), None) => { + let kind = classify_build_diagnostic(&e.to_string()); + Err(BuildFailure { kind, message: format!("couldn't build the compositor's pipelines: {e}") }) + } + (_, Some(e)) => { + Err(BuildFailure { kind: BuildFailureKind::TransientOrUnknown, message: format!("couldn't build the compositor's pipelines: {e}") }) + } + } + } + /// Create a compositor whose accumulation/render-target format is `acc_format`. Pass /// `Rgba16Float` on adapters that can't render to `Rgba32Float` (e.g. some Intel Vulkan drivers), /// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`. pub fn new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Self { - let (comp, error) = Self::build(device, acc_format); + let (comp, error) = Self::build(device, acc_format, false); if let Some(e) = error { log::error!("GPU compositor: couldn't build its pipelines: {e}"); } @@ -54,7 +124,7 @@ impl Compositor { /// while the cache still held it, and dropping the device then aborted the process. So each /// job has its own vertex entry point and fragment shaders ([`build_jobs`]), and builds of /// several compositors take turns. - fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> (Self, Option) { + fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat, sequential: bool) -> (Self, Option) { static BUILDING: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _building = BUILDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); @@ -98,7 +168,7 @@ impl Compositor { }; // Effect maps render to R32Float / R16Float, which some adapters (e.g. GL without float // render targets) can't: then layer effects use the CPU compositor and the rest stays here. - let built = fx::par_map(jobs, |job| { + let build_one = |job: super::kit::BuildJob| { job.keys .into_iter() .map(|key| { @@ -106,7 +176,8 @@ impl Compositor { (key, built, error) }) .collect::>() - }); + }; + let built: Vec<_> = if sequential { jobs.into_iter().map(build_one).collect() } else { fx::par_map(jobs, build_one) }; let built = built.into_iter().flatten(); let (mut pipelines, mut maps) = (HashMap::new(), Vec::new()); let (mut error, mut map_error) = (None, None); @@ -548,3 +619,16 @@ impl Compositor { self.timer.as_mut().and_then(timing::GpuTimer::take) } } + +#[cfg(test)] +mod build_failure_tests { + use super::{BuildFailureKind, classify_build_diagnostic}; + + #[test] + fn only_concrete_shader_compiler_diagnostic_is_deterministic() { + assert_eq!(classify_build_diagnostic("error X3507: loop control variable must be integer"), BuildFailureKind::DeterministicShaderCompilation); + assert_eq!(classify_build_diagnostic("Validation Error: invalid bind group"), BuildFailureKind::TransientOrUnknown); + assert_eq!(classify_build_diagnostic("out of memory"), BuildFailureKind::TransientOrUnknown); + assert_eq!(classify_build_diagnostic("shader compilation failed"), BuildFailureKind::TransientOrUnknown); + } +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 09fe6129e7..adca9ef4ef 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -426,3 +426,9 @@ fn colour_managed_tiles_match_the_per_pixel_conversion() { fn grid_rounds_out() { assert_eq!(tile_grid(Rect::new(0, 0, 300, 256)), Rect::new(0, 0, 512, 256)); } + +#[test] +fn shader_fingerprint_is_fnv1a_of_embedded_source() { + assert_eq!(super::fnv1a(b"hello"), 0xa430_d846_80aa_bd0b); + assert_eq!(super::shader_fingerprint(), super::fnv1a(super::SHADER.as_bytes())); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0af8e7ac17..26177c5e68 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true workspace = true [dependencies] +chrono = { workspace = true } craft-print = { workspace = true } craft-ui = { workspace = true } photocraft-geom = { workspace = true } diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index efa1300c73..0ba257defd 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -322,9 +322,13 @@ impl Bench { /// Reports a row. `splits` (one per sample, or none) adds the command / refresh split. fn record(&mut self, name: &str, s: &Session, samples: &[f64], splits: &[Split], result: Res<()>) { + self.record_with_gpu_bytes(name, samples, splits, result, self.gpu_bytes(s)); + } + + fn record_with_gpu_bytes(&mut self, name: &str, samples: &[f64], splits: &[Split], result: Res<()>, gpu_bytes: Option) { match result { Ok(()) => { - let mut r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s)); + let mut r = row(name, samples, self.sampler.peak(), gpu_bytes); let mut split = String::new(); if !splits.is_empty() && splits.len() == samples.len() { let cmd: Vec = splits.iter().map(|x| x.cmd_ms).collect(); @@ -404,8 +408,13 @@ struct Sizes { /// A huge document with one painted layer, refreshed zoomed out (#1015, #9330). The row name /// leaves out the size because `--quick` uses a smaller one. huge_doc: (u32, u32), + /// The new document whose compositor builds in the background. + first_document: (u32, u32), } +/// The most `--reps` a run takes: each sample is kept, and a mistyped count must not allocate for them all. +const MAX_REPS: usize = 10_000; + const FULL: Sizes = Sizes { layered: (5472, 3648), big_layer: (4243, 2828), @@ -418,6 +427,7 @@ const FULL: Sizes = Sizes { a4: (2480, 3508), huge_new: (78_740, 39_370), huge_doc: (30_000, 30_000), + first_document: (1920, 1080), }; const QUICK: Sizes = Sizes { @@ -432,6 +442,7 @@ const QUICK: Sizes = Sizes { a4: (1240, 1754), huge_new: (20_000, 10_000), huge_doc: (8_000, 8_000), + first_document: (640, 480), }; /// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke, @@ -498,7 +509,7 @@ fn main() { let args: Vec = std::env::args().collect(); let quick = args.iter().any(|a| a == "--quick"); let sz = if quick { &QUICK } else { &FULL }; - let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).max(1); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).clamp(1, MAX_REPS); let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() }; let no_reuse = args.iter().any(|a| a == "--no-composite-reuse"); if no_reuse && let Some((canvas, _)) = &gpu { @@ -526,7 +537,7 @@ fn main() { // A panic in one group (a crash the scenario found) is reported against the row that was // running, and the other groups still run. type Group = fn(&mut Bench, &Sizes); - let groups: [(&str, Group); 8] = [ + let groups: [(&str, Group); 9] = [ ("layered", layered_scenarios), ("many layers", many_layer_scenarios), ("full-canvas layers", full_canvas_layer_scenarios), @@ -534,6 +545,7 @@ fn main() { ("long operations", job_scenarios), ("algorithms", algorithm_scenarios), ("huge document", huge_document_scenarios), + ("first document", first_document_scenarios), ("redraw area", redraw_area_scenarios), ]; let use_gpu = b.gpu.is_some(); @@ -584,6 +596,79 @@ fn main() { } } +/// Startup costs when a new canvas builds its compositor in the background, as the app does. +fn first_document_scenarios(b: &mut Bench, sz: &Sizes) { + let (w, h) = sz.first_document; + const FIRST: &str = "first document: UI-thread time to first pixels"; + const READY: &str = "first document: GPU compositor ready"; + let Some((_, rs)) = b.gpu.as_ref() else { + println!("GPU compositor startup scenarios skipped: no GPU canvas"); + return; + }; + let rs = rs.clone(); + if rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu { + println!("GPU compositor startup scenarios skipped: software adapter"); + return; + } + for name in [FIRST, READY] { + if !b.wanted(name) { + continue; + } + b.current = Some(name.into()); + b.sampler.reset(); + let mut samples = Vec::with_capacity(b.reps); + let mut failure = None; + for _ in 0..b.reps { + let canvas = GpuCanvas::new(&rs); + let ctx = egui::Context::default(); + let started = Instant::now(); + canvas.start_compositor(&ctx); + let mut session = Session::new(); + if let Err(e) = exec(&mut session, "file.new", json!({"width": w, "height": h, "background": "white"})) { + failure = Some(e); + break; + } + let first_ms = if name == FIRST { + let Some(doc) = session.active().map(|d| &d.doc) else { + failure = Some("file.new did not create a document".into()); + break; + }; + canvas.refresh(doc.id.0, doc, None, None); + Some(started.elapsed().as_secs_f64() * 1000.0) + } else { + None + }; + // Wait before starting the next sample so builds cannot overlap or replace resources. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + if let Some(result) = canvas.take_compositor_completion() { + if let Err((message, _)) = result { + failure = Some(format!("GPU compositor build failed: {message}")); + } + break; + } + if std::time::Instant::now() >= deadline { + failure = Some("timed out waiting for GPU compositor build".into()); + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + if failure.is_some() { + break; + } + samples.push(first_ms.unwrap_or_else(|| started.elapsed().as_secs_f64() * 1000.0)); + } + if let Some(error) = failure { + println!("{name:<52} ERROR {error}"); + b.errors.push((name.into(), error)); + break; + } else { + // Each sample owns a separate canvas that has already been dropped. + b.record_with_gpu_bytes(name, &samples, &[], Ok(()), None); + } + } +} + /// #209 rows on the layered 20 MP document. fn layered_scenarios(b: &mut Bench, sz: &Sizes) { let (w, h) = sz.layered; diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 055a85e0fd..b38536ab6b 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -33,6 +33,80 @@ impl PhotocraftApp { self.prefs_rt.gpu_style = None; } + /// Begin preparing the compositor after the GPU canvas is initialized. + pub fn start_compositor(&mut self, ctx: &egui::Context) { + self.start_compositor_inner(ctx, false); + } + + pub(crate) fn retry_compositor(&mut self, ctx: &egui::Context) { + self.start_compositor_inner(ctx, true); + } + + fn start_compositor_inner(&mut self, ctx: &egui::Context, retry: bool) { + if self.session.prefs().performance.effective_rendering_mode() == photocraft_engine::prefs::RenderingMode::Cpu { + return; + } + let Some(gpu) = self.gpu.clone() else { return }; + let compiler = self.services.shader_compiler.as_deref().unwrap_or(""); + let key = gpu.compositor_memo_key(compiler); + let mut memo = self.load_gpu_memo(); + let bypass = retry || std::env::var("PHOTOCRAFT_GPU_REBUILD").is_ok_and(|v| v == "1"); + if bypass { + if memo.remove(&key) { + self.save_gpu_memo(&memo); + } + } else if let Some(entry) = memo.get(&key) { + gpu.remember_compositor_failure(&entry.error); + return; + } + gpu.start_compositor(ctx); + } + + fn load_gpu_memo(&mut self) -> crate::gpu_memo::GpuMemo { + self.services + .load_gpu_memo + .as_mut() + .and_then(|load| load()) + .and_then(|text| crate::gpu_memo::GpuMemo::parse(&text).map_err(|e| log::info!("ignoring GPU compositor memo: {e}")).ok()) + .unwrap_or_default() + } + + fn save_gpu_memo(&mut self, memo: &crate::gpu_memo::GpuMemo) { + if let (Ok(text), Some(save)) = (memo.serialize(), self.services.save_gpu_memo.as_mut()) + && let Err(error) = save(&text) + { + log::info!("could not save GPU compositor memo: {error}"); + } + } + + fn record_compositor_completion(&mut self) { + let Some(gpu) = self.gpu.clone() else { return }; + let Some(result) = gpu.take_compositor_completion() else { return }; + // A completed CPU redraw can leave a current texture and a temporary build reason. + // Recompute those documents on their next repaint, including any hidden documents. + for id in self.render_path.compositor_building_docs() { + if let Some(doc) = self.session.documents().iter().find(|st| st.doc.id.0 == id).map(|st| st.doc.id) { + crate::canvas::background_redraw::cancel(self, doc); + self.canvases.remove(&(doc, crate::canvas::GPU_OUTPUT)); + } + } + let compiler = self.services.shader_compiler.as_deref().unwrap_or(""); + let key = gpu.compositor_memo_key(compiler); + let mut memo = self.load_gpu_memo(); + let changed = match result { + Ok(()) => memo.remove(&key), + Err((error, true)) if gpu.fault().is_none() => { + let now: chrono::DateTime = std::time::SystemTime::now().into(); + memo.remember(key, error, now.to_rfc3339()); + true + } + Err(_) => false, + }; + if changed { + self.save_gpu_memo(&memo); + } + } + /// Whether the document canvas currently draws on the GPU (false on the CPU path, and after /// the device was lost). pub fn gpu_active(&self) -> bool { @@ -72,5 +146,6 @@ impl PhotocraftApp { /// the CPU canvas after a device loss and runs the started hook. pub fn check_gpu(&mut self, ctx: &egui::Context) { gpu_status::check(self, ctx); + self.record_compositor_completion(); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318242b504..51c9714075 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -64,7 +64,6 @@ pub mod zoom_window_fit; pub(crate) use cursors::eyedropper_ring_colors; pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_cursor, pen_cursor, pencil_cursor_rect, pencil_tip_is_round, pipette_cursor}; use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, visible_crosshair}; -#[cfg(test)] pub(crate) use display::GPU_OUTPUT; use display::texture_key; pub(crate) use display::{cache_key, canvas_display, display_doc, display_image}; diff --git a/crates/ui-egui/src/canvas/background_redraw.rs b/crates/ui-egui/src/canvas/background_redraw.rs index 003d250ab3..042aac8e36 100644 --- a/crates/ui-egui/src/canvas/background_redraw.rs +++ b/crates/ui-egui/src/canvas/background_redraw.rs @@ -111,6 +111,9 @@ pub(crate) struct Redraw { stop: Arc, started_ms: f64, fallback: Option, + /// Time spent on the failed GPU composite attempts that led to this redraw (ms), recorded once + /// when it ends. + gpu_attempt_ms: f64, /// The state the blocks are drawn from, and what the texels depend on besides it. doc: Arc, display: u64, @@ -286,6 +289,7 @@ pub(crate) fn start( visible: Rect, region: Rect, fallback: Option, + gpu_attempt_ms: f64, ) { let all = blocks(doc.bounds()); let display_key = display.as_deref().map_or(0, |d| d.texture_key); @@ -293,15 +297,21 @@ pub(crate) fn start( // Taken again when a running redraw continues: the edit (or Memory Usage) may have changed it. let cap = keep_cap(app, doc.id); app.kept.trim(cap); - let (mut pending, since, fallback, mut keep) = match app.redraws.remove(&doc.id) { + let (mut pending, since, fallback, gpu_attempt_ms, mut keep) = match app.redraws.remove(&doc.id) { Some(mut old) if old.blocks == all => { // Blocks drawn from another state aren't part of this one's composite. let same = old.display == display_key && old.text_gamma == gamma && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc); let (collected, bytes) = if same && old.collected_bytes <= cap { (std::mem::take(&mut old.collected), old.collected_bytes) } else { (vec![None; all.len()], 0) }; - (std::mem::take(&mut old.pending), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.take()), (collected, bytes, cap)) + ( + std::mem::take(&mut old.pending), + (old.started_ms, old.drawn), + fallback.or_else(|| old.fallback.take()), + gpu_attempt_ms + old.gpu_attempt_ms, + (collected, bytes, cap), + ) } - _ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback, (vec![None; all.len()], 0, cap)), + _ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback, gpu_attempt_ms, (vec![None; all.len()], 0, cap)), }; for (p, b) in pending.iter_mut().zip(&all) { *p |= !b.intersect(®ion).is_empty(); @@ -328,14 +338,14 @@ pub(crate) fn start( } keep = (vec![None; all.len()], 0, keep.2); } - spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, keep }); + spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, gpu_attempt_ms, keep }); } /// After an edit drew its own damage: continue document `doc.id`'s redraw, if one is running, on /// the edited document, so blocks still to come don't bring back what was there before it. pub(crate) fn restart(app: &mut PhotocraftApp, ctx: &egui::Context, gpu: &GpuCanvas, doc: Arc, display: Option>, visible: Rect) { if app.redraws.contains_key(&doc.id) { - start(app, ctx, gpu, doc, display, visible, Rect::EMPTY, None); + start(app, ctx, gpu, doc, display, visible, Rect::EMPTY, None, 0.0); } } @@ -384,6 +394,7 @@ pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { } let ms = crate::gpu_canvas::now_ms() - r.started_ms; app.perf.record("background", r.drawn, ms, 0.0); + app.perf.record_gpu_attempt(r.gpu_attempt_ms); crate::notices::slow_cpu_refresh(app, doc.0, ms, r.fallback.as_ref()); } @@ -455,6 +466,7 @@ struct Spawn { /// When it started and the pixels uploaded so far. since: (f64, u64), fallback: Option, + gpu_attempt_ms: f64, /// Blocks collected for [`Kept`], their bytes, and the cap. keep: (Vec>>, u64, u64), } @@ -471,6 +483,7 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) { pending, since: (started_ms, drawn), fallback, + gpu_attempt_ms, keep: (collected, collected_bytes, cap), } = s; let id = doc.id; @@ -512,6 +525,7 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) { stop, started_ms, fallback, + gpu_attempt_ms, doc: kept_doc, display: display_key, text_gamma, diff --git a/crates/ui-egui/src/canvas/background_redraw/tests.rs b/crates/ui-egui/src/canvas/background_redraw/tests.rs index ce47c0c003..6176ea0e26 100644 --- a/crates/ui-egui/src/canvas/background_redraw/tests.rs +++ b/crates/ui-egui/src/canvas/background_redraw/tests.rs @@ -599,6 +599,34 @@ fn a_compositor_build_failure_shows_its_reason() { assert!(g.harness.state().perf.gpu_fallback.as_deref().is_some_and(|f| f.contains("X3507"))); } +#[test] +fn completed_compositor_build_refreshes_an_unchanged_cached_document() { + let _gpu = photocraft_testkit::gpu_lock(); + for success in [true, false] { + let Some(mut g) = gpu_app() else { return }; + let gpu = g.harness.state().gpu.as_ref().expect("GPU canvas").clone(); + gpu.simulate_compositor_build(); + g.harness.state_mut().run("file.new", json!({"width": 400, "height": 300, "background": "#336699"})).expect("new"); + g.harness.step(); + settle(&mut g); + let doc = g.harness.state().session.active().expect("document").doc.id; + let revision = g.harness.state().session.active().expect("document").revision; + assert_eq!(render_path_of(&g).0, Some("compositorBuilding")); + assert!(gpu.simulate_compositor_completion(success), "test compositor must build"); + g.harness.step(); + settle(&mut g); + g.harness.run_steps(2); + assert_eq!(g.harness.state().session.active().expect("document").revision, revision); + if success { + assert!(on_gpu_canvas(&g), "successful build should replace the temporary reason: {:?}", render_path_of(&g)); + } else { + assert_eq!(render_path_of(&g).0, Some("compositorBuild")); + assert!(g.harness.state().perf.gpu_fallback.as_deref().is_some_and(|s| s.contains("simulated build failure"))); + assert_matches_synchronous(&mut g, doc); + } + } +} + #[test] fn a_canvas_over_the_texture_budget_records_its_reason() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/crates/ui-egui/src/canvas/gpu_canvas.rs b/crates/ui-egui/src/canvas/gpu_canvas.rs index af3d574980..0311f56934 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas.rs @@ -39,6 +39,7 @@ pub use photocraft_gpu::{DeviceHealth, Fault}; mod budget; mod callback; +mod compositor_build; mod doc_textures; mod pages; mod perf; @@ -53,8 +54,8 @@ use budget::detect_video_memory; #[cfg(all(test, target_os = "linux"))] use budget::nvidia_video_memory; pub use budget::{ - DEFER_PX, MIN_GPU_BUDGET, OVER_BUDGET, OVER_CANVAS_BUDGET, REFRESH_DEFERRED, REFRESH_OVER_BUDGET, canvas_budget, canvas_texture_bytes, memory_budget, - physical_memory, video_memory, vram_share, + COMPOSITOR_BUILDING, DEFER_PX, MIN_GPU_BUDGET, OVER_BUDGET, OVER_CANVAS_BUDGET, REFRESH_DEFERRED, REFRESH_OVER_BUDGET, canvas_budget, canvas_texture_bytes, + memory_budget, physical_memory, video_memory, vram_share, }; #[cfg(test)] use budget::{NVIDIA_VENDOR, bounded_output, drm_video_memory, parse_nvidia_smi}; @@ -171,6 +172,8 @@ pub struct GpuCanvas { /// Camera Raw's preview pass, built the first time the dialog asks (#6419). camera_raw: crate::camera_raw_gpu::Slot, camera_raw_started: std::sync::Arc, + compositor_build: compositor_build::Slot, + compositor_completion: compositor_build::Completion, } /// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on @@ -228,7 +231,61 @@ impl GpuCanvas { rs.target_format, vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20)) ); - Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget, camera_raw: Default::default(), camera_raw_started: Default::default() } + Self { + rs: rs.clone(), + tile, + high, + health, + vram, + canvas_budget: budget, + camera_raw: Default::default(), + camera_raw_started: Default::default(), + compositor_build: Default::default(), + compositor_completion: Default::default(), + } + } + + /// Start compositor compilation off the UI thread on native hardware. + pub fn start_compositor(&self, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + if self.rs.adapter.get_info().device_type != wgpu::DeviceType::Cpu && std::env::var_os("PHOTOCRAFT_CPU_COMPOSE").is_none() { + compositor_build::start( + &self.rs.device, + photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter), + ctx, + &self.compositor_build, + &self.compositor_completion, + ); + } + #[cfg(target_arch = "wasm32")] + let _ = ctx; + } + + /// Key the memo to this device, application and shader compiler. + pub fn compositor_memo_key(&self, compiler: &str) -> crate::gpu_memo::MemoKey { + let info = self.rs.adapter.get_info(); + crate::gpu_memo::MemoKey { + app_version: env!("CARGO_PKG_VERSION").into(), + shader_fingerprint: photocraft_gpu::shader_fingerprint(), + adapter_name: info.name, + vendor: info.vendor, + device: info.device, + driver: info.driver, + driver_info: info.driver_info, + backend: format!("{:?}", info.backend), + shader_compiler: compiler.into(), + } + } + + /// Use a known deterministic failure without recompiling the same shaders. + pub fn remember_compositor_failure(&self, error: &str) { + *self.compositor_build.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = + compositor_build::BuildState::Failed(photocraft_gpu::Unsupported(format!("remembered from an earlier launch: {error}"))); + } + + /// Take the background build result once, independently of compositor adoption. + pub fn take_compositor_completion(&self) -> Option> { + self.compositor_completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() } /// The device's health flag (shared with the wgpu compositor and the paint callback). @@ -402,6 +459,28 @@ impl GpuCanvas { } } + #[cfg(test)] + pub(crate) fn simulate_compositor_build(&self) { + *self.compositor_build.lock().unwrap() = compositor_build::BuildState::Building; + } + + #[cfg(test)] + pub(crate) fn simulate_compositor_completion(&self, success: bool) -> bool { + let (state, outcome) = if success { + let Ok(comp) = photocraft_gpu::Compositor::try_new_with_format(&self.rs.device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter)) + else { + return false; + }; + (compositor_build::BuildState::Ready(Box::new(comp)), Ok(())) + } else { + let message = "simulated build failure".to_string(); + (compositor_build::BuildState::Failed(photocraft_gpu::Unsupported(message.clone())), Err((message, false))) + }; + *self.compositor_build.lock().unwrap() = state; + *self.compositor_completion.lock().unwrap() = Some(outcome); + true + } + /// Why the compositor couldn't be built, once it couldn't (see [`GpuCanvas::composite`]). fn compositor_failed(&self) -> Option { let r = self.rs.renderer.read(); @@ -422,6 +501,9 @@ impl GpuCanvas { let (bytes, budget) = self.canvas_fit(doc).unwrap_or_default(); return CpuReason::CanvasTooLarge { bytes, budget }; } + if e.0 == COMPOSITOR_BUILDING { + return CpuReason::CompositorBuilding; + } if self.compositor_failed().is_some_and(|f| f == e.0) { return CpuReason::CompositorBuild(e.0); } @@ -623,6 +705,22 @@ impl GpuCanvas { if let Some(e) = &res.compositor_failed { return Err(e.clone()); } + { + let mut state = self.compositor_build.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *state, compositor_build::BuildState::Adopted) { + compositor_build::BuildState::Building => { + *state = compositor_build::BuildState::Building; + return Err(photocraft_gpu::Unsupported(COMPOSITOR_BUILDING.into())); + } + compositor_build::BuildState::Ready(comp) => res.compositor = Some(*comp), + compositor_build::BuildState::Failed(error) => { + res.compositor_failed = Some(error.clone()); + return Err(error); + } + compositor_build::BuildState::NotStarted => *state = compositor_build::BuildState::NotStarted, + compositor_build::BuildState::Adopted => (), + } + } let mut comp = match res.compositor.take() { Some(c) => c, None => match photocraft_gpu::Compositor::try_new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter)) { @@ -809,8 +907,12 @@ impl GpuCanvas { out.gpu = stats.gpu_time; return out; } - Err(e) => out.fallback = Some(self.reason(doc, e)), + Err(e) => { + out.gpu_attempt_ms = now_ms() - t0; + out.fallback = Some(self.reason(doc, e)); + } } + let t0 = now_ms(); let px = region.width() as u64 * region.height() as u64; if defer && px >= DEFER_PX && self.prepare_texture(key, doc).is_some() { out.kind = REFRESH_DEFERRED; diff --git a/crates/ui-egui/src/canvas/gpu_canvas/budget.rs b/crates/ui-egui/src/canvas/gpu_canvas/budget.rs index 242eebaaa9..772b6f828e 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/budget.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/budget.rs @@ -8,6 +8,9 @@ pub const MIN_GPU_BUDGET: u64 = 512 << 20; /// CPU compositor. pub const OVER_BUDGET: &str = photocraft_gpu::OVER_BUDGET; +/// Why a full refresh goes to the CPU compositor while the GPU one is still being built. +pub const COMPOSITOR_BUILDING: &str = "GPU compositor is building"; + /// GPU memory the wgpu compositor may hold for layer pages and effect maps. /// /// `fixed` (Preferences › Performance › GPU Memory, bytes) sets it when it isn't 0, within diff --git a/crates/ui-egui/src/canvas/gpu_canvas/compositor_build.rs b/crates/ui-egui/src/canvas/gpu_canvas/compositor_build.rs new file mode 100644 index 0000000000..5e280a05af --- /dev/null +++ b/crates/ui-egui/src/canvas/gpu_canvas/compositor_build.rs @@ -0,0 +1,76 @@ +//! Builds the compositor without blocking the UI's first document refresh. +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +pub(super) enum BuildState { + #[default] + NotStarted, + Building, + Ready(Box), + Failed(photocraft_gpu::Unsupported), + Adopted, +} + +pub(super) type Slot = Arc>; +pub(super) type Completion = Arc>>>; + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn start(device: &eframe::wgpu::Device, format: eframe::wgpu::TextureFormat, ctx: &egui::Context, slot: &Slot, completion: &Completion) { + let mut state = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(*state, BuildState::NotStarted) { + return; + } + *state = BuildState::Building; + let (device, ctx, out) = (device.clone(), ctx.clone(), slot.clone()); + let worker_completion = completion.clone(); + let spawned = std::thread::Builder::new().name("gpu-compositor-build".into()).spawn(move || { + let threads = std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + photocraft_gpu::Compositor::try_new_with_format_threads_classified(&device, format, threads) + })); + let (result, outcome) = match result { + Ok(Ok(comp)) => (BuildState::Ready(Box::new(comp)), Ok(())), + Ok(Err(error)) => { + let deterministic = error.kind == photocraft_gpu::BuildFailureKind::DeterministicShaderCompilation; + (BuildState::Failed(photocraft_gpu::Unsupported(error.message.clone())), Err((error.message, deterministic))) + } + Err(_) => ( + BuildState::Failed(photocraft_gpu::Unsupported("building the GPU compositor panicked".into())), + Err(("building the GPU compositor panicked".into(), false)), + ), + }; + *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = result; + *worker_completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(outcome); + ctx.request_repaint(); + }); + if let Err(error) = spawned { + record_spawn_failure(&mut state, completion, &error); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn record_spawn_failure(state: &mut BuildState, completion: &Completion, error: &std::io::Error) { + let message = format!("failed to spawn GPU compositor build thread: {error}"); + *state = BuildState::Failed(photocraft_gpu::Unsupported(message.clone())); + *completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err((message, false))); +} + +#[cfg(test)] +mod tests { + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn spawn_failure_is_reported_as_retryable() { + let completion = super::Completion::default(); + let mut state = super::BuildState::Building; + super::record_spawn_failure(&mut state, &completion, &std::io::Error::other("resource exhausted")); + assert!(matches!(state, super::BuildState::Failed(_))); + let outcome = completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take(); + assert_eq!(outcome, Some(Err(("failed to spawn GPU compositor build thread: resource exhausted".into(), false)))); + } + + #[test] + fn compositor_is_send() { + fn assert_send() {} + assert_send::(); + } +} diff --git a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs index 8de45748b6..61edb94bdb 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs @@ -66,6 +66,7 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() { } // On the same device (see the top of the file). canvas_textures_the_gpu_refuses_leave_it_working(&gpu); + background_compositor_switch_preserves_pixels(&gpu); refused_viewport_page_work_is_caught(&gpu); } @@ -202,3 +203,36 @@ fn partial_updates_match_a_full_rebuild_at_every_mip_level() { } } } + +/// A CPU-created texture stays valid when a newly built compositor updates only a damage rect. +fn background_compositor_switch_preserves_pixels(gpu: &GpuCanvas) { + use photocraft_doc::{ColorMode, Document, Layer, LayerContent}; + let mut doc = Document::new("background build", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("pixels", doc.pixel_format()); + if let LayerContent::Raster(surface) = &mut layer.content { + surface.fill_rect(Rect::new(0, 0, 64, 64), &[0.2, 0.4, 0.6, 1.0]); + } + doc.layers.push(layer); + let key = doc.id.0; + *gpu.compositor_build.lock().unwrap() = compositor_build::BuildState::Building; + let first = gpu.refresh(key, &doc, None, None); + assert_eq!(first.kind, "full"); + assert_eq!(first.fallback, Some(CpuReason::CompositorBuilding)); + assert!(gpu.compositor_failed().is_none()); + let built = photocraft_gpu::Compositor::try_new_with_format(&gpu.rs.device, photocraft_gpu::Compositor::preferred_acc_format(&gpu.rs.adapter)); + let Ok(comp) = built else { return }; + *gpu.compositor_build.lock().unwrap() = compositor_build::BuildState::Ready(Box::new(comp)); + let damage = Rect::new(8, 8, 16, 16); + if let LayerContent::Raster(surface) = &mut doc.layers[0].content { + surface.fill_rect(damage, &[0.8, 0.2, 0.1, 1.0]); + } + let second = gpu.refresh(key, &doc, Some(damage), None); + assert_eq!(second.kind, "gpu-rect", "{:?}", second.fallback); + let actual = read_mips(gpu, key).remove(0); + let expected = photocraft_compose::render(&doc, doc.bounds()); + for (got, want) in actual.iter().zip(expected.px.iter()) { + for (g, w) in got.iter().zip(want.iter()) { + assert!((g - w).abs() < 0.02, "{got:?} != {want:?}"); + } + } +} diff --git a/crates/ui-egui/src/canvas/gpu_canvas/perf.rs b/crates/ui-egui/src/canvas/gpu_canvas/perf.rs index 58b12774b2..a633e67300 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/perf.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/perf.rs @@ -26,6 +26,8 @@ pub struct Perf { /// path the cost of *encoding and submitting* the composite — not of running it, which is /// `gpu_ms`. pub composite_ms: f64, + /// Time spent on a failed GPU composite attempt before CPU fallback. + pub gpu_attempt_ms: f64, /// Last canvas refresh: conversion + upload (ms). pub upload_ms: f64, /// Last canvas refresh kind: "full", "rect" or "none". @@ -219,6 +221,9 @@ impl Perf { self.last_refresh_px = px; self.composite_ms = composite_ms; self.upload_ms = upload_ms; + // A refresh that didn't come through `record_refresh` or `record_gpu_attempt` had no failed + // GPU attempt: the last one's time would be read as this refresh's. + self.gpu_attempt_ms = 0.0; if !kind.starts_with("gpu") { self.gpu_uploads = 0; // The CPU compositor did this one: there is no GPU time, and the last GPU @@ -231,11 +236,19 @@ impl Perf { /// Record a canvas refresh and any GPU measurement that came back with it. pub fn record_refresh(&mut self, r: &Refresh) { self.record(r.kind, r.px, r.composite_ms, r.upload_ms); + self.record_gpu_attempt(r.gpu_attempt_ms); if let Some(t) = r.gpu { self.record_gpu(t); } } + /// Record the time a failed GPU composite attempt took before the CPU did the refresh just + /// recorded, apart from that refresh's own CPU time. + pub fn record_gpu_attempt(&mut self, ms: f64) { + self.gpu_attempt_ms = ms; + self.stage.composite_ms += ms; + } + /// Record a GPU composite measurement that came back (see [`photocraft_gpu::GpuTiming`]). pub fn record_gpu(&mut self, t: photocraft_gpu::GpuTiming) { self.gpu_ms = Some(t.ms); diff --git a/crates/ui-egui/src/canvas/gpu_canvas/setup.rs b/crates/ui-egui/src/canvas/gpu_canvas/setup.rs index b9d3f98bbe..8565cd8f7f 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/setup.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/setup.rs @@ -32,6 +32,8 @@ pub struct Refresh { /// Pixels refreshed. pub px: u64, pub composite_ms: f64, + /// Time spent on a failed GPU composite attempt before CPU fallback. + pub gpu_attempt_ms: f64, pub upload_ms: f64, /// Layer tiles the wgpu compositor uploaded. pub uploads: u64, diff --git a/crates/ui-egui/src/canvas/gpu_canvas/tests.rs b/crates/ui-egui/src/canvas/gpu_canvas/tests.rs index 74305c024b..858b3691b4 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/tests.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/tests.rs @@ -397,3 +397,30 @@ fn shaders_validate() { .unwrap_or_else(|e| panic!("{name}: {e:?}")); } } + +#[test] +fn failed_gpu_attempt_is_recorded_separately_and_counted_in_frame_time() { + let mut perf = Perf::default(); + perf.record_refresh(&Refresh { kind: "full", composite_ms: 2.0, upload_ms: 3.0, gpu_attempt_ms: 7.0, ..Default::default() }); + assert_eq!(perf.composite_ms, 2.0); + assert_eq!(perf.gpu_attempt_ms, 7.0); + assert_eq!(perf.stage.composite_ms + perf.stage.upload_ms, 12.0); +} + +#[test] +fn a_refresh_recorded_directly_has_no_gpu_attempt_of_its_own() { + let mut perf = Perf::default(); + perf.record_refresh(&Refresh { kind: "full", composite_ms: 2.0, upload_ms: 3.0, gpu_attempt_ms: 7.0, ..Default::default() }); + perf.record("rect", 10, 1.0, 1.0); + assert_eq!(perf.gpu_attempt_ms, 0.0); +} + +#[test] +fn a_deferred_redraws_gpu_attempt_is_counted_once_when_it_ends() { + let mut perf = Perf::default(); + perf.record("background", 10, 2.0, 0.0); + perf.record_gpu_attempt(7.0); + assert_eq!(perf.composite_ms, 2.0); + assert_eq!(perf.gpu_attempt_ms, 7.0); + assert_eq!(perf.stage.composite_ms + perf.stage.upload_ms, 9.0); +} diff --git a/crates/ui-egui/src/canvas/textures.rs b/crates/ui-egui/src/canvas/textures.rs index 455f2edb56..570549e799 100644 --- a/crates/ui-egui/src/canvas/textures.rs +++ b/crates/ui-egui/src/canvas/textures.rs @@ -578,7 +578,7 @@ pub(super) fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz if r.kind == crate::gpu_canvas::REFRESH_DEFERRED { // Composited off the UI thread, the visible part first (#4024). let region = if partial { damage.unwrap_or(doc.bounds()) } else { doc.bounds() }; - crate::background_redraw::start(app, ctx, &gpu, doc.clone(), display.clone(), visible, region, r.fallback.clone()); + crate::background_redraw::start(app, ctx, &gpu, doc.clone(), display.clone(), visible, region, r.fallback.clone(), r.gpu_attempt_ms); } else if partial { // The edit drew its damage; blocks of a running redraw still to come follow the edit. crate::background_redraw::restart(app, ctx, &gpu, doc.clone(), display.clone(), visible); diff --git a/crates/ui-egui/src/gpu_memo.rs b/crates/ui-egui/src/gpu_memo.rs new file mode 100644 index 0000000000..710fec97fa --- /dev/null +++ b/crates/ui-egui/src/gpu_memo.rs @@ -0,0 +1,178 @@ +//! Persistence model for deterministic GPU compositor build failures. +//! +//! Call [`GpuMemo::remember`] only for deterministic build failures. Transient device or +//! allocation errors are deliberately not classified here and are never saved automatically. + +use serde::{Deserialize, Serialize}; + +/// Current on-disk schema version. +pub const GPU_MEMO_VERSION: u32 = 1; +/// Maximum accepted serialized document size. +pub const MAX_GPU_MEMO_BYTES: usize = 64 * 1024; +/// Number of failure records retained, newest last. +pub const MAX_GPU_MEMO_ENTRIES: usize = 8; + +/// Identifies the application, shader set, and graphics stack that produced a failure. +#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct MemoKey { + pub app_version: String, + pub shader_fingerprint: u64, + pub adapter_name: String, + pub vendor: u32, + pub device: u32, + pub driver: String, + pub driver_info: String, + pub backend: String, + pub shader_compiler: String, +} + +/// One remembered deterministic build failure. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MemoEntry { + pub key: MemoKey, + pub error: String, + /// RFC3339 timestamp supplied by the caller. + pub when: String, +} + +/// Bounded, versioned compositor failure memo. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GpuMemo { + version: u32, + failures: Vec, +} + +impl Default for GpuMemo { + fn default() -> Self { + Self::new() + } +} + +impl GpuMemo { + /// Creates an empty memo using the current schema version. + pub fn new() -> Self { + Self { version: GPU_MEMO_VERSION, failures: Vec::new() } + } + + /// Parse persisted JSON. Rejects oversized, malformed, and unknown-version data. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_GPU_MEMO_BYTES { + return Err("GPU memo exceeds 64 KiB".into()); + } + let mut memo: Self = serde_json::from_str(text).map_err(|e| e.to_string())?; + if memo.version != GPU_MEMO_VERSION { + return Err(format!("unsupported GPU memo version {}", memo.version)); + } + if memo.failures.len() > MAX_GPU_MEMO_ENTRIES { + let excess = memo.failures.len() - MAX_GPU_MEMO_ENTRIES; + memo.failures.drain(..excess); + } + Ok(memo) + } + + /// Serialize this memo as JSON. + pub fn serialize(&self) -> Result { + let text = serde_json::to_string(self).map_err(|e| e.to_string())?; + if text.len() > MAX_GPU_MEMO_BYTES { + return Err("GPU memo exceeds 64 KiB".into()); + } + Ok(text) + } + + /// Returns the remembered failure for `key`, if present. + pub fn get(&self, key: &MemoKey) -> Option<&MemoEntry> { + self.failures.iter().rev().find(|entry| &entry.key == key) + } + + /// Records a deterministic failure. Callers must exclude transient device-loss and OOM errors. + /// Existing entries for the key are replaced and the oldest record is dropped at capacity. + pub fn remember(&mut self, key: MemoKey, error: String, when: String) { + if self.version != GPU_MEMO_VERSION { + *self = Self::new(); + } + self.remove(&key); + if self.failures.len() == MAX_GPU_MEMO_ENTRIES { + self.failures.remove(0); + } + self.failures.push(MemoEntry { key, error, when }); + } + + /// Removes a memoized failure for `key`; returns whether one existed. + pub fn remove(&mut self, key: &MemoKey) -> bool { + let before = self.failures.len(); + self.failures.retain(|entry| &entry.key != key); + before != self.failures.len() + } + + /// Number of retained failure records. + pub fn len(&self) -> usize { + self.failures.len() + } + + /// Whether the memo has no records. + pub fn is_empty(&self) -> bool { + self.failures.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(n: usize) -> MemoKey { + MemoKey { app_version: n.to_string(), ..MemoKey::default() } + } + + #[test] + fn round_trip_and_remove() { + let mut memo = GpuMemo::new(); + memo.remember(key(1), "compiler failed".into(), "2026-10-28T00:00:00Z".into()); + let parsed = GpuMemo::parse(&memo.serialize().expect("serializes")).expect("parses"); + assert_eq!(parsed.get(&key(1)).map(|entry| entry.error.as_str()), Some("compiler failed")); + let mut parsed = parsed; + assert!(parsed.remove(&key(1))); + assert!(!parsed.remove(&key(1))); + assert!(parsed.is_empty()); + } + + #[test] + fn changing_any_identity_field_invalidates_a_failure() { + let original = MemoKey::default(); + let mut memo = GpuMemo::new(); + memo.remember(original.clone(), "compiler failed".into(), "now".into()); + let variants = [ + MemoKey { app_version: "new".into(), ..original.clone() }, + MemoKey { shader_fingerprint: 1, ..original.clone() }, + MemoKey { adapter_name: "new".into(), ..original.clone() }, + MemoKey { vendor: 1, ..original.clone() }, + MemoKey { device: 1, ..original.clone() }, + MemoKey { driver: "new".into(), ..original.clone() }, + MemoKey { driver_info: "new".into(), ..original.clone() }, + MemoKey { backend: "new".into(), ..original.clone() }, + MemoKey { shader_compiler: "new".into(), ..original.clone() }, + ]; + for variant in variants { + assert!(memo.get(&variant).is_none()); + } + } + + #[test] + fn retains_only_eight_newest_entries() { + let mut memo = GpuMemo::new(); + for n in 0..10 { + memo.remember(key(n), "failure".into(), "now".into()); + } + assert_eq!(memo.len(), MAX_GPU_MEMO_ENTRIES); + assert!(memo.get(&key(0)).is_none()); + assert!(memo.get(&key(1)).is_none()); + assert!(memo.get(&key(2)).is_some()); + assert!(memo.get(&key(9)).is_some()); + } + + #[test] + fn rejects_corrupt_oversized_and_unknown_versions() { + assert!(GpuMemo::parse("{").is_err()); + assert!(GpuMemo::parse(&" ".repeat(MAX_GPU_MEMO_BYTES + 1)).is_err()); + assert!(GpuMemo::parse(r#"{"version":2,"failures":[]}"#).is_err()); + } +} diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index ca832645b7..6c5eee78ad 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -34,6 +34,7 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { if fault.is_out_of_memory() && app.gpu_oom_retries < OOM_AUTO_RETRIES && can_retry_now(app) { app.gpu_oom_retries += 1; if retry_now(app) { + app.retry_compositor(ctx); log::warn!("{fault}; rebuilt the GPU canvas (automatic retry {} of {OOM_AUTO_RETRIES})", app.gpu_oom_retries); app.ui.gpu_fallback_notice = None; } @@ -292,10 +293,12 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { } }); }); - if let Some(retry) = choice - && let Err(error) = choose_recovery(app, retry) - { - crate::notices::error(app, error); + if let Some(retry) = choice { + match choose_recovery(app, retry) { + Ok(()) if retry => app.retry_compositor(ctx), + Ok(()) => (), + Err(error) => crate::notices::error(app, error), + } } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b657be0bf3..e11ad45163 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4247,6 +4247,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Předvolby velikosti písma Leading presets Předvolby prokladu Tracking presets Předvolby prostrkání + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neplatná číselná hodnota. Je vyžadováno celé číslo mezi {min}% a {max}%. Byla vložena předchozí hodnota. Color Aware Podle barev Decontaminate Colors Dekontaminovat barvy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ffac13ce5e..801c7b38f8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3529,6 +3529,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Schriftgrößen-Vorgaben Leading presets Zeilenabstand-Vorgaben Tracking presets Laufweiten-Vorgaben + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ungültige Zahleneingabe. Es ist eine ganze Zahl zwischen {min}% und {max}% erforderlich. Der vorherige Wert wurde eingesetzt. Clear Selection Auswahl löschen Color Aware Farbbezogen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 58c2db19ea..4ef92c6162 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4200,6 +4200,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Προεπιλογές μεγέθους γραμματοσειράς Leading presets Προεπιλογές διάστιχου Tracking presets Προεπιλογές διαστήματος χαρακτήρων + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Μη έγκυρη αριθμητική τιμή. Απαιτείται ακέραιος αριθμός μεταξύ {min}% και {max}%. Εισήχθη η προηγούμενη τιμή. Color Aware Με βάση το χρώμα Decontaminate Colors Απολύμανση χρωμάτων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0863b02224..117026a6d9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4411,6 +4411,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Valores predefinidos de tamaño de fuente Leading presets Valores predefinidos de interlineado Tracking presets Valores predefinidos de tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica no válida. Se requiere un número entero entre {min}% y {max}%. Se ha insertado el valor anterior. Color Aware Según color Decontaminate Colors Descontaminar colores diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cb0245ab35..1a937b8fb9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4537,6 +4537,8 @@ shape Appearance Apparence Font size presets Tailles de police prédéfinies Leading presets Interlignages prédéfinis Tracking presets Approches prédéfinies + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrée numérique non valide. Un entier compris entre {min}% et {max}% est requis. La valeur précédente a été insérée. Color Aware Selon la couleur Decontaminate Colors Décontaminer les couleurs diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 182b273467..b1fc4631e0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -567,6 +567,8 @@ cameraRaw Yellow पीला Target लक्ष्य Units इकाइयाँ Demosaic डिमोज़ेक + + The GPU compositor is still being prepared The GPU compositor is still being prepared Clear Selection चयन साफ़ करें Color Aware रंग-आधारित Decontaminate Colors रंग शुद्ध करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f443e17885..14606a1e70 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4420,6 +4420,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Betűméret-előbeállítások Leading presets Sorköz-előbeállítások Tracking presets Betűköz-előbeállítások + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Érvénytelen számbevitel. {min}% és {max}% közötti egész szám szükséges. Az előző érték lett beillesztve. Color Aware Színalapú Decontaminate Colors Színek fertőtlenítése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d476cb0185..bc6766f662 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3519,6 +3519,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Preset ukuran font Leading presets Preset leading Tracking presets Preset tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entri numerik tidak valid. Diperlukan bilangan bulat antara {min}% dan {max}%. Nilai sebelumnya telah disisipkan. Clear Selection Hapus Seleksi Color Aware Berbasis Warna diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7d109ac4b2..312d902b87 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4613,6 +4613,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Predefiniti dimensione font Leading presets Predefiniti interlinea Tracking presets Predefiniti spaziatura + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Valore numerico non valido. È richiesto un numero intero compreso tra {min}% e {max}%. È stato inserito il valore precedente. Color Aware In base al colore Decontaminate Colors Decontamina colori diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7ddf07b49e..e80417f2be 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4175,6 +4175,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets フォントサイズのプリセット Leading presets 行送りのプリセット Tracking presets トラッキングのプリセット + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 無効な数値です。{min}% から {max}% までの整数が必要です。以前の値が挿入されました。 Clear Selection 選択範囲をクリア Color Aware カラー認識 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d74b6784e5..0423429641 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3718,6 +3718,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 글꼴 크기 사전 설정 Leading presets 행간 사전 설정 Tracking presets 자간 사전 설정 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 잘못된 숫자 입력입니다. {min}%에서 {max}% 사이의 정수가 필요합니다. 이전 값이 입력되었습니다. Clear Selection 선택 영역 지우기 Color Aware 색상 인식 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 418c756ff4..ef2df8db00 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4257,6 +4257,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Voorinstellingen lettergrootte Leading presets Voorinstellingen interlinie Tracking presets Voorinstellingen spatiëring + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ongeldige numerieke invoer. Er is een geheel getal tussen {min}% en {max}% vereist. De vorige waarde is ingevoegd. Color Aware Kleurbewust Decontaminate Colors Kleuren zuiveren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 52e6fd05a1..41cd0e671d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4117,6 +4117,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Ustawienia wstępne rozmiaru czcionki Leading presets Ustawienia wstępne interlinii Tracking presets Ustawienia wstępne światła + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Nieprawidłowa wartość liczbowa. Wymagana jest liczba całkowita od {min}% do {max}%. Wstawiono poprzednią wartość. Clear Selection Wyczyść zaznaczenie Color Aware Według kolorów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 044de6271e..db66f03f90 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4427,6 +4427,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Predefinições de tamanho da fonte Leading presets Predefinições de entrelinha Tracking presets Predefinições de tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica inválida. É necessário um número inteiro entre {min}% e {max}%. O valor anterior foi inserido. Color Aware Com reconhecimento de cor Decontaminate Colors Descontaminar cores diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e449b92917..e54f6ff397 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4698,6 +4698,8 @@ shape Appearance Оформление Font size presets Наборы размеров шрифта Leading presets Наборы интерлиньяжа Tracking presets Наборы трекинга + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Недопустимое числовое значение. Требуется целое число от {min}% до {max}%. Вставлено предыдущее значение. Color Aware С учетом цвета Decontaminate Colors Очистить цвета diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index cb3da7689c..9b5eef1478 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4578,6 +4578,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Unapred podešene veličine fonta Leading presets Unapred podešeni proredi Tracking presets Unapred podešeni razmaci slova + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neispravan numerički unos. Potreban je ceo broj između {min}% i {max}%. Umetnuta je prethodna vrednost. Color Aware Prema boji Decontaminate Colors Dekontaminiraj boje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e441028963..d319a3614c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4229,6 +4229,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Yazı tipi boyutu hazır ayarları Leading presets Satır aralığı hazır ayarları Tracking presets İzleme hazır ayarları + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Geçersiz sayısal giriş. {min}% ile {max}% arasında bir tam sayı gerekiyor. Önceki değer eklendi. Color Aware Renge Duyarlı Decontaminate Colors Renkleri Arındır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5416f3d57f..c1621be051 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4210,6 +4210,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Набори розмірів шрифту Leading presets Набори інтерліньяжу Tracking presets Набори трекінгу + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Неприпустиме числове значення. Потрібне ціле число від {min}% до {max}%. Вставлено попереднє значення. Clear Selection Очистити виділення Color Aware З урахуванням кольору diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb746926fe..de99217a09 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4006,6 +4006,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Cỡ chữ đặt sẵn Leading presets Giãn dòng đặt sẵn Tracking presets Khoảng cách chữ đặt sẵn + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Giá trị số không hợp lệ. Cần một số nguyên từ {min}% đến {max}%. Đã chèn giá trị trước đó. Clear Selection Xóa vùng chọn Color Aware Nhận biết màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 86ddac9738..f89abd7117 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3182,6 +3182,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 字号预设 Leading presets 行距预设 Tracking presets 字距调整预设 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 数值输入无效。需要介于 {min}% 和 {max}% 之间的整数。已插入先前的值。 Clear Selection 清除选区 Color Aware 颜色识别 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4f6107659d..c3c1fdac34 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3138,6 +3138,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 字體大小預設 Leading presets 行距預設 Tracking presets 字距調整預設 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 數值輸入無效。需要介於 {min}% 和 {max}% 之間的整數。已插入先前的值。 Clear Selection 清除選取範圍 Color Aware 顏色感知 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b51f8990ba..562144fb5b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub(crate) mod edit_session; pub mod file_dialog; pub mod file_open; pub mod frame_stages; +pub mod gpu_memo; pub mod gpu_status; pub mod home_recent_ui; pub mod i18n; diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index d34044d73c..f2025a0d78 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -133,7 +133,11 @@ pub fn clipboard_unreadable(app: &mut PhotocraftApp) { /// `ms`. The canvas doesn't respond while it runs, so once per document say why it was slow and /// which edits will do it again (#1015). pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Option<&crate::render_path::CpuReason>) { - if ms.is_nan() || ms < SLOW_CPU_REFRESH_MS || app.ui.slow_refresh_noticed.contains(&doc) { + if matches!(reason, Some(crate::render_path::CpuReason::CompositorBuilding)) + || ms.is_nan() + || ms < SLOW_CPU_REFRESH_MS + || app.ui.slow_refresh_noticed.contains(&doc) + { return; } app.ui.slow_refresh_noticed.push(doc); @@ -415,6 +419,14 @@ mod tests { assert!(app.ui.notices.is_empty()); } + #[test] + fn preparing_compositor_does_not_warn_about_large_documents() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + slow_cpu_refresh(&mut app, 1, 60_000.0, Some(&crate::render_path::CpuReason::CompositorBuilding)); + assert!(app.ui.notices.is_empty()); + assert!(app.ui.slow_refresh_noticed.is_empty()); + } + #[test] fn a_slow_cpu_refresh_is_explained_once_per_document() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/render_path.rs b/crates/ui-egui/src/render_path.rs index fe42e50667..9aae3cdb6d 100644 --- a/crates/ui-egui/src/render_path.rs +++ b/crates/ui-egui/src/render_path.rs @@ -32,6 +32,8 @@ pub enum CpuReason { CanvasTooLarge { bytes: u64, budget: u64 }, /// The driver couldn't build the compositor's pipelines (such as DX12 FXC X3507, #8733). CompositorBuild(String), + /// The GPU compositor is being built in the background. + CompositorBuilding, /// The device was lost (the fault's text). DeviceLost(String), /// An uncaptured GPU error (the fault's text). @@ -61,6 +63,7 @@ impl CpuReason { CpuReason::OverBudget { .. } => "overBudget", CpuReason::CanvasTooLarge { .. } => "canvasTooLarge", CpuReason::CompositorBuild(_) => "compositorBuild", + CpuReason::CompositorBuilding => "compositorBuilding", CpuReason::DeviceLost(_) => "deviceLost", CpuReason::GpuError(_) => "gpuError", CpuReason::Unsupported(_) => "unsupported", @@ -87,6 +90,7 @@ impl fmt::Display for CpuReason { CpuReason::CanvasTooLarge { bytes, budget } => { write!(f, "the canvas needs {} of GPU textures, budget {}; drawing it on the CPU canvas", fmt_bytes(*bytes), fmt_bytes(*budget)) } + CpuReason::CompositorBuilding => f.write_str(tl!("The GPU compositor is still being prepared")), CpuReason::CompositorBuild(e) => write!(f, "the GPU compositor couldn't be built: {e}"), CpuReason::DeviceLost(e) | CpuReason::GpuError(e) | CpuReason::Unsupported(e) => f.write_str(e), CpuReason::AdjustPreview(why) => write!(f, "the adjustment preview uses the CPU proxy: {why}"), @@ -137,6 +141,9 @@ pub struct RenderPath { } impl RenderPath { + pub(crate) fn compositor_building_docs(&self) -> Vec { + self.docs.iter().filter_map(|(&doc, reason)| matches!(reason, CpuReason::CompositorBuilding).then_some(doc)).collect() + } /// Why document `doc` is drawn on the CPU now, or None on the GPU: the window's reason, else /// the view's, else the canvas's, else the adjustment preview's. A software adapter still runs /// the GPU canvas, so a document that left even that names its own reason first. diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 7f05fdc1de..569154fe22 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -475,6 +475,11 @@ pub struct Services { /// the web (see `prefs_ui`). pub load_prefs: Option, pub save_prefs: Option, + /// Load/save the bounded deterministic GPU compositor failure memo (native only). + pub load_gpu_memo: Option, + pub save_gpu_memo: Option, + /// Compiler identity included in GPU memo keys (for example `fxc` or `dxc:`). + pub shader_compiler: Option, /// Photoshop's live keyboard shortcut set, imported once at first launch (`kys_import`). pub photoshop_shortcuts: Option, pub system_theme: Option, diff --git a/docs/gaps.md b/docs/gaps.md index 29b4dd8ed5..2044af01d4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -342,6 +342,7 @@ what an agent can't finish alone. - **Estimate:** 150–250 h. ### G2. Interactive performance +- **Partial (2026-10-28, #9157, #9364, #6419):** Native startup prepares GPU compositor pipelines on a background pool; the CPU compositor draws while they build. The startup benchmark rows have no baseline yet. A Windows probe measures device stalls during the build; shader compiler fixes and DXC distribution remain separate work. - **Progress (2026-10-27):** Smooth and Feather also crop command mask reads and replacement to the nonzero selection bounds plus their blur reach and a zero margin. A 50×50 selection at radius 5 uses 62×62 and 82×82 samples respectively on a 36 MP @@ -473,6 +474,7 @@ what an agent can't finish alone. | 2026-10-31 | minor | G11: twelve Stylize/Pixelate ATN events mapped; current counts 128 supported/89 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | | 2026-11-05 | minor | G8: Type > OpenType > Stylistic Sets submenu (Set 1–20) landed (TYPE-218-5, #10285); font-capability greying still missing; PhotoCraft-native PSDs already keep the sets since #10308 | | 2026-10-30 | minor | Art History Brush Opacity on the options bar, #10263; no estimate changed | +| 2026-10-28 | minor | G2: prepare the GPU compositor in the background; startup performance baseline pending (#9157) | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-11-01 | minor | G9: 11 of 183 preferences do nothing (was 12): Graphics Adapter (`performance.gpuAdapter`) always worked, but startup read it by raw JSON key, which the scorecard prefs audit can't see; it now reads the typed Performance section; no estimate changed | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index e8f6a6da1b..0fe3e98549 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (GPU compositor startup builds in the background, measurements pending; Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026 Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR @@ -19,6 +19,7 @@ HDR display to verify. |---|---|---|---| | GPU canvas, zoom/pan, rotate view | Metal (macOS), DirectX/OpenCL (Windows) | wgpu 30: Metal, Vulkan, DX12, GL; backend selectable | done | | GPU compositing of layers, blend modes, effects | partial (Photoshop composites mostly on CPU) | wgpu compositor, ≤ 1/255 vs CPU oracle; Multichannel and > texture-limit regions fall back to CPU | done (ahead in places) | +| GPU compositor startup | available | Pipeline preparation runs in the background on native hardware; the CPU composites until ready. Windows WARP probe: 0.329 ms p95, 6.860 ms maximum device-work stall during two FXC builds; hardware measurements remain pending (#9157, #9364, #6419) | partial | | GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects. Camera Raw's preview runs its per-pixel stages (white balance to grain) in one wgpu pass, about 2-3 ms vs 11-28 ms on the CPU for the 858×572 proxy, depending on the machine; spatial stages, Calibration and OK still develop on the CPU (#6916) | 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 | @@ -62,6 +63,7 @@ HDR display to verify. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Build GPU compositor pipelines in the background; benchmark baseline pending (#9157) | | 2026-10-10 | major | First version | | 2026-10-17 | minor | GPU filters: Camera Raw's preview, per-pixel stages (#6916 step 1) | | 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) | diff --git a/docs/scorecard.md b/docs/scorecard.md index c540f7b060..c225a85a94 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 | 49 | 83 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 45 not run, 4 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 189 (target 0) | @@ -98,6 +98,8 @@ Baseline: 2026-10-05, 1 run, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Appl | P77 | Toggle an adjustment layer's visibility | ≤ 10 ms p95 | – | – | – | – | – | not run | [#6535](https://github.com/storytold/photocraft/issues/6535) | | P78 | Hide the top adjustment layer among 150 full-canvas layers, 6 MP, GPU budget forced to 512 MB (CPU fallback) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#6533](https://github.com/storytold/photocraft/issues/6533) | | P79 | Zoomed-out reduced composite of a 30000x30000 document with one painted layer (#9330 target: 60000x60000) | track only | – | – | – | – | – | not run | [#1015](https://github.com/storytold/photocraft/issues/1015) | +| B2a | First document: UI-thread time to first pixels | ≤ 100 ms p95 | – | – | – | – | – | not run | [#9157](https://github.com/storytold/photocraft/issues/9157) | +| B2b | First document: GPU compositor ready | ≤ 2000 ms p95 | – | – | – | – | – | not run | [#9157](https://github.com/storytold/photocraft/issues/9157) | | P80 | App frame: full CPU refresh of a 24 MP document | ≤ 50 ms p95 | – | – | – | – | – | not run | [#9328](https://github.com/storytold/photocraft/issues/9328) | | P81 | Undo a brush dab on a full-canvas 16-bit 15000 x 10000 layer + refresh | track only | – | – | – | – | – | not run | [#9329](https://github.com/storytold/photocraft/issues/9329) | | P82 | Fill a 2 % selection on a full-canvas 24 MP layer + refresh | track only | – | – | – | – | – | not run | [#9329](https://github.com/storytold/photocraft/issues/9329) | diff --git a/perf/budgets.toml b/perf/budgets.toml index 762a8d27b7..32d44b72d7 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -767,6 +767,28 @@ bench = "perf_scenarios" row = "zoomed-out reduced composite of the huge document with one painted layer" enforce = false # tracked only +# ---- #9157: background compositor startup ------------------------------------------------------ + +[[scenario]] +id = "B2a" +title = "First document: UI-thread time to first pixels" +issue = 9157 +bench = "perf_scenarios" +row = "first document: UI-thread time to first pixels" +metric = "p95" +budget_ms = 100 +enforce = false # nightly Metal baseline not recorded yet + +[[scenario]] +id = "B2b" +title = "First document: GPU compositor ready" +issue = 9157 +bench = "perf_scenarios" +row = "first document: GPU compositor ready" +metric = "p95" +budget_ms = 2000 +enforce = false # informational until a nightly Metal baseline exists + [[scenario]] id = "P80" title = "App frame: full CPU refresh of a 24 MP document" From e55e2f3577ad08cb5ff84779a4eee1b8003bf98f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 03:19:52 +0530 Subject: [PATCH 57/63] Place decodes and fits in the background: dropping a large file no longer freezes the window (#10147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Place decodes, fits and renders in a background job File > Place Embedded / Linked started with Session::start, and Session::start_place_bytes for bytes with no path, now run the decode, flatten, resize and SVG render on a worker (prepare_place) and insert the layer when the job is applied (apply_place). Inline callers (execute, the CLI, headless MCP, actions) behave and return exactly as before. Part of #10031. * Dropped and placed files no longer freeze the window on desktop With background jobs on, File > Place and files dropped on the canvas start the place as a job (JobsUi.place). Free Transform begins when the layer has landed, a failed or cancelled place is reported and the drop queue goes on, and a drop that arrives meanwhile waits for it. The place shows its progress in the status bar instead of the modal dialog, so the view stays usable. Part of #10031. * Tests: compare place job results without the layer id Layer ids come from a counter shared by every session, so a job and an inline place in two sessions never get the same one. * Dropped files stay with the document they were dropped on Each queued canvas drop now carries its document's id. A file is placed only while that document is active: files dropped on a document in another tab wait until it is back in view, and are discarded with a message when it is closed. Web drops, read by the shell after the drop, go to the document the drop landed on. Before, the next queued file went into whichever document was active when the previous place finished, failed or was cancelled, in the background and inline paths alike. * Remove the unused file_open import * fix: address background placement review feedback * fix: preserve drop targets and interrupt placement pixel stages * fix: qualify placement inbox document identity * test: wait for background placement in app drop regression * fix: retain queued drops while external document jobs run * test: clone rendered pixels from drop-managed buffers * test: cover band rows channels and queued-drop resumption * Place decodes and fits in the background: dropping a large file no longer freezes the window Placing a file decoded, flattened and resized on the UI thread; a large PNG froze the window for seconds (#10031). `place_bytes` is split into a session read, `prepare_place` (decode, flatten, fit, translate, SVG render at placement scale) and `apply_place`, and runs through `jobs::run`, so File › Place and canvas drops run on a worker with progress and cancel. The window follows the job: status-bar progress, Free Transform when the layer lands, and drops queued per target document. Review fixes on the way: - a path and byte drop queue entry records its target DocId, so files dropped on one document never land in another after a tab switch, and closed documents discard theirs; - the closed-document notice stays up for a frame instead of being cleared by the next queued placement; - the queue is bounded (32 files / 256 MiB) so drops waiting on another tab cannot grow it without limit; - flatten and resize stop at the job's interrupt, and a place skips the modal progress dialog only when the status bar actually shows its progress; - the placement's `doc` is kept only for SVG input, through the importer's `is_svg_input`; - the interrupted-composite test uses an opaque fixture that varies by row and channel, and checks the composite against the layer's stored pixels. * Place reads its file in the job, and refuses a huge canvas as before file.placeEmbedded and file.placeLinked read the whole file in the command before the job started, so a path dropped on the canvas or picked in File > Place still blocked the window for the read. The place now carries the path and reads it in prepare_place: on the job's worker in the background, on the calling thread inline (wasm, background jobs off), with the same read_file limit and error text. Whether the source is a vector one is decided once it has been read. The job's flatten check returned Other("the document canvas is too large to flatten"), where the place used to give flattened_checked's BadParams for file.place naming the canvas size. Both now share check_flattenable, so the old error type and message are back. Co-Authored-By: Claude Opus 5.5 * Path drops aren't capped, and a discarded drop wakes the queue The 32-file cap on the drop queue counted path drops, which hold no file contents while they wait, so dropping 40 files on the canvas placed 32 and refused 8 where main placed them all. The count cap now applies to browser drops only (each holds its whole file); the byte budget still bounds those. When place_next_dropped discarded a closed document's files it returned without asking for another frame, so another document's queued files waited for the next input event. It now requests a repaint when files remain, keeping the one frame that shows the notice. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 21 +- crates/algo/src/resample.rs | 29 +- crates/algo/src/resample/tests.rs | 15 + crates/compose/src/lib.rs | 4 +- crates/compose/src/render.rs | 27 ++ crates/compose/tests/interrupt_surface.rs | 36 ++ crates/engine/src/cmd/file_cmds.rs | 17 +- crates/engine/src/cmd/file_cmds/open.rs | 271 +++++++++--- .../src/cmd/file_cmds/place_job_tests.rs | 262 ++++++++++++ crates/engine/src/jobs.rs | 16 + crates/engine/tests/it/svg_smart_object.rs | 38 ++ crates/io/src/lib.rs | 2 +- crates/ui-egui/src/app/frame.rs | 14 +- crates/ui-egui/src/file_dialog/tests.rs | 33 ++ crates/ui-egui/src/file_open.rs | 134 +++++- crates/ui-egui/src/file_open/tests.rs | 389 +++++++++++++++++- crates/ui-egui/src/jobs_ui.rs | 102 ++++- crates/ui-egui/src/lib.rs | 9 +- crates/ui-egui/src/menus/view_cmds/dialogs.rs | 3 +- crates/ui-egui/src/services.rs | 4 +- docs/control-protocol.md | 2 +- 21 files changed, 1318 insertions(+), 110 deletions(-) create mode 100644 crates/compose/tests/interrupt_surface.rs create mode 100644 crates/engine/src/cmd/file_cmds/place_job_tests.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 54cf56adc5..3590f42f1a 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -15,6 +15,7 @@ use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Ser use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; +type PlaceInbox = photocraft_ui_egui::PlaceInbox; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries. @@ -95,7 +96,7 @@ pub fn start() { PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); - let place_inbox: Inbox = Arc::default(); + let place_inbox: PlaceInbox = Arc::default(); let served: Served = Arc::default(); load_font_manifest(served.clone(), cc.egui_ctx.clone()); let (session, warnings) = crate::preset_bridge::session(presets.clone()); @@ -344,7 +345,7 @@ struct WebShell { /// Dropped files to open as documents. inbox: Inbox, /// Dropped files to place on the active document's canvas. - place_inbox: Inbox, + place_inbox: PlaceInbox, /// Read by the `beforeunload` listener ([`guard_unload`]). unsaved: Arc, served: Served, @@ -431,16 +432,22 @@ impl eframe::App for WebShell { // Decided now, where the drop landed: on the document's canvas the files are placed // as layers (as on the desktop), elsewhere opened. let places = self.app.dropped_on_canvas(ctx, &names); - let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect(); + let target_doc = self.app.session.active().map(|st| st.doc.id); + let files: Vec<_> = dropped.into_iter().zip(names).zip(places).map(|((f, name), place)| (f, name, place, target_doc)).collect(); let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone()); let ctx = ctx.clone(); // One after another, so they open or place in the order they were dropped. wasm_bindgen_futures::spawn_local(async move { - for ((f, name), place) in files { + for (f, name, place, target_doc) in files { match f.bytes_async().await { Ok(bytes) => { - let queue = if place { &place_inbox } else { &inbox }; - queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); + if place { + if let Some(doc) = target_doc { + place_inbox.lock().unwrap_or_else(PoisonError::into_inner).push((doc, name, bytes)); + } + } else { + inbox.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); + } ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -484,7 +491,7 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { +fn services(inbox: Inbox, place_inbox: PlaceInbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index 4dcf0b6944..15a2eb72aa 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -348,10 +348,32 @@ fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option) /// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so /// the intermediate image is never larger than the result. fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option, budget: usize) -> Surface { + resize_controlled(s, sx, sy, filter, canvas, budget, &photocraft_raster::Interrupt::NONE) +} + +/// Resize with cancellation between passes and bounded groups of output bands. +/// A cancelled operation returns no partially resized surface. +pub fn resize_surface_with_interrupt( + s: &Surface, + sx: f64, + sy: f64, + filter: Resample, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check()?; + let out = resize_controlled(s, sx, sy, filter, None, READ_BUDGET, ctl); + ctl.check()?; + Ok(out) +} + +fn resize_controlled(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface { let mut cur: Option = None; let mut canvas = canvas; for p in plan(filter, sx, sy) { - let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget); + if ctl.cancelled() { + return Surface::new(s.format()); + } + let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget, ctl); canvas = canvas.map(|c| scaled_rect(c, p.sx, p.sy)); cur = Some(next); } @@ -368,7 +390,7 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: O /// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see /// [`resize_surface_in_canvas`]). -fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> Surface { +fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface { let Pass { sx, sy, kx, ky } = pass; let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty()); let fmt = s.format(); @@ -484,6 +506,9 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> #[cfg(target_arch = "wasm32")] let group = 1; for chunk in bands.chunks(group) { + if ctl.cancelled() { + return Surface::new(fmt); + } #[cfg(not(target_arch = "wasm32"))] let parts: Vec<(Rect, Vec)> = { use rayon::prelude::*; diff --git a/crates/algo/src/resample/tests.rs b/crates/algo/src/resample/tests.rs index 0ee08847e1..3c80881675 100644 --- a/crates/algo/src/resample/tests.rs +++ b/crates/algo/src/resample/tests.rs @@ -400,3 +400,18 @@ fn nearest_neighbor_reduction_does_not_average() { } } } + +#[test] +fn controlled_resize_matches_pixels_and_discards_cancelled_work() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let src = ramp(depth); + let expected = resize_surface(&src, 1.7, 0.6, Resample::Bicubic); + let actual = resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &photocraft_raster::Interrupt::NONE).unwrap(); + let bounds = expected.content_bounds(); + assert_eq!(expected.read_region(bounds), actual.read_region(bounds)); + let cancelled = || true; + let progress = |_| {}; + let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress); + assert!(resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &ctl).is_err()); + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 2af1c2e8fb..9364806c55 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -82,8 +82,8 @@ use render::in_blend_space; use render::render_tiled_with; pub(crate) use render::visible_layers; pub use render::{ - BAND_PIXELS, MAX_RENDER_PIXELS, band_rows_for, cmyk_space, composite_to_surface, flatten, flatten_to_surface, render, render_bands, render_layer, - render_layer_in, render_tiled, + BAND_PIXELS, MAX_RENDER_PIXELS, band_rows_for, cmyk_space, composite_to_surface, composite_to_surface_with_interrupt, flatten, flatten_to_surface, render, + render_bands, render_layer, render_layer_in, render_tiled, }; /// Straight-alpha RGBA float buffer covering a rectangle. diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index 250a3ebce1..4f5d629f3a 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -357,3 +357,30 @@ pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer { buf }) } + +/// Composite in bounded bands, retaining native colour-model paths, with cancellation. +pub fn composite_to_surface_with_interrupt( + doc: &Document, + rect: Rect, + fmt: photocraft_color::PixelFormat, + background: Option<[f32; 3]>, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check()?; + let mut out = Surface::new(fmt); + let rows = band_rows_for(rect.width(), 64); + let mut y = rect.y0; + while y < rect.y1 { + ctl.check()?; + let end = y.saturating_add(rows).min(rect.y1); + let band = Rect::new(rect.x0, y, rect.x1, end); + let part = composite_to_surface(doc, band, fmt, background); + ctl.check()?; + let mut pixels = Vec::new(); + part.read_region_into(band, &mut pixels); + out.write_region(band, &pixels); + y = end; + } + out.prune(); + Ok(out) +} diff --git a/crates/compose/tests/interrupt_surface.rs b/crates/compose/tests/interrupt_surface.rs new file mode 100644 index 0000000000..2131c52b79 --- /dev/null +++ b/crates/compose/tests/interrupt_surface.rs @@ -0,0 +1,36 @@ +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_compose::{composite_to_surface, composite_to_surface_with_interrupt}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::Interrupt; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[test] +fn controlled_composite_keeps_native_pixels_and_cancels_between_bands() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("bands", Size::new(16, 600), mode, depth); + let fmt = PixelFormat::new(mode, depth, true); + let mut layer = Layer::raster("pixels", fmt); + let (channels, colors) = (fmt.channels(), mode.color_channels()); + for y in 0..600 { + // Every row and channel differs, and every pixel is opaque: a band at the wrong + // offset or with a swapped channel changes the result. + let pixel: Vec = + (0..channels).map(|c| if c == colors { 1.0 } else { (1 + (y as usize % 29) * channels + c * 7) as f32 / 255.0 }).collect(); + layer.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, y, 16, 1), &pixel); + } + // What the layer holds: an opaque single raster layer composites to its own pixels. + let stored = layer.surface().unwrap().read_region(doc.bounds()); + doc.layers.push(layer); + let expected = composite_to_surface(&doc, doc.bounds(), fmt, None); + let actual = composite_to_surface_with_interrupt(&doc, doc.bounds(), fmt, None, &Interrupt::NONE).unwrap(); + assert_eq!(actual.read_region(doc.bounds()), expected.read_region(doc.bounds()), "{mode:?} {depth:?}"); + assert_eq!(actual.read_region(doc.bounds()), stored, "the composite keeps the layer's pixels: {mode:?} {depth:?}"); + let calls = AtomicUsize::new(0); + let cancelled = || calls.fetch_add(1, Ordering::Relaxed) >= 3; + let progress = |_| {}; + assert!(composite_to_surface_with_interrupt(&doc, doc.bounds(), fmt, None, &Interrupt::new(&cancelled, &progress)).is_err()); + } + } +} diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 7e04fcff0e..e14f6a2fed 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -10,7 +10,7 @@ use std::sync::Arc; -use photocraft_algo::resample::{Resample, resize_surface, translate_surface}; +use photocraft_algo::resample::{Resample, translate_surface}; use photocraft_color::{ColorMode, PixelFormat}; use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, SmartObject, SmartSource}; use photocraft_geom::Rect; @@ -47,8 +47,8 @@ pub use info::{CAMERA_FIELDS, camera_data, read_file_info, write_file_info}; use info::{exposure_text, find_element}; pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_batch_open}; pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; +pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, prepare_place, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; -pub(crate) use open::{import, import_with_options, tag_exr_colorspace}; use paths::is_file; #[cfg(test)] use paths::openable_name; @@ -171,11 +171,18 @@ pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface { /// a sparse bundle declares its canvas, and one the size of the format allows would be rendered /// in full, however little it holds. Refused past the compositor's own per-render limit. pub(crate) fn flattened_checked(doc: &Document, fmt: PixelFormat, cmd: &str) -> Result { + check_flattenable(doc, cmd)?; + Ok(flattened(doc, fmt)) +} + +/// The refusal [`flattened_checked`] gives a canvas past the compositor's per-render limit, for a +/// caller that flattens `doc` some other way (a place job, which can be cancelled while it does). +pub(crate) fn check_flattenable(doc: &Document, cmd: &str) -> Result<()> { let b = doc.bounds(); if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("the document's {}×{} canvas is too large to flatten", b.width(), b.height()) }); } - Ok(flattened(doc, fmt)) + Ok(()) } fn new_guide_layout(s: &mut Session, p: &Value) -> Result { @@ -408,6 +415,10 @@ pub fn specs() -> Vec { #[path = "file_cmds/tests.rs"] mod tests; +#[cfg(test)] +#[path = "file_cmds/place_job_tests.rs"] +mod place_job_tests; + #[cfg(test)] mod aces_exr_tests { use super::*; diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index f1a01d0afe..cb05f39483 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -1,8 +1,9 @@ use super::paths::{file_name, read_file, stem, template_name}; use super::{ - Affine, Arc, Document, EngineError, Layer, LayerContent, LayerId, Resample, Result, Session, SmartObject, SmartSource, Value, f64_param, flattened_checked, - json, resize_surface, str_param, translate_surface, + Affine, Arc, Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, Resample, Result, Session, SmartObject, SmartSource, Value, f64_param, json, + str_param, translate_surface, }; +use crate::jobs::JobCtx; /// Input and successfully opened document identities of one Override Open scratch session. pub(crate) struct BatchOpen { @@ -312,47 +313,164 @@ pub const PLACE_EMBEDDED: &str = "Place Embedded"; /// `linked` makes it a linked smart object that refers to that path instead of embedding the bytes. /// With Preferences ▸ General ▸ Always Create Smart Objects When Placing off, an embedded place /// lands as a plain pixel layer instead (an explicit `"smartObject": false` asks for one). +/// +/// The decoding and resampling are a background job when the command was started with +/// [`Session::start`] (see [`crate::jobs`]); inline otherwise. The layer is inserted by the apply +/// step on the calling thread, as one undo step. pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option, p: &Value) -> Result { - let d = s.active().ok_or(EngineError::NoDocument)?; - let frame_id = match p.get("frame") { - Some(Value::Bool(false)) => None, - Some(v) if !v.is_null() => Some(LayerId(v.as_u64().ok_or_else(|| EngineError::Other("frame must be a frame id or false".into()))?)), - _ => d.active_layer.and_then(|id| crate::frame_cmds::frame_of(&d.doc, id)), - }; - let frame = frame_id - .map(|id| { - d.doc.layer(id).and_then(|l| l.frame.clone()).map(|f| (id, f)).ok_or_else(|| EngineError::Other("the destination frame is unavailable".into())) + let input = PlaceInput::new(s, name, bytes, linked, p)?; + place_input(s, input) +} + +/// Run a place: in the background when the command was started with [`Session::start`]. +fn place_input(s: &mut Session, input: PlaceInput) -> Result { + let label = input.job_label(); + crate::jobs::run(s, &label, true, move |ctx| prepare_place(input, ctx), apply_place) +} + +/// The file a place reads. +enum PlaceFile { + /// Its contents, already read (a dropped or picked file with no path, a script's bytes). + Bytes(Vec), + /// Its path, read by [`prepare_place`]: on the job's worker when the place runs in the + /// background, so a large file doesn't hold up the window while it is read. + Path(String), +} + +/// Everything a place reads from the session, taken up front so [`prepare_place`] can run on a +/// worker that never sees the [`Session`]. +pub(crate) struct PlaceInput { + name: String, + file: PlaceFile, + linked: Option, + params: Value, + canvas: (f64, f64), + fmt: PixelFormat, + opts: photocraft_io::ImportOptions, + /// Preferences ▸ General ▸ Resize Image During Place, unless the params say otherwise. + fit: bool, + /// Preferences ▸ General ▸ Always Create Smart Objects When Placing, unless the params say + /// otherwise. A linked place is always a smart object. + smart: bool, + /// The document as the job sees it, for rendering a vector source at its placement scale. A + /// path isn't read yet, so whether it is a vector source is decided once it is. + doc: Option>, + /// The frame the image goes into: the active layer's, `"frame"` in the params, or none + /// (`"frame": false`). Its image renders through a smart object. + frame: Option<(LayerId, photocraft_doc::Frame)>, +} + +impl PlaceInput { + pub(crate) fn new(s: &Session, name: &str, bytes: Vec, linked: Option, p: &Value) -> Result { + Self::with_file(s, name, PlaceFile::Bytes(bytes), linked, p) + } + + fn with_file(s: &Session, name: &str, file: PlaceFile, linked: Option, p: &Value) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let prefs = s.prefs(); + let frame_id = match p.get("frame") { + Some(Value::Bool(false)) => None, + Some(v) if !v.is_null() => Some(LayerId(v.as_u64().ok_or_else(|| EngineError::Other("frame must be a frame id or false".into()))?)), + _ => d.active_layer.and_then(|id| crate::frame_cmds::frame_of(&d.doc, id)), + }; + let frame = frame_id + .map(|id| { + d.doc + .layer(id) + .and_then(|l| l.frame.as_deref().cloned()) + .map(|f| (id, f)) + .ok_or_else(|| EngineError::Other("the destination frame is unavailable".into())) + }) + .transpose()?; + let canvas = frame.as_ref().map_or((d.doc.size.width as f64, d.doc.size.height as f64), |(_, f)| { + let [a, b, c, d, _, _] = f.transform.m; + (a.hypot(b), c.hypot(d)) + }); + let approve = match p.get("approveHighMemory") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + }; + let mut opts = crate::jobs::import_options(prefs); + opts.psd_high_memory_approved = approve; + opts.psd_approved_estimate = None; + Ok(Self { + name: name.to_string(), + canvas, + fmt: d.doc.pixel_format(), + opts, + // The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default). + fit: p.get("fit").and_then(Value::as_bool).unwrap_or(prefs.general.resize_image_during_place), + // Preferences ▸ General ▸ Always Create Smart Objects When Placing (on by default): off, + // an embedded place lands as a plain pixel layer. + smart: linked.is_some() || p.get("smartObject").and_then(Value::as_bool).unwrap_or(prefs.general.always_create_smart_objects_when_placing), + doc: match &file { + PlaceFile::Bytes(bytes) => (frame.is_some() || photocraft_io::is_svg_input(name, bytes)).then(|| Arc::clone(&d.doc)), + PlaceFile::Path(_) => Some(Arc::clone(&d.doc)), + }, + frame, + params: p.clone(), + file, + linked, }) - .transpose()?; - let (cw, ch) = frame.as_ref().map_or((d.doc.size.width as f64, d.doc.size.height as f64), |(_, f)| { - let [a, b, c, d, _, _] = f.transform.m; - (a.hypot(b), c.hypot(d)) - }); - let fmt = d.doc.pixel_format(); - let vector = photocraft_io::svg::is_svg(&bytes); - // Refuse before decoding if the operation cannot fit. - crate::memory_gate::ensure(&format!("place \u{201c}{}\u{201d}", file_name(name)), crate::memory_gate::place_need(&bytes, fmt.bytes_per_pixel()))?; - let approve = match p.get("approveHighMemory") { - None | Some(Value::Null) => false, - Some(Value::Bool(b)) => *b, - Some(_) => { - return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + + /// The job's name in the progress UI. + pub(crate) fn job_label(&self) -> String { + format!("Placing {}", file_name(&self.name)) + } + + /// The command this place stands for. + pub(crate) fn command(&self) -> &'static str { + if self.linked.is_some() { "file.placeLinked" } else { "file.placeEmbedded" } + } +} + +/// A place decoded, flattened and fitted, ready to be inserted by [`apply_place`]. +pub(crate) struct Prepared { + content: LayerContent, + layer_name: String, + /// The history step's name. + label: &'static str, + scale: f64, + bounds: [f64; 4], + smart: bool, + /// The frame the layer fills, if any (it becomes the frame's content instead of a new layer). + frame: Option, +} + +/// The heavy part of a place: read (a path), decode, flatten, resize and translate (and render a +/// vector source at its placement scale). Touches no [`Session`], so it can run on a worker; `ctx` +/// reports progress and stops it between stages when cancelled. +pub(crate) fn prepare_place(input: PlaceInput, ctx: &JobCtx) -> Result { + let PlaceInput { name, file, linked, params: p, canvas: (cw, ch), fmt, opts, fit, smart, doc, frame } = input; + let bytes = match file { + PlaceFile::Bytes(bytes) => bytes, + PlaceFile::Path(path) => { + ctx.progress(0.0, "Reading"); + let bytes = read_file(&path)?; + ctx.check()?; + bytes } }; - let mut opts = crate::jobs::import_options(s.prefs()); - opts.psd_high_memory_approved = approve; - opts.psd_approved_estimate = None; - let imported = photocraft_io::import_with_options(name, &bytes, &opts).map_err(|e| crate::error::io_open_error(name, e))?; + // Refuse before decoding if the operation cannot fit. + crate::memory_gate::ensure(&format!("place \u{201c}{}\u{201d}", file_name(&name)), crate::memory_gate::place_need(&bytes, fmt.bytes_per_pixel()))?; + let doc = doc.filter(|_| frame.is_some() || photocraft_io::is_svg_input(&name, &bytes)); + let imported = ctx.stage(0.0, 0.6, "Decoding", |ctl| photocraft_io::import_with_options_and_interrupt(&name, &bytes, &opts, ctl)).map_err(|e| match e { + photocraft_io::IoError::Cancelled => EngineError::Cancelled, + e => crate::error::io_open_error(&name, e), + })?; if imported.preview_only { return Err(EngineError::Other(format!( "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" ))); } + ctx.check()?; let src = imported.document; let (w, h) = (src.size.width as f64, src.size.height as f64); - // The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default). - let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(s.prefs().general.resize_image_during_place); - let scale = match f64_param(p, "scale") { + let scale = match f64_param(&p, "scale") { Some(k) => (k / 100.0).max(1e-4), None if frame.is_some() && fit => (cw / w).max(ch / h), None if fit && (w > cw || h > ch) => (cw / w).min(ch / h), @@ -366,14 +484,25 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option 1e-9 { - px = resize_surface(&px, scale, scale, Resample::Bicubic); + ctx.progress(0.7, "Resizing"); + px = ctx + .stage(0.7, 0.9, "Resizing", |ctl| photocraft_algo::resample::resize_surface_with_interrupt(&px, scale, scale, Resample::Bicubic, ctl)) + .map_err(|_| EngineError::Cancelled)?; + ctx.check()?; } Some(translate_surface(&px, dx as i32, dy as i32)) } else { None }; + drop(src); let placement = frame .as_ref() .map_or(Affine { m: [scale, 0.0, 0.0, scale, dx, dy] }, |(_, f)| f.transform.mul(&Affine { m: [scale / cw, 0.0, 0.0, scale / ch, dx / cw, dy / ch] })); @@ -384,45 +513,50 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option SmartSource::Linked { path }, - None => SmartSource::Embedded { file_name: file_name(name), bytes: Arc::new(bytes) }, - }; - let layer_name = stem(name); - let label = if matches!(&source, SmartSource::Linked { .. }) { "Place Linked" } else { PLACE_EMBEDDED }; - let id = s.edit(label, |doc, active| { - let layer = if smart { - let mut so = SmartObject::new(source, placement, px); - // A vector source (SVG) renders at its placement scale instead of as a resampled raster. - if (vector || frame.is_some()) - && let Some(sharp) = crate::smart_cmds::render(doc, &so)? - { + // A frame's image is always a smart object. + let smart = smart || frame.is_some(); + let label = if linked.is_some() { "Place Linked" } else { PLACE_EMBEDDED }; + let content = if smart { + let source = match linked { + Some(path) => SmartSource::Linked { path }, + None => SmartSource::Embedded { file_name: file_name(&name), bytes: Arc::new(bytes) }, + }; + let mut so = SmartObject::new(source, placement, px); + // A vector source (SVG) and a frame's image render at their placement scale instead of as + // a resampled raster. + if let Some(doc) = doc { + ctx.progress(0.9, "Rendering"); + ctx.check()?; + if let Some(sharp) = crate::smart_cmds::render(&doc, &so)? { so.cache = Some(sharp); } - if so.cache.is_none() { - return Err(EngineError::Other(format!("{name}: the placed image could not be rendered"))); - } - Layer::new(layer_name, LayerContent::Smart(so)) - } else { - let px = px.ok_or_else(|| EngineError::Other(format!("{name}: a frame's image must be a smart object")))?; - Layer::new(layer_name, LayerContent::Raster(px)) - }; - let id = layer.id; - if let Some((frame_id, _)) = &frame { - crate::frame_cmds::put_content(doc, *frame_id, layer)?; - *active = Some(*frame_id); + } + if so.cache.is_none() { + return Err(EngineError::Other(format!("{name}: the placed image could not be rendered"))); + } + LayerContent::Smart(so) + } else { + LayerContent::Raster(px.ok_or_else(|| EngineError::Other(format!("{name}: a frame's image must be a smart object")))?) + }; + Ok(Prepared { content, layer_name: stem(&name), label, scale, bounds, smart, frame: frame.map(|(id, _)| id) }) +} + +/// Insert the prepared layer as one undo step and report the place. Runs on the session's thread. +pub(crate) fn apply_place(s: &mut Session, p: Prepared) -> Result { + let Prepared { content, layer_name, label, scale, bounds, smart, frame } = p; + let layer = Layer::new(layer_name, content); + let id = layer.id; + s.edit(label, move |doc, active| { + if let Some(frame_id) = frame { + crate::frame_cmds::put_content(doc, frame_id, layer)?; + *active = Some(frame_id); } else { crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::AutoEditable); *active = Some(id); } - Ok(id) + Ok(()) })?; - Ok(json!({"layer": id.0, "frame": frame.as_ref().map(|(id,_)|id.0), "scale": scale * 100.0, "bounds": bounds, "smartObject": smart})) + Ok(json!({"layer": id.0, "frame": frame.map(|f| f.0), "scale": scale * 100.0, "bounds": bounds, "smartObject": smart})) } pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { @@ -431,7 +565,6 @@ pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected boolean for approveHighMemory".into() }); } let path = str_param(p, "path", cmd)?.to_string(); - let bytes = read_file(&path)?; // The link is stored absolute: a relative one would later be taken from the document's // folder (`doc_path`), not the working directory the path was given in. let link = linked.then(|| { @@ -441,5 +574,7 @@ pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { path.clone() } }); - place_bytes(s, &path, bytes, link, p) + // The file is read by the place itself, on the job's worker when it runs in the background. + let input = PlaceInput::with_file(s, &path, PlaceFile::Path(path.clone()), link, p)?; + place_input(s, input) } diff --git a/crates/engine/src/cmd/file_cmds/place_job_tests.rs b/crates/engine/src/cmd/file_cmds/place_job_tests.rs new file mode 100644 index 0000000000..f174a54f50 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/place_job_tests.rs @@ -0,0 +1,262 @@ +//! Place as a background job (#10031): the decode, flatten and resize run on a worker and the +//! layer is inserted when the job is applied. Inline callers see no difference. + +use std::time::{Duration, Instant}; + +use super::*; +use crate::jobs::{JobEvent, JobId, JobOutcome, Started}; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("photocraft-place-job-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.to_string_lossy().into_owned() +} + +fn session(w: u32, h: u32, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); + s +} + +/// A small RGB gradient PNG, so a wrong pixel anywhere shows. +fn gradient_png(w: u32, h: u32) -> Vec { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +fn job(r: Started) -> JobId { + match r { + Started::Job(id) => id, + Started::Done(v) => panic!("expected a background job, ran inline: {v}"), + } +} + +/// Poll until job `id` ends (or 60 s pass); returns its event. +fn wait_event(s: &mut Session, id: JobId) -> JobEvent { + let t = Instant::now(); + loop { + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + return e; + } + assert!(t.elapsed() < Duration::from_secs(60), "job {id:?} did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// The active layer's pixels over the canvas and its content. +fn layer_pixels(s: &Session) -> Vec { + let d = s.active().unwrap(); + let surface = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap(); + surface.read_region(d.doc.bounds().union(&surface.content_bounds())) +} + +fn canvas_pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px.clone() +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// The place a job does is the place the command does inline: the same result, layer, pixels and +/// one history step, at every depth, for a smart object and for a pixel layer. +#[test] +fn a_place_job_equals_the_inline_place() { + let dir = tmp("equal"); + let path = join(&dir, "grad.png"); + std::fs::write(&path, gradient_png(160, 80)).unwrap(); + for depth in [8, 16, 32] { + for smart in [true, false] { + let params = json!({"path": path, "smartObject": smart}); + let mut inline = session(100, 100, depth); + let want = inline.execute("file.placeEmbedded", params.clone()).unwrap(); + + let mut s = session(100, 100, depth); + let before = steps(&s); + let id = job(s.start("file.placeEmbedded", params).unwrap()); + assert_eq!(steps(&s), before, "nothing is inserted before the job is applied"); + let e = wait_event(&mut s, id); + let JobOutcome::Done(got) = &e.outcome else { panic!("{depth} {smart}: {e:?}") }; + assert_eq!(e.command, "file.placeEmbedded"); + assert_eq!(e.label, "Placing grad.png"); + same_but_layer(got, &want); + assert_eq!(steps(&s), before + 1, "one undo step"); + assert_eq!(s.active().unwrap().history.undo_label(), Some(PLACE_EMBEDDED)); + assert_eq!(layer_pixels(&s), layer_pixels(&inline), "layer pixels at {depth}-bit, smart {smart}"); + assert_eq!(canvas_pixels(&s), canvas_pixels(&inline), "composite at {depth}-bit, smart {smart}"); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + } + } +} + +/// Place Linked as a job keeps the link and its history label. +#[test] +fn a_linked_place_job_keeps_the_link() { + let dir = tmp("linked"); + let path = join(&dir, "grad.png"); + std::fs::write(&path, gradient_png(40, 40)).unwrap(); + let mut s = session(100, 100, 8); + let id = s.start("file.placeLinked", json!({"path": path})).map(job).unwrap(); + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_))); + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + assert!(matches!(&l.content, LayerContent::Smart(so) if matches!(&so.source, SmartSource::Linked { path: p } if *p == path))); + assert_eq!(d.history.undo_label(), Some("Place Linked")); +} + +/// Bytes with no path (a web or dropped file) start the same job through `start_place_bytes`. +#[test] +fn start_place_bytes_matches_place_bytes() { + let bytes = gradient_png(160, 80); + let mut inline = session(100, 100, 16); + let want = place_bytes(&mut inline, "drop.png", bytes.clone(), None, &json!({})).unwrap(); + + let mut s = session(100, 100, 16); + let id = job(s.start_place_bytes("drop.png", bytes.clone(), None, json!({})).unwrap()); + // The document is locked meanwhile: another place (or edit) is refused with the job's name. + let refused = s.start_place_bytes("again.png", bytes, None, json!({})).unwrap_err().to_string(); + assert!(refused.contains("Placing drop.png"), "{refused}"); + assert!(s.execute("layer.new.layer", json!({})).is_err()); + assert!(s.execute("jobs.list", json!({})).is_ok(), "queries still run"); + let e = wait_event(&mut s, id); + assert_eq!(e.command, "file.placeEmbedded"); + let JobOutcome::Done(got) = &e.outcome else { panic!("{e:?}") }; + same_but_layer(got, &want); + assert_eq!(got["layer"], json!(s.active().unwrap().active_layer.unwrap().0), "the result names the placed layer"); + assert_eq!(layer_pixels(&s), layer_pixels(&inline)); + assert_eq!(steps(&s), steps(&inline)); +} + +/// Assert a place result equals `want` except for its `layer`: layer ids come from a counter +/// shared by every session, so two sessions never hand out the same one. +fn same_but_layer(got: &Value, want: &Value) { + let strip = |v: &Value| { + let mut v = v.clone(); + v.as_object_mut().map(|o| o.remove("layer")); + v + }; + assert!(got["layer"].is_u64(), "{got}"); + assert_eq!(strip(got), strip(want)); +} + +/// Cancelling stops the place: nothing is inserted and the document is the very same one. +#[test] +fn cancelling_a_place_job_leaves_the_document_unchanged() { + let mut s = session(100, 100, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + let id = job(s.start_place_bytes("drop.png", gradient_png(160, 80), None, json!({})).unwrap()); + assert!(s.cancel_job(id)); + s.join_cancelled_jobs(); + assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled); + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(steps(&s), before); + assert!(!s.active().unwrap().history.can_redo()); + assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled))); + assert!(s.active_job().is_none(), "the document is unlocked"); +} + +/// An edit made while the job runs wins: applying fails and no layer is placed. +#[test] +fn an_edit_during_a_place_job_discards_the_place() { + let mut s = session(100, 100, 8); + let id = job(s.start_place_bytes("drop.png", gradient_png(160, 80), None, json!({})).unwrap()); + // Commands are refused while the job runs; a direct edit (the shell's, say) is not. + s.edit("Poke", |doc, _| { + doc.name = "poked".into(); + Ok(()) + }) + .unwrap(); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert!(err.contains("Placing drop.png") && err.contains("document changed"), "{err}"); + let d = s.active().unwrap(); + assert_eq!(d.doc.name, "poked", "the edit is kept"); + assert_eq!(d.doc.layers.len(), 1, "nothing was placed"); + assert_eq!(d.history.undo_label(), Some("Poke")); +} + +/// Hostile input as a job is an error, never a panic, and the document is left alone. +#[test] +fn bad_input_as_a_place_job_fails_without_panicking() { + let dir = tmp("bad"); + let good = gradient_png(32, 32); + let empty = join(&dir, "empty.png"); + std::fs::write(&empty, b"").unwrap(); + let truncated = join(&dir, "truncated.png"); + std::fs::write(&truncated, good.get(..good.len() / 2).unwrap()).unwrap(); + let garbage = join(&dir, "garbage.png"); + std::fs::write(&garbage, [0x89u8, b'P', b'N', b'G', 0xff, 0x00, 0x13, 0x37, 0x42]).unwrap(); + + let mut s = session(64, 64, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + for path in [&empty, &truncated, &garbage] { + let id = job(s.start("file.placeEmbedded", json!({"path": path})).unwrap()); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{path}: {e:?}") }; + assert!(err.contains("png"), "the file is named: {err}"); + } + for bytes in [Vec::new(), good.get(..10).unwrap().to_vec(), vec![0u8; 3]] { + let id = job(s.start_place_bytes("bad.png", bytes, None, json!({})).unwrap()); + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_))); + } + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc), "a failed place changes nothing"); + assert_eq!(steps(&s), before); + // Params of the wrong type fall back to the defaults or are refused, but never panic. + let params = json!({"scale": "big", "center": "middle", "fit": "yes", "smartObject": 3}); + if let Ok(Started::Job(id)) = s.start_place_bytes("ok.png", good, None, params) { + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_))); + assert!(s.undo()); + } + assert_eq!(steps(&s), before); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + // Inline, a missing document is the same error as before. + let mut none = Session::new(); + assert!(matches!(place_bytes(&mut none, "a.png", Vec::new(), None, &json!({})), Err(EngineError::NoDocument))); + assert!(matches!(none.start_place_bytes("a.png", Vec::new(), None, json!({})), Err(EngineError::NoDocument))); +} + +/// A place from a path reads the file in the job, not in the command that starts it: a large file +/// doesn't hold up the window while it is read. A file that can't be read fails the job with the +/// error the inline place gives, and changes nothing. +#[test] +fn a_place_job_reads_its_file_on_the_worker() { + let dir = tmp("read"); + let missing = join(&dir, "missing.png"); + let mut inline = session(64, 64, 8); + let want = inline.execute("file.placeEmbedded", json!({"path": missing})).unwrap_err().to_string(); + assert!(want.contains(&missing), "{want}"); + + let mut s = session(64, 64, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + let id = job(s.start("file.placeEmbedded", json!({"path": missing})).expect("the job starts before the file is read")); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert_eq!(err, &want); + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(steps(&s), before); +} + +/// A placed file whose canvas is past the compositor's limit is refused with the same error the +/// place gave before it ran as a job: bad params naming the canvas. +#[test] +fn a_canvas_too_large_to_flatten_is_refused_as_before() { + let huge = Document::new("huge", photocraft_doc::Size::new(40_000, 30_000), ColorMode::Rgb, photocraft_color::SampleType::U8); + let bytes = photocraft_format::save_to_bytes(&huge, &Default::default()).unwrap(); + let mut s = session(100, 100, 8); + let before = steps(&s); + match place_bytes(&mut s, "huge.pcraft", bytes.clone(), None, &json!({})) { + Err(EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, "file.place"); + assert_eq!(msg, "the document's 40000×30000 canvas is too large to flatten"); + } + other => panic!("{other:?}"), + } + let id = job(s.start_place_bytes("huge.pcraft", bytes, None, json!({})).unwrap()); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert!(err.contains("40000×30000 canvas is too large to flatten"), "{err}"); + assert_eq!(steps(&s), before, "nothing was placed"); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 94adb15a0e..15ae84a291 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -1084,6 +1084,22 @@ impl Session { self.start_open_inner(name, source, true, false, None, None, background) } + /// Place a file's bytes in the background, like `file.placeEmbedded` (`linked`: `file.placeLinked` + /// to that path) for bytes that have no path to read: decode, flatten and fit on a worker, then + /// insert the layer as one undo step when [`Session::poll_jobs`] applies it. `name` names the + /// layer and the file; `params` are the command's (`scale`, `center`, `fit`, `smartObject`). + /// The job's result is the command's: `{layer, frame, scale, bounds, smartObject}`. The active + /// document is locked meanwhile, so this is refused while another job runs on it. Runs inline + /// on wasm. + pub fn start_place_bytes(&mut self, name: &str, bytes: Vec, linked: Option, params: Value) -> Result { + let input = crate::file_cmds::PlaceInput::new(self, name, bytes, linked, ¶ms)?; + let (command, label) = (input.command(), input.job_label()); + if let Some(conflict) = self.job_conflict(command, true) { + return Err(EngineError::Other(conflict)); + } + self.start_job(command, json!({"name": name}), &label, true, move |ctx| crate::file_cmds::prepare_place(input, ctx), crate::file_cmds::apply_place) + } + /// Decodes a file in a job and optionally holds damaged PSD content for /// interactive confirmation while leaving headless opens non-interactive. // Keep the worker and apply closures together with their execution options. diff --git a/crates/engine/tests/it/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs index 16d8f3d459..c32041581f 100644 --- a/crates/engine/tests/it/svg_smart_object.rs +++ b/crates/engine/tests/it/svg_smart_object.rs @@ -62,3 +62,41 @@ fn re_placing_at_another_scale_re_renders_the_vector_source() { assert!(alpha(&surf, 268, 60) > 0.99 && alpha(&surf, 271, 60) < 0.01); assert!(red(&surf, 150, 81) < 0.02 && red(&surf, 150, 75) > 0.98 && red(&surf, 150, 87) > 0.98); } + +/// Placed through a background job, an SVG is rendered at its placement scale on the worker and +/// lands exactly as the inline place does: still sharp, not a resampled raster. +#[test] +fn an_svg_placed_as_a_job_is_as_sharp_as_the_inline_place() { + use photocraft_engine::jobs::Started; + let dir = std::env::temp_dir().join(format!("photocraft-svg-place-job-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("logo.svg"); + std::fs::write(&path, SVG).unwrap(); + let params = json!({"path": path.to_string_lossy(), "scale": 1000}); + let new_doc = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 400, "background": "white"})).unwrap(); + s + }; + let mut inline = new_doc(); + let want = inline.execute("file.placeEmbedded", params.clone()).unwrap(); + + let mut s = new_doc(); + let Started::Job(id) = s.start("file.placeEmbedded", params).unwrap() else { panic!("expected a background job") }; + let mut got = s.wait_job(id).unwrap(); + // Layer ids come from a counter shared by every session: the rest of the result must match. + let mut want = want; + assert!(got.as_object_mut().and_then(|o| o.remove("layer")).is_some() && want.as_object_mut().and_then(|o| o.remove("layer")).is_some()); + assert_eq!(got, want); + assert_eq!(got["bounds"], json!([100.0, 150.0, 300.0, 250.0]), "{got}"); + let doc = s.active().unwrap().doc.clone(); + let layer = doc.layers.last().unwrap(); + assert!(matches!(layer.content, LayerContent::Smart(_))); + let surf = layer.surface().expect("rendered"); + assert!(alpha(surf, 200, 148) < 0.01 && alpha(surf, 200, 151) > 0.99, "a pixel-exact top edge"); + assert!(red(surf, 200, 192) < 0.02, "the thin line is a crisp band: {}", red(surf, 200, 192)); + assert!(red(surf, 200, 187) > 0.98 && red(surf, 200, 198) > 0.98); + let region = Rect::new(90, 140, 310, 260); + let inline_doc = inline.active().unwrap().doc.clone(); + assert_eq!(surf.read_region(region), inline_doc.layers.last().unwrap().surface().unwrap().read_region(region), "same pixels as the inline place"); +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 688aa1f9ff..fe85d88ce1 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -952,7 +952,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o /// Extension names can be wrong (for example, an image downloaded as WebP and renamed `.svg`). /// Valid plain SVG is recognized by its XML content; an otherwise unknown `.svg` is passed to the /// SVG parser for a useful malformed-file error, and `.svgz` disambiguates gzip streams. -fn is_svg_input(name: &str, bytes: &[u8]) -> bool { +pub fn is_svg_input(name: &str, bytes: &[u8]) -> bool { // Keep the SVG extension as a fallback for malformed files so the SVG parser can return an // actionable SVG error. A recognized raster signature still wins over a misleading `.svg` // suffix (for example, a WebP downloaded as Firefox_logo.svg). diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 2f04ddbd89..eec74bfcc4 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,9 +1,9 @@ use super::{ - PhotocraftApp, analysis_ui, brush_panel, brush_picker, camera_raw_ui, canvas, dds_options_ui, dialogs, discard_ui, distort_ui, field_wheel, file_open, - gpu_canvas, gpu_status, gradient_ui, i18n, jobs_ui, jpeg_options_ui, kys_import, last_brush, layers_panel_ui, monitor_status, native_menu, notices, - paint_mouse, palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, smart_svg_ui, targa_options_ui, - text_field_menu, theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui, - view_cmds, wide_angle_ui, work_area, workspace_ui, + PhotocraftApp, analysis_ui, brush_panel, brush_picker, camera_raw_ui, canvas, dds_options_ui, dialogs, discard_ui, distort_ui, field_wheel, gpu_canvas, + gpu_status, gradient_ui, i18n, jobs_ui, jpeg_options_ui, kys_import, last_brush, layers_panel_ui, monitor_status, native_menu, notices, paint_mouse, + palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, smart_svg_ui, targa_options_ui, text_field_menu, + theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui, view_cmds, + wide_angle_ui, work_area, workspace_ui, }; impl PhotocraftApp { @@ -111,9 +111,7 @@ impl PhotocraftApp { } } // Web drops onto the canvas, read since the last frame: placed below, one at a time. - let to_place: Vec<(String, Vec)> = - self.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); - self.drop_places.extend(to_place.into_iter().map(|(name, bytes)| file_open::DropPlace::Bytes(name, bytes))); + self.queue_web_places(); // Finder double-click / Open With / Dock drops (macOS open-documents events). self.drain_os_events(ctx); // While files are dragged over the window it gets no pointer events: keep frames coming so diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 52149ab8e0..abebcac5f4 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -322,6 +322,39 @@ fn menu_place_starts_transform_for_each_chosen_image() { } } +/// #10031: File › Place decodes in the background; Free Transform starts when the layer lands, and +/// a second place is refused while the first runs. +#[test] +fn menu_place_runs_in_the_background_and_starts_transform_when_it_lands() { + let (mut app, open, _) = app(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + app.background_jobs = true; + let ctx = egui::Context::default(); + let layers = app.session.active().unwrap().doc.layer_count(); + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("drawing.png".into(), bytes.clone()))); + app.poll_file_dialog(&ctx, None); + let place = app.jobs.place.clone().expect("being placed"); + assert_eq!(place.name, "drawing.png"); + assert!(app.ui.transform.is_none(), "Free Transform waits for the layer"); + assert_eq!(app.session.active().unwrap().doc.layer_count(), layers); + // Another place is refused while this one runs, and the first one stays tracked. + assert!(crate::jobs_ui::start_place_bytes(&mut app, "other.png", bytes, None, &serde_json::json!({})).is_err()); + assert_eq!(app.jobs.place.as_ref().map(|p| p.job), Some(place.job)); + let t = std::time::Instant::now(); + while app.jobs.place.is_some() { + assert!(t.elapsed() < std::time::Duration::from_secs(30), "the place did not finish"); + crate::jobs_ui::tick(&mut app, &ctx); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer_count(), layers + 1); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "drawing"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); +} + #[test] fn cancelled_or_invalid_menu_place_does_not_start_transform() { for command in ["file.placeEmbedded", "file.placeLinked"] { diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index ed5c3a642e..1534502a4f 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -6,6 +6,7 @@ //! are shown as errors (status bar + notice); import warnings as a notice. use crate::{PhotocraftApp, notices}; +use photocraft_doc::DocId; use photocraft_engine::file_cmds::{is_template, untitled_name}; /// A request from the operating system, delivered by the platform shell through @@ -33,7 +34,8 @@ pub enum DropTarget { Elsewhere, } -/// A file dropped on the canvas, waiting its turn to be placed. +/// A file dropped on the canvas, waiting its turn to be placed in the document it was dropped on +/// (queued with its [`DocId`] in `drop_places`). pub(crate) enum DropPlace { /// Destination captured when the file is dropped, before a later pointer move. At { source: Box, frame: Option }, @@ -44,6 +46,41 @@ pub(crate) enum DropPlace { Bytes(String, Vec), } +impl DropPlace { + /// The file contents held while it waits for its document (a path drop holds none yet). + fn queued_bytes(&self) -> usize { + match self { + Self::At { source, .. } => source.queued_bytes(), + Self::File(_) => 0, + Self::Bytes(_, bytes) => bytes.len(), + } + } + + /// Whether it holds its file's contents while it waits (a browser drop); a path drop is read + /// only when its turn comes. + fn holds_bytes(&self) -> bool { + match self { + Self::At { source, .. } => source.holds_bytes(), + Self::File(_) => false, + Self::Bytes(..) => true, + } + } +} + +/// The most browser drops, each holding its whole file, that can wait for their document to come +/// back in view. Path drops hold only their path and aren't counted: dropping a folder's worth of +/// files on the canvas places them all. +const MAX_QUEUED_DROPS: usize = 32; +/// The most dropped file contents kept in memory while they wait: a browser drop carries its whole +/// file, and anything queued on a document in another tab stays until it is back in view. +const MAX_QUEUED_DROP_BYTES: usize = 256 * 1024 * 1024; + +/// Whether the drop queue takes a browser drop of `incoming` bytes when it holds `queued` of them +/// and `bytes` in all. +fn queue_takes_drop(queued: usize, bytes: usize, incoming: usize) -> bool { + queued < MAX_QUEUED_DROPS && bytes.saturating_add(incoming) <= MAX_QUEUED_DROP_BYTES +} + /// `warnings` from an import, plus one for each font family the active document's type uses that /// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and /// automation already report them; the type would otherwise silently render in a fallback face. @@ -247,6 +284,11 @@ impl PhotocraftApp { }); } + /// Show "Couldn't place : " as an error (status bar + notice). + pub fn place_failed(&mut self, name: &str, err: &str) { + self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", name), ("error", err)])); + } + /// Log `msg` and show it as an error (status bar + notice). fn failed(&mut self, msg: String) { log::warn!("{msg}"); @@ -272,7 +314,9 @@ impl PhotocraftApp { for f in files { let name = dropped_name(&f); if target == DropTarget::Canvas && !crate::preset_files_ui::is_preset_file(&name) { - self.drop_places.push_back(DropPlace::At { source: Box::new(DropPlace::File(f)), frame }); + if let Some(doc) = self.session.active().map(|st| st.doc.id) { + self.queue_drop(doc, DropPlace::At { source: Box::new(DropPlace::File(f)), frame }); + } continue; } match self.open_dropped_file(&f, &name, slot) { @@ -295,8 +339,12 @@ impl PhotocraftApp { let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); let canvas = self.drop_target(ctx, at) == DropTarget::Canvas; let places: Vec = names.iter().map(|n| canvas && !crate::preset_files_ui::is_preset_file(n)).collect(); - if places.contains(&true) && self.ui.transform.is_some() { - crate::transform_tool::commit(self); + if places.contains(&true) { + // The shell reads the files after this returns: they go to this document even if the + // tab changes before they arrive. + if self.ui.transform.is_some() { + crate::transform_tool::commit(self); + } } places } @@ -337,9 +385,27 @@ impl PhotocraftApp { /// Place the next file dropped on the canvas and start Free Transform on it, once no other /// transform is in progress (so several files are placed and transformed one after another). + /// With background jobs on, a file is placed by a job: the next one waits until that job has + /// landed and its Free Transform has been applied (see `jobs_ui::finish_place`). + /// + /// Each file goes into the document it was dropped on, never into whichever one is active now: + /// files dropped on a document in another tab wait until it is back in view, and are discarded + /// (with a message) when it is closed. pub(crate) fn place_next_dropped(&mut self, ctx: &egui::Context) { - while self.ui.transform.is_none() { - let Some(next) = self.drop_places.pop_front() else { return }; + // Leave the closed-document notice up for this frame: starting the next queued placement + // would clear the status bar. The next frame retries the queue, so ask for one at once: + // another document's files would otherwise wait for the next input event. + if self.discard_drops_of_closed_documents() { + if !self.drop_places.is_empty() { + ctx.request_repaint(); + } + return; + } + while self.ui.transform.is_none() && self.jobs.place.is_none() && self.session.active_job().is_none() { + let Some(active) = self.session.active().map(|st| st.doc.id) else { return }; + let Some((_, next)) = self.drop_places.iter().position(|(doc, _)| *doc == active).and_then(|i| self.drop_places.remove(i)) else { + return; + }; let (next, frame) = match next { DropPlace::At { source, frame } => (*source, Some(frame)), next => (next, None), @@ -372,6 +438,8 @@ impl PhotocraftApp { } }; match placed { + // Running in the background: Free Transform starts when the layer has landed. + Ok(started) if crate::jobs_ui::track_place(self, &name, &started) => return, Ok(result) => { if result.get("frame").and_then(serde_json::Value::as_u64).is_some() { continue; @@ -380,18 +448,66 @@ impl PhotocraftApp { self.ui.status = e; } } - Err(e) => self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", &name), ("error", &e)])), + Err(e) => self.place_failed(&name, &e), } } } + /// Queue the web drops onto the canvas that the shell has read since the last frame + /// ([`Services::place_inbox`](crate::Services::place_inbox)), for the document they were + /// dropped on. + pub(crate) fn queue_web_places(&mut self) { + let arrived: Vec<(DocId, String, Vec)> = + self.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + for (doc, name, bytes) in arrived { + self.queue_drop(doc, DropPlace::Bytes(name, bytes)); + } + } + + /// Queue a canvas drop for the document it was dropped on, keeping the file contents it holds + /// bounded: files dropped on a document in another tab wait here, and a browser drop carries + /// its whole file. A path drop holds no contents, so any number of them queue. + fn queue_drop(&mut self, doc: DocId, place: DropPlace) { + let held = self.drop_places.iter().filter(|(_, p)| p.holds_bytes()).count(); + let bytes = self.drop_places.iter().fold(0usize, |sum, (_, p)| sum.saturating_add(p.queued_bytes())); + if place.holds_bytes() && !queue_takes_drop(held, bytes, place.queued_bytes()) { + self.ui.status = tl!("Dropped files weren't placed: too many are waiting for their document.").into(); + self.ui.status_error = true; + return; + } + self.drop_places.push_back((doc, place)); + } + + /// Drop the queued files whose document has been closed, saying so. True when one was dropped + /// (the caller lets the notice stand). + fn discard_drops_of_closed_documents(&mut self) -> bool { + if self.drop_places.is_empty() { + return false; + } + let before = self.drop_places.len(); + let open: Vec = self.session.documents().iter().map(|st| st.doc.id).collect(); + self.drop_places.retain(|(doc, _)| open.contains(doc)); + let discarded = self.drop_places.len() < before; + if discarded { + self.ui.status = tl!("Dropped files weren't placed: their document was closed.").into(); + self.ui.status_error = false; + } + discarded + } + /// Place a file's bytes as a smart object layer (File › Place; `linked`: Place Linked, to that - /// path), for files that weren't read from a path the command can read. + /// path), for files that weren't read from a path the command can read. With background jobs + /// on it starts a job (`{"job", "pending": true}`; see [`crate::jobs_ui::track_place`]). pub(crate) fn place_bytes(&mut self, name: &str, bytes: Vec, linked: Option) -> Result { self.place_bytes_with(name, bytes, linked, &serde_json::json!({})) } + fn place_bytes_with(&mut self, name: &str, bytes: Vec, linked: Option, params: &serde_json::Value) -> Result { - let r = photocraft_engine::file_cmds::place_bytes(&mut self.session, name, bytes, linked, params).map_err(|e| e.to_string()); + let r = if self.background_jobs { + crate::jobs_ui::start_place_bytes(self, name, bytes, linked, params) + } else { + photocraft_engine::file_cmds::place_bytes(&mut self.session, name, bytes, linked, params).map_err(|e| e.to_string()) + }; self.sync_views(); r } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index bd74eceb9e..037cdda108 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -189,8 +189,9 @@ fn web_drops_onto_the_canvas_are_placed_from_their_bytes() { at.set(egui::pos2(20.0, 20.0)); assert_eq!(app.dropped_on_canvas(&ctx, &names), [false, false], "off the canvas: opened"); let layers = layer_count(&app); - app.drop_places.push_back(crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3))); - app.drop_places.push_back(crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec())); + let doc = app.session.active().unwrap().doc.id; + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3)))); + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec()))); app.place_next_dropped(&ctx); assert_eq!(layer_count(&app), layers + 1); assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); @@ -538,6 +539,366 @@ fn a_separate_drop_applies_the_open_transform_and_places_at_once() { assert_eq!(history_steps(&app), steps + 2); } +/// Apply the jobs that have finished, frame by frame, until `done` holds (or 30 s pass). +fn tick_until(app: &mut PhotocraftApp, ctx: &egui::Context, done: impl Fn(&PhotocraftApp) -> bool) { + let t = std::time::Instant::now(); + while !done(app) { + assert!(t.elapsed() < std::time::Duration::from_secs(30), "not done after 30 s"); + crate::jobs_ui::tick(app, ctx); + app.place_next_dropped(ctx); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// #10031: with background jobs a dropped file is decoded on a worker. Free Transform starts when +/// the layer has landed, and the next file is started only after that transform is applied. +#[test] +fn background_drops_are_placed_by_a_job_one_after_another() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let (layers, steps) = (layer_count(&app), history_steps(&app)); + let at = Some(egui::pos2(400.0, 300.0)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], at); + app.place_next_dropped(&ctx); + let place = app.jobs.place.clone().expect("the first file is being placed"); + assert_eq!(place.name, "first.png"); + assert!(app.session.job(place.job).is_some(), "as a background job"); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps), "nothing lands before the job is applied"); + assert!(app.ui.transform.is_none(), "Free Transform waits for the layer"); + // Frames while it runs don't start the second file next to it. + app.place_next_dropped(&ctx); + assert_eq!(app.drop_places.len(), 1); + assert_eq!(app.jobs.place.as_ref().map(|p| p.job), Some(place.job)); + + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "first"); + // The second file waits for the first transform. + app.place_next_dropped(&ctx); + assert!(app.jobs.place.is_none() && app.drop_places.len() == 1); + crate::transform_tool::commit(&mut app); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("second.png")); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + crate::transform_tool::commit(&mut app); + assert_eq!((layer_count(&app), history_steps(&app)), (layers + 2, steps + 2)); + assert!(app.drop_places.is_empty()); +} + +/// A file that can't be decoded is reported by name and the files dropped after it go on. +#[test] +fn a_background_place_that_fails_reports_it_and_the_queue_continues() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let layers = layer_count(&app); + app.open_dropped(&ctx, vec![dropped("bad.png", Ok(b"bad".to_vec())), dropped("good.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("bad.png")); + tick_until(&mut app, &ctx, |app| app.ui.notices.iter().any(|n| n.error && n.title.contains("bad.png"))); + assert_eq!(layer_count(&app), layers, "nothing was placed"); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("good.png"), "the next file started at once"); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert_eq!(app.ui.notices.iter().filter(|n| n.error).count(), 1); +} + +/// Cancelling the place says so, places nothing and goes on with the queue. +#[test] +fn cancelling_a_background_place_continues_the_queue() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let (layers, steps) = (layer_count(&app), history_steps(&app)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + let first = app.jobs.place.clone().expect("being placed"); + assert!(crate::jobs_ui::cancel(&mut app, first.job)); + crate::jobs_ui::tick(&mut app, &ctx); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps)); + let second = app.jobs.place.clone().expect("the queue went on"); + assert_eq!(second.name, "second.png"); + assert_ne!(second.job, first.job); + assert!(!app.ui.status_error); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); + assert_eq!(layer_count(&app), layers + 1); +} + +/// Frames with nothing to wait for: jobs applied and the drop queue served. +fn idle_frames(app: &mut PhotocraftApp, ctx: &egui::Context) { + for _ in 0..3 { + crate::jobs_ui::tick(app, ctx); + app.place_next_dropped(ctx); + } +} + +/// Drops two files on the canvas of document A (the app's only one) with background jobs on, starts +/// placing the first, then opens document B, which becomes active. Returns A's id and layer count. +fn two_drops_then_switch(app: &mut PhotocraftApp, ctx: &egui::Context) -> (photocraft_doc::DocId, usize) { + app.background_jobs = true; + let (a, layers) = (app.session.active().unwrap().doc.id, layer_count(app)); + app.open_dropped(ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("first.png", a))); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + assert_ne!(app.session.active().unwrap().doc.id, a); + (a, layers) +} + +/// FredApps' review of #10147: files dropped on A stay A's when the tab changes while the first is +/// placed. It lands in A, no Free Transform opens over B, the second waits rather than going into B, +/// and goes on in A when A is back in view. +#[test] +fn background_drops_stay_with_their_document_when_the_tab_changes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let (a, layers) = two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.session.documents()[0].doc.layers.len(), layers + 1, "the first landed in A"); + assert_eq!(app.drop_places.len(), 1, "the second waits for A"); + + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("second.png", a))); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 2); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(app.drop_places.is_empty()); + assert_eq!(app.session.documents()[1].doc.layers.len(), b_layers); +} + +/// Cancelling A's place while B is in view doesn't hand A's next file to B. +#[test] +fn cancelling_a_background_place_from_another_tab_keeps_the_queue_in_its_document() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let (a, layers) = two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + let first = app.jobs.place.clone().unwrap(); + assert!(crate::jobs_ui::cancel(&mut app, first.job)); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.session.documents()[0].doc.layers.len(), layers, "the cancelled file wasn't placed"); + assert_eq!(app.drop_places.len(), 1); + assert!(app.session.set_active(0)); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("second.png", a))); +} + +/// Closing A drops its waiting files with a message; none goes into B. +#[test] +fn closing_the_document_discards_its_dropped_files() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + two_drops_then_switch(&mut app, &ctx); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + app.run("file.close", json!({"document": 0})).unwrap(); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.place_next_dropped(&ctx); + assert!(app.drop_places.is_empty()); + assert!(app.ui.status.contains("closed") && !app.ui.status_error, "{}", app.ui.status); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps)); +} + +/// The closed-document notice survives the same call that would start the next queued file: the +/// placement waits one frame, so the message is drawn. That frame is asked for at once, so B's file +/// doesn't wait for the next input event. +#[test] +fn the_closed_document_notice_stays_when_the_next_queued_file_starts() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let a = app.session.active().unwrap().doc.id; + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let b = app.session.active().unwrap().doc.id; + let b_layers = layer_count(&app); + app.drop_places.push_back((a, crate::file_open::DropPlace::Bytes("a.png".into(), png_bytes(4, 3)))); + app.drop_places.push_back((b, crate::file_open::DropPlace::Bytes("b.png".into(), png_bytes(4, 3)))); + app.run("file.close", json!({"document": 0})).unwrap(); + for _ in 0..4 { + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + } + assert!(!ctx.has_requested_repaint(), "idle"); + app.place_next_dropped(&ctx); + assert!(app.ui.status.contains("closed"), "{}", app.ui.status); + assert_eq!(app.drop_places.len(), 1, "B's file waits for the next frame"); + assert!(ctx.has_requested_repaint(), "and asks for it"); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), b_layers + 1); + assert!(app.drop_places.is_empty()); +} + +/// The drop queue is bounded: files dropped on another tab wait, and a browser drop holds its whole +/// file, so neither a flood of drops nor a huge one grows it without limit. +#[test] +fn the_drop_queue_is_bounded() { + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + for _ in 0..crate::file_open::MAX_QUEUED_DROPS { + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("p.png".into(), vec![0])); + } + assert_eq!(app.drop_places.len(), crate::file_open::MAX_QUEUED_DROPS); + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("over.png".into(), vec![0])); + assert_eq!(app.drop_places.len(), crate::file_open::MAX_QUEUED_DROPS, "the queue keeps its cap"); + assert!(app.ui.status.contains("too many") && app.ui.status_error, "{}", app.ui.status); + assert!(crate::file_open::queue_takes_drop(0, crate::file_open::MAX_QUEUED_DROP_BYTES, 0)); + assert!(!crate::file_open::queue_takes_drop(0, crate::file_open::MAX_QUEUED_DROP_BYTES, 1)); + assert!(!crate::file_open::queue_takes_drop(crate::file_open::MAX_QUEUED_DROPS, 0, 0)); +} + +/// Path drops hold no file contents while they wait, so the cap on browser drops doesn't apply to +/// them: dropping 40 files on the canvas queues all of them, and a browser drop after them still fits. +#[test] +fn many_path_drops_all_queue() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + // More than the browser drops' cap; none is read while it waits. + let n = crate::file_open::MAX_QUEUED_DROPS + 8; + app.open_dropped(&ctx, (0..n).map(|i| dropped(format!("p{i}.png"), Ok(Vec::new()))).collect(), Some(egui::pos2(400.0, 300.0))); + assert_eq!(app.drop_places.len(), n); + assert!(!app.ui.status_error, "{}", app.ui.status); + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("web.png".into(), vec![0])); + assert_eq!(app.drop_places.len(), n + 1, "path drops don't use up the browser drops' room"); +} + +/// Closing A while its file is being placed: nothing lands anywhere, and A's next file is dropped. +#[test] +fn closing_the_document_while_its_file_is_placed_places_nothing_elsewhere() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.run("file.close", json!({"document": 0})).unwrap(); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.drop_places.is_empty() && app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps)); +} + +/// Without background jobs too: leaving A's Free Transform for B cancels that place (#1099) and A's +/// next file waits for A instead of going into B. +#[test] +fn inline_drops_stay_with_their_document_when_the_tab_changes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let layers = layer_count(&app); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + assert!(app.ui.transform.is_some()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + crate::transform_tool::end_if_left(&mut app); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.place_next_dropped(&ctx); + assert!(app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.drop_places.len(), 1); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert_eq!(layer_count(&app), layers + 1, "the first was cancelled by leaving it, the second placed"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); +} + +/// A web drop goes to the document it was dropped on, even when the tab changes before the shell +/// has read the files. +#[test] +fn web_drops_go_to_the_document_they_were_dropped_on() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let layers = layer_count(&app); + let inbox: crate::PlaceInbox = Default::default(); + app.services.place_inbox = Some(inbox.clone()); + app.services.cursor_pos = Some(Box::new(|_: &egui::Context| Some(egui::pos2(400.0, 300.0)))); + assert_eq!(app.dropped_on_canvas(&ctx, &["photo.png".to_string()]), [true]); + let target = app.session.active().map(|st| st.doc.id).unwrap(); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + inbox.lock().unwrap().push((target, "photo.png".into(), png_bytes(4, 3))); + app.queue_web_places(); + app.place_next_dropped(&ctx); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); +} + +#[test] +fn overlapping_web_drop_deliveries_keep_each_target() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let a = app.session.active().unwrap().doc.id; + let a_layers = layer_count(&app); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let b = app.session.active().unwrap().doc.id; + let b_layers = layer_count(&app); + let inbox: crate::PlaceInbox = Default::default(); + app.services.place_inbox = Some(inbox.clone()); + // B's read finishes before A's, although A was dropped first. + inbox.lock().unwrap().extend([(b, "b.png".into(), png_bytes(3, 2)), (a, "a.png".into(), png_bytes(4, 3))]); + app.queue_web_places(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), b_layers + 1); + crate::transform_tool::commit(&mut app); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), a_layers + 1); + assert!(app.drop_places.is_empty()); +} + +/// A file dropped from a path is placed by the `file.placeEmbedded` job, read on its worker, and a +/// drop that arrives meanwhile queues behind it. +#[test] +fn a_background_drop_from_a_path_is_tracked_and_later_drops_queue() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-place-job-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let abs = dir.join("photo.png"); + std::fs::write(&abs, png_bytes(40, 20)).unwrap(); + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let layers = layer_count(&app); + let at = Some(egui::pos2(400.0, 300.0)); + app.open_dropped(&ctx, vec![dropped(abs, Err("egui's reader must not be used".into()))], at); + app.place_next_dropped(&ctx); + let place = app.jobs.place.clone().expect("being placed"); + assert_eq!(place.name, "photo.png"); + assert_eq!(app.jobs.last_started, Some(place.job)); + // A drop while it runs is queued, not started beside it. + app.open_dropped(&ctx, vec![dropped("later.png", Ok(png_bytes(2, 2)))], at); + app.place_next_dropped(&ctx); + assert_eq!(app.drop_places.len(), 1); + assert_eq!(app.session.jobs().len(), 1); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.drop_places.len(), 1, "waiting for the transform"); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1099: switching tabs after an actual canvas drop cancels the place in its source document. #[test] fn switching_documents_after_a_canvas_drop_undoes_only_the_place() { @@ -694,6 +1055,12 @@ fn drag_and_drop_in_the_running_app() { pointer.set(h.state().drop_canvas_rect.expect("the canvas is shown").center()); h.input_mut().dropped_files.push(dropped(file, Err("read from the path".into()))); h.run_steps(2); + // Placement runs on a worker; frame count does not imply completion. + let started = std::time::Instant::now(); + while h.state().jobs.place.is_some() && started.elapsed() < std::time::Duration::from_secs(30) { + h.step(); + } + assert!(h.state().jobs.place.is_none(), "placement completed"); assert_eq!(h.state().session.documents().len(), 3, "no new document"); assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1); assert_eq!(h.state().ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); @@ -1127,3 +1494,21 @@ fn opening_an_open_file_again_shows_its_tab() { let _ = std::fs::remove_dir_all(&d); } } + +#[test] +fn queued_drop_waits_for_an_external_document_job() { + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + app.session.start_job("test.external", json!({}), "External job", true, |_| Ok(()), |_, ()| Ok(json!(null))).unwrap(); + let job = app.session.active_job().unwrap().id; + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("queued.png".into(), png_bytes(4, 3)))); + app.place_next_dropped(&egui::Context::default()); + assert_eq!(app.drop_places.len(), 1, "busy document retains its drop"); + assert!(app.jobs.place.is_none()); + let layers = layer_count(&app); + app.session.wait_job(job).unwrap(); + app.place_next_dropped(&egui::Context::default()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(app.drop_places.is_empty()); +} diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 9855b4f36b..66ac7a8c45 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -70,6 +70,17 @@ pub(crate) struct OpenBatch { opened: Vec, } +/// A file being placed in the background (File › Place, or a file dropped on the canvas): Free +/// Transform starts on its layer when the job lands, and the files dropped after it follow. +#[derive(Clone, Debug)] +pub struct PlaceJob { + pub job: JobId, + /// Display name (the file name). + pub name: String, + /// The document it lands in: Free Transform starts only while that one is in view. + pub doc: DocId, +} + /// The job command of a background save. pub const SAVE_JOB: &str = "file.save"; @@ -165,6 +176,9 @@ pub struct JobsUi { pub focus: Option, /// The job the latest [`run`] started (the control channel waits on it). pub(crate) last_started: Option, + /// The file being placed in the background, if any. One at a time: files dropped meanwhile + /// wait in the app's drop queue. + pub place: Option, /// Cached read-only canvas image of the current folder Batch input. batch_preview_texture: Option<(JobId, u64, egui::TextureHandle)>, } @@ -188,6 +202,62 @@ pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result, linked: Option, params: &Value) -> Result { + if app.jobs.place.is_some() { + return Err(tl!("A file is still being placed; try again when it has finished.").into()); + } + match app.session.start_place_bytes(name, bytes, linked, params.clone()).map_err(|e| e.to_string())? { + Started::Done(v) => Ok(v), + Started::Job(job) => { + app.jobs.last_started = Some(job); + // The status bar's progress readout names the job; clear any old message. + app.ui.status.clear(); + app.ui.status_error = false; + Ok(json!({"job": job.0, "pending": true})) + } + } +} + +/// Follow the place that `started` (the result of [`run`] or [`start_place_bytes`] for the file +/// `name`) began in the background: when it lands, Free Transform starts on its layer and the +/// next dropped file is placed (see `on_event`). False when it already ran (nothing to follow). +pub(crate) fn track_place(app: &mut PhotocraftApp, name: &str, started: &Value) -> bool { + let job = started.get("pending").and_then(Value::as_bool).filter(|pending| *pending).and_then(|_| started.get("job")?.as_u64()); + let (Some(job), Some(doc)) = (job, app.session.active().map(|st| st.doc.id)) else { return false }; + app.jobs.place = Some(PlaceJob { job: JobId(job), name: crate::file_open::display_name(name), doc }); + true +} + +/// A background place ended: start Free Transform on the layer it made, or report why not, then +/// go on with the files dropped meanwhile. +fn finish_place(app: &mut PhotocraftApp, ctx: &egui::Context, place: PlaceJob, outcome: JobOutcome) { + app.sync_views(); + match outcome { + JobOutcome::Done(result) => { + // The tab can change while the file is placed: the box opens only on the document it + // was placed in, and never over another one. An image placed into a frame has none. + if app.ui.transform.is_none() + && result.get("frame").and_then(Value::as_u64).is_none() + && app.session.active().is_some_and(|st| st.doc.id == place.doc) + && let Err(e) = crate::transform_tool::begin_placed(app, ctx) + { + app.ui.status = e; + } + } + JobOutcome::Failed(err) => app.place_failed(&place.name, &err), + JobOutcome::Cancelled if app.session.documents().iter().any(|st| st.doc.id == place.doc) => { + app.ui.status = crate::i18n::fmt(tl!("Cancelled placing {name}"), &[("name", &place.name)]); + app.ui.status_error = false; + } + JobOutcome::Cancelled => {} + } + app.place_next_dropped(ctx); +} + /// Open a file in the background: a tab appears at once and shows the progress; the document /// replaces it when decoded. `path` is remembered for File › Save and Open Recent. `slot` (a file /// dropped on the tabs) and `order` (its place in a set opened together) survive an inline @@ -509,6 +579,12 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { if crate::camera_raw_ui::on_redevelop_event(app, &e) { return; } + if app.jobs.place.as_ref().is_some_and(|p| p.job == e.id) + && let Some(place) = app.jobs.place.take() + { + finish_place(app, ctx, place, e.outcome); + return; + } if e.command == SAVE_JOB && let Some(i) = app.jobs.saves.iter().position(|s| s.job == e.id) { @@ -845,7 +921,13 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(j) => j, None => { let Some(j) = app.session.active_job() else { return }; - if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { + // A save shows its progress in the tab and the status bar; so does a place, which + // leaves the view free to zoom and pan while the file is decoded. + let place = place_uses_status_bar( + &j.command, + !app.ui.view.hides_chrome() && app.ui.panels.status_bar && job_in_view(app).is_some_and(|shown| shown.id == j.id), + ); + if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || place || app.jobs.saves.iter().any(|s| s.job == j.id) { return; } j @@ -1008,6 +1090,7 @@ pub fn inspect(app: &PhotocraftApp) -> Value { "running": app.session.jobs(), "opening": app.jobs.opens.iter().map(|o| json!({"job": o.job.0, "name": o.name, "path": o.path})).collect::>(), "saving": app.jobs.saves.iter().map(|s| json!({"job": s.job.0, "path": s.path})).collect::>(), + "placing": app.jobs.place.as_ref().map(|p| json!({"job": p.job.0, "name": p.name})), "focus": app.jobs.focus.map(|j| j.0), "background": app.background_jobs, }) @@ -1020,3 +1103,20 @@ pub fn bytes(b: &[u8]) -> OpenSource { #[cfg(test)] mod tests; + +/// Placements keep the canvas accessible when progress and Cancel are visible in the status bar. +fn place_uses_status_bar(command: &str, status_bar_visible: bool) -> bool { + status_bar_visible && matches!(command, "file.placeEmbedded" | "file.placeLinked") +} + +#[cfg(test)] +mod place_progress_tests { + #[test] + fn placement_modal_depends_on_command_and_visible_progress() { + for command in ["file.placeEmbedded", "file.placeLinked"] { + assert!(super::place_uses_status_bar(command, true)); + assert!(!super::place_uses_status_bar(command, false)); + } + assert!(!super::place_uses_status_bar("filter.blur", true)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 562144fb5b..0612021bea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -260,8 +260,8 @@ pub use services::{ AdoptAutosaveFn, AppendTextFn, AutomationCommandFn, AutomationOpenPcraftFn, AutomationReadFn, AutomationWriteFn, AutosaveFn, AutosaveResultsFn, CapsLockFn, CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, - LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, - SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, + LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, PlaceInbox, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, + SaveFileFn, SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, }; pub use text::bidi_text; pub use text::cjk_fonts; @@ -486,10 +486,11 @@ pub struct PhotocraftApp { pub(crate) drop_canvas_rect: Option, /// The document tab strip last frame: a drop there opens the file at the slot under it. pub(crate) tab_strip: Option, - /// Files dropped on the canvas still to place, one Free Transform at a time. pub(crate) frame_tool_ctx: Option, pub(crate) frame_preview: Option, - pub(crate) drop_places: std::collections::VecDeque, + /// Files dropped on the canvas still to place, one Free Transform at a time, each with the + /// document it was dropped on. + pub(crate) drop_places: std::collections::VecDeque<(DocId, file_open::DropPlace)>, /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, /// The document `ui.selected_path` belongs to, as of the last [`Self::sync_views`]: a Paths diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 02bf041185..57ade54a13 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -323,7 +323,8 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para app.refocus(doc)?; let linked = linked.then(|| name.clone()); let result = app.place_bytes(&name, bytes, linked)?; - if result.get("frame").and_then(Value::as_u64).is_none() { + // In the background, Free Transform starts when the layer has landed. + if result.get("frame").and_then(Value::as_u64).is_none() && !crate::jobs_ui::track_place(app, &name, &result) { crate::transform_tool::begin_placed(app, &ctx)?; } Ok(result) diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 569154fe22..8224b28698 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -331,6 +331,8 @@ pub struct ExternalVectorSource { pub type OpenExternalSvgFn = Box Result, String>>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; +/// Shared queue of browser canvas drops with the document captured at the drop event. +pub type PlaceInbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). pub type LoadTextFn = Box Option>; @@ -461,7 +463,7 @@ pub struct Services { /// Files delivered asynchronously for a drop onto the active document's canvas (web /// drag-and-drop, routed by [`PhotocraftApp::dropped_on_canvas`]): placed as layers like a /// desktop canvas drop (Place Embedded, each in Free Transform). - pub place_inbox: Option, + pub place_inbox: Option, /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d54c45e972..e118ddf51e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -251,7 +251,7 @@ The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). ### Background jobs (#210) -Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged; a folder Batch can already have written earlier output files. A cancelled Batch has `state: "cancelled"` with its partial `result` in `jobs.list`; a waiting control request receives `{"ok":false,"error":"cancelled","result":…}`, and an MCP call through the app bridge gets the error `cancelled; partial result: {…}`. A Batch already asked to stop, or past its last input, is listed with `reason: "finishing"`. A save that has started replacing its file can no longer be cancelled: `jobs.cancel` leaves it running and lists it in `notCancelled: [{job, reason: "committing"}]` (next to `cancelled`, the number of jobs it did cancel); the save then ends as done or failed like any other. Closing its document doesn't stop it either. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. +Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens, file places (`file.placeEmbedded` / `file.placeLinked`, File › Place and files dropped on the canvas: the file is read, decoded and resized on the worker, the layer is inserted when the job lands and Free Transform starts then) and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels; a place shows its progress in the status bar only, so the view stays free to pan and zoom). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged; a folder Batch can already have written earlier output files. A cancelled Batch has `state: "cancelled"` with its partial `result` in `jobs.list`; a waiting control request receives `{"ok":false,"error":"cancelled","result":…}`, and an MCP call through the app bridge gets the error `cancelled; partial result: {…}`. A Batch already asked to stop, or past its last input, is listed with `reason: "finishing"`. A save that has started replacing its file can no longer be cancelled: `jobs.cancel` leaves it running and lists it in `notCancelled: [{job, reason: "committing"}]` (next to `cancelled`, the number of jobs it did cancel); the save then ends as done or failed like any other. Closing its document doesn't stop it either. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files, the file being placed). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. ## Preferences From e4e2607c85eb8c293633fc566b96f040e4152da7 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 04:04:12 +0530 Subject: [PATCH 58/63] Preview spot inks over the RGB/CMYK composite as an overprint (#10276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PSD: a flat file's named extra channels are not its transparency A PSD without layer records had its first extra channel read as the merged transparency, so the first spot or alpha channel of a flattened document vanished and its ink punched a transparent hole in the image (psd-tools cmyk-alpha-spot.psd, cmyk-spot.psd). When the alpha channel names (1045, or 1006 when 1045 is missing) name every extra channel and the first is not "Transparency", there is none. The import also reads channel names from 1006 when 1045 is missing, and a spot whose DisplayInfo colour space is unknown takes its ink from the Alternate Spot Colors (1067). * channel_map: read 1067 records with as_chunks * Preview spot inks over the RGB/CMYK composite as an overprint Visible spot channels of RGB and CMYK documents print over the canvas composite with the Multichannel ink formula, in Channels panel order, instead of a tint; alpha channels and Quick Mask still tint over them. Spot channels open visible, and the PSD thumbnail (1036) prints them. flatten, export and the merged image stay free of spot inks. Ink colours go through the document's CMYK profile, and into an RGB document's own profile. Refs #10249 * channel_view: test solidity over a gray picture * Thumbnail: matte only inked pixels; test the merged planes directly Review feedback on #10276: pixels without spot ink keep their transparency in the thumbnail buffer, and the thumbnail test checks the saved merged process planes instead of a re-imported flatten. * Thumbnail spot inks: sample big boxes on a 4x4 grid Review feedback on #10276: reducing each spot channel no longer scans the whole document; a thumbnail pixel averages at most 4x4 samples, so a huge document costs about h x 4 row reads per spot. * Spot overlay: composite a proxy for the first draw of big documents The overprint needs the composite under the inks. Rendering the full-resolution composite to sample every factor-th pixel cost ~1 s for a 24 MP CMYK document. A proxy document picks the same pixels (nearest-neighbour downsample) for about 1/factor² of the work. Documents with layer effects, vector masks or feathered masks keep the full-resolution path. * Proxy: downsample rows in parallel; the overlay proxy leaves out unused channels The spot overlay's proxy shrank the spot channels, selection and Quick Mask that an RGB or CMYK composite never reads: about two thirds of its cost. downsample now reads its rows in parallel, 64 at a time. * Spot overlay proxy: keep the full-resolution composite under uncached pattern fills A pattern fill renders its tiles at their own size on a proxy, so the proxy would sample other pattern pixels. Unless the fill's matching cache is shrunk with the proxy, use the full-resolution composite. * Spot overlay proxy: hidden layers don't decide the composite path Hidden layers and the contents of hidden groups are not composited, so a hidden feathered mask or pattern fill no longer forces the full-resolution composite (the same rule as proxy_faithful). * Update crates/compose/src/multichannel.rs Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> * Spot overlay: only inked pixels replace the canvas; inks through the display transform Where every visible spot is 0 the overlay stays transparent, so the normal canvas (display LUT, proofing, 32-bit preview, full resolution) shows; an empty spot renders no composite. Printed pixels go through the canvas display transform (sRGB encoding of linear composites, monitor profile, Proof Colors), keyed into the texture. Ink colours come from one InkSpace per render: the document's profiles are parsed once, not per spot per damage rect. * test(compose): cover hidden spot preview and document approximation * fix(ui-egui): preserve solo spot channel display when process colours are hidden Clicking a spot channel row in the Channels panel targets the channel alone and hides all process colour channels (`vis == 0`). Overprint inks were previously extracted from `overlays` before checking `vis == 0`, leaving `overlays` empty and causing the solo channel view to fall back to an active layer mask (or return `None` if the layer had no mask). Keep the spot channel in `overlays` so `vis == 0` can select it as the solo grayscale picture, separating overprint inks only when process colours are visible (`vis > 0`). Adds a regression test targeting an existing spot channel in RGB and CMYK on an unmasked layer and editing it. * channel_view: document the spot overprint preview as an approximation * test(ui-egui): solo spot channel wins over an unrelated layer mask * channel_view: sRGB spot inks, lazy display transform, inked-area composite Review fixes for the spot overprint preview: - RGB spot colours are sRGB, like the CMYK, Lab and gray ones: InkSpace now converts them into an RGB document's own profile too. In a linear (EXR/HDR), Adobe RGB or ProPhoto document an RGB ink showed the wrong colour (brightened in a linear one once the canvas sRGB-encoded it). - The display transform is looked up only when the view is redrawn and inks are shown, not every frame before the cache check; display_transform_for borrows the proof state (proof_ref) instead of cloning a default CMYK profile. - Visible spot channels without ink, with nothing else shown, draw no texture: render returns None and ensure remembers it, instead of uploading an all-transparent document-sized texture. A damage re-render that finds nothing clears its rect, so erased ink never lingers. - Printing a few inked pixels composites only their bounding box when that is cheaper than the whole (proxy) composite, and a cached composite is borrowed instead of copied. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/compose/Cargo.toml | 2 +- crates/compose/src/multichannel.rs | 381 ++++++++++++++- crates/compose/src/proxy.rs | 33 +- crates/engine/src/cmd/channel_cmds/tests.rs | 19 + crates/engine/src/cmd/color_cmds.rs | 12 +- crates/engine/src/doc_state.rs | 7 +- crates/io/src/psd_thumbnail.rs | 7 +- crates/io/tests/it/psd_thumbnail.rs | 26 ++ crates/ui-egui/src/canvas.rs | 16 +- crates/ui-egui/src/canvas/channel_view.rs | 306 ++++++++++-- .../ui-egui/src/canvas/channel_view/tests.rs | 439 +++++++++++++++++- crates/ui-egui/src/canvas/tests.rs | 18 +- docs/gaps.md | 11 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 5 +- scorecard/file_compat.toml | 2 +- 16 files changed, 1172 insertions(+), 114 deletions(-) diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 50e8e220ac..17d8f9faf7 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -13,6 +13,7 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-vector = { workspace = true } +photocraft-cms = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } @@ -21,7 +22,6 @@ rayon = { workspace = true } proptest = { workspace = true } criterion = { workspace = true } photocraft-paint = { workspace = true } -photocraft-cms = { workspace = true } [[bench]] name = "compose" diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 4eedc88619..ea6c11b5b0 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -1,49 +1,215 @@ -//! Display of Multichannel documents: every spot (ink) channel printed over white paper. +//! Spot inks printed over a picture: every spot (ink) channel of a Multichannel document over +//! white paper, and the visible spot channels of an RGB or CMYK document over its composite +//! (the canvas preview and the PSD thumbnail; never `flatten`, export or the merged image). //! //! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity //! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is -//! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document -//! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. -//! Its value is stored as-is (1 = selected, white in the Channels panel), so its ink is `1 − value`. +//! how the reference editor previews spot channels. Inks print in Channels panel order. An +//! alpha-type channel of a Multichannel document (selected / masked areas) still prints, as a +//! transparent black ink, like the reference editor shows it. Its value is stored as-is +//! (1 = selected, white in the Channels panel), so its ink is `1 − value`. -use photocraft_doc::Document; +//! This is a screen approximation, not a press proof: stored RGB alternate colours are taken as +//! sRGB and, like Lab/gray alternates, converted into an RGB document's own profile; CMYK +//! alternates use the document CMYK profile. No spectral ink or paper interaction is modelled. +//! At full coverage, solidity 0 multiplies the underlying colour and solidity 1 replaces it. +//! Previewing borrows the document and only changes the supplied display buffer. + +use std::sync::Arc; + +use photocraft_cms::{Builtin, ColorSpace, Profile, Transform, TransformOptions}; +use photocraft_color::convert::CmykSpace; +use photocraft_color::{Color, ColorMode}; +use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::Rect; use crate::Buffer; -/// The printed inks of `doc` over `rect` (opaque). -pub fn inks(doc: &Document, rect: Rect) -> Buffer { - let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); - let mut vals = Vec::new(); - for ch in &doc.channels { - let alpha = ch.spot.is_none(); - let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); - // An empty alpha channel (all black) is solid black ink, so it can't be skipped. - if !alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { - continue; - } - let c = ink.to_rgb(); - let s = solidity.clamp(0.0, 1.0); - let n = ch.surface.channels(); - ch.surface.read_region_into(rect, &mut vals); - for (p, v) in buf.px.iter_mut().zip(vals.chunks_exact(n)) { - let v = if alpha { 1.0 - v[0] } else { v[0] }.clamp(0.0, 1.0); - if v <= 0.0 { +/// One channel printed as an ink: its display colour in the composite's colour space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ink { + pub rgb: [f32; 3], + pub solidity: f32, + /// An alpha-type channel: black ink where the channel is unselected (`1 − value`). + pub alpha: bool, +} + +impl Ink { + /// The ink of channel `ch` of `doc`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. Printing several inks, build one [`InkSpace`] and use [`InkSpace::ink`]. + pub fn of(doc: &Document, ch: &AlphaChannel) -> Ink { + InkSpace::of(doc).ink(ch) + } + + /// Print this ink with channel values `values` (one per pixel) over the opaque `px`. + pub fn apply(&self, px: &mut [[f32; 4]], values: impl IntoIterator) { + let (c, s) = (self.rgb, self.solidity); + for (p, v) in px.iter_mut().zip(values) { + let v = if self.alpha { 1.0 - v } else { v }; + if v.is_nan() || v <= 0.0 { continue; } + let v = v.min(1.0); let keep = 1.0 - s * v; for j in 0..3 { p[j] = keep * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]; } } } +} + +/// How spot colours of one document map into its composite space. Build it once per render: +/// the document's profiles are parsed here, not per ink or per damage rect. +pub struct InkSpace { + cmyk: Option>, + /// sRGB → an RGB document's own profile, when that differs from sRGB. + to_own: Option>, +} + +impl InkSpace { + pub fn of(doc: &Document) -> InkSpace { + let to_own = if doc.mode == ColorMode::Rgb { + doc.icc_profile + .as_ref() + .and_then(|b| Profile::parse(b).ok()) + .filter(|p| p.color_space == ColorSpace::Rgb && !p.same_colors(Builtin::Srgb.profile())) + .and_then(|own| photocraft_cms::transform::cached(Builtin::Srgb.profile(), &own, TransformOptions::default()).ok()) + } else { + None + }; + InkSpace { cmyk: crate::cmyk_space(doc), to_own } + } + + /// The ink of channel `ch`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. + pub fn ink(&self, ch: &AlphaChannel) -> Ink { + match ch.spot { + Some((color, solidity)) => Ink { rgb: self.rgb(color), solidity: solidity.clamp(0.0, 1.0), alpha: false }, + None => Ink { rgb: [0.0; 3], solidity: 0.0, alpha: true }, + } + } + + /// The display colour of spot colour `color` in the composite space: CMYK colours through the + /// document's CMYK profile (the built-in coated CMYK otherwise), and RGB (taken as sRGB), CMYK, + /// Lab and gray colours of an RGB document converted from sRGB into its own profile, so an ink + /// keeps its colour in a linear (32-bit), Adobe RGB or ProPhoto document. + pub fn rgb(&self, color: Color) -> [f32; 3] { + if matches!(color.mode, ColorMode::Indexed | ColorMode::Multichannel) { + return color.to_rgb(); + } + let mut rgb = photocraft_color::convert::with_cmyk_space(self.cmyk.as_ref(), || color.to_rgb()); + if let Some(t) = &self.to_own { + t.apply(&mut rgb, 3); + } + rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }) + } +} + +/// Print the channels `visible(i)` picks from `doc.channels` (in panel order) as inks over the +/// opaque `buf`, reading them over `buf.rect`. +pub fn overprint(buf: &mut Buffer, doc: &Document, visible: impl Fn(usize) -> bool) { + let rect = buf.rect; + let space = InkSpace::of(doc); + let mut vals = Vec::new(); + for (i, ch) in doc.channels.iter().enumerate() { + if !visible(i) { + continue; + } + let ink = space.ink(ch); + // An empty alpha channel (all black) is solid black ink, so it can't be skipped. + if !ink.alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { + continue; + } + let n = ch.surface.channels().max(1); + ch.surface.read_region_into(rect, &mut vals); + ink.apply(&mut buf.px, vals.chunks_exact(n).map(|v| v[0])); + } +} + +/// Whether channel `i` of `doc` is a spot ink that prints over an RGB or CMYK composite. +pub fn is_overprint_spot(doc: &Document, i: usize) -> bool { + matches!(doc.mode, ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Grayscale | ColorMode::Lab) && doc.channels.get(i).is_some_and(|c| c.spot.is_some()) +} + +/// The printed inks of `doc` over `rect` (opaque). +pub fn inks(doc: &Document, rect: Rect) -> Buffer { + let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); + overprint(&mut buf, doc, |_| true); buf } /// The starting backdrop of a document render: the inks for Multichannel, else transparent. pub(crate) fn backdrop(doc: &Document, rect: Rect) -> Buffer { - if doc.mode == photocraft_color::ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } + if doc.mode == ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } +} + +/// [`crate::thumbnail_buffer`] of an RGB or CMYK document with its spot channels printed over it +/// (matted over white, so opaque, where there is ink): the document's preview, as the reference +/// editor saves it. +/// Unchanged when no spot channel has ink. +pub fn thumbnail_with_inks(doc: &Document, max_side: u32) -> Buffer { + let mut buf = crate::thumbnail_buffer(doc, max_side); + let spots: Vec = (0..doc.channels.len()) + .filter(|&i| is_overprint_spot(doc, i) && doc.channels.get(i).is_some_and(|c| !c.surface.content_bounds().intersect(&doc.bounds()).is_empty())) + .collect(); + let (w, h) = (buf.rect.width(), buf.rect.height()); + if spots.is_empty() || w == 0 || h == 0 || buf.px.len() != w as usize * h as usize { + return buf; + } + let space = InkSpace::of(doc); + let inks: Vec<(Ink, Vec)> = + spots.into_iter().filter_map(|i| doc.channels.get(i)).map(|ch| (space.ink(ch), reduce(&ch.surface, doc.bounds(), w, h))).collect(); + // Only pixels with ink are matted over white (the inks print on paper); the rest keep their + // transparency. + for (k, p) in buf.px.iter_mut().enumerate() { + if inks.iter().any(|(_, vals)| vals.get(k).is_some_and(|v| *v > 0.0)) { + let a = if p[3].is_nan() { 0.0 } else { p[3].clamp(0.0, 1.0) }; + *p = [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0]; + } + } + for (ink, vals) in inks { + ink.apply(&mut buf.px, vals); + } + buf +} + +/// Samples per side of a thumbnail pixel that [`reduce`] averages at most. +const REDUCE_SAMPLES: i32 = 4; + +/// The first channel of `s` over `area`, averaged down to `w`×`h`, one output row at a time. +/// A box up to [`REDUCE_SAMPLES`] pixels a side is averaged whole; a bigger one at that many +/// evenly spaced rows and columns, so a huge document costs about `h × 4` row reads, not a scan. +fn reduce(s: &photocraft_raster::Surface, area: Rect, w: u32, h: u32) -> Vec { + let (sw, sh) = (u64::from(area.width()), u64::from(area.height())); + let n = s.channels().max(1); + let edge = |i: u32, out: u32, len: u64| (u64::from(i) * len / u64::from(out.max(1))) as i32; + // The offsets from a box's start that are sampled. + let picks = |len: i32| -> Vec { + if len <= REDUCE_SAMPLES { (0..len).collect() } else { (0..REDUCE_SAMPLES).map(|j| ((2 * j + 1) * len) / (2 * REDUCE_SAMPLES)).collect() } + }; + let mut out = Vec::with_capacity(w as usize * h as usize); + let mut row = Vec::new(); + let mut sums = vec![(0.0f32, 0u32); w as usize]; + for y in 0..h { + let (y0, y1) = (edge(y, h, sh), edge(y + 1, h, sh)); + sums.iter_mut().for_each(|t| *t = (0.0, 0)); + for dy in picks((y1 - y0).max(1)) { + let sy = area.y0 + y0 + dy; + s.read_region_into(Rect::new(area.x0, sy, area.x1, sy + 1), &mut row); + for (x, t) in sums.iter_mut().enumerate() { + let (x0, x1) = (edge(x as u32, w, sw), edge(x as u32 + 1, w, sw)); + for dx in picks((x1 - x0).max(1)) { + if let Some(v) = row.get((x0 + dx) as usize * n) { + t.0 += v; + t.1 += 1; + } + } + } + } + out.extend(sums.iter().map(|&(sum, count)| if count > 0 { sum / count as f32 } else { 0.0 })); + } + out } #[cfg(test)] @@ -70,7 +236,7 @@ mod tests { assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); // An alpha-type channel (stored as-is: 1 = selected) prints as a black ink where it is - // unselected, as Photoshop shows it. + // unselected, as the reference editor shows it. let mut k = Surface::new(fmt); k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 1.0, 1.0, 0.0]); doc.channels.push(AlphaChannel::new("Alpha", k)); @@ -83,4 +249,169 @@ mod tests { doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); } + + /// One spot channel of `depth` holding `vals` (a row), ink `rgb` at `solidity`. + fn spot(depth: SampleType, name: &str, rgb: [f32; 3], solidity: f32, vals: &[f32]) -> AlphaChannel { + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_region(Rect::new(0, 0, vals.len() as i32, 1), vals); + AlphaChannel { spot: Some((Color::rgb(rgb[0], rgb[1], rgb[2]), solidity)), ..AlphaChannel::new(name, s) } + } + + /// The ink formula written out for one ink over `p`. + fn print(p: [f32; 3], c: [f32; 3], s: f32, v: f32) -> [f32; 3] { + std::array::from_fn(|j| (1.0 - s * v) * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]) + } + + fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool { + a[3] == 1.0 && a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) + } + + /// Spot channels never reach the composite of an RGB or CMYK document: flatten, the merged + /// image and export are the same with and without them. + #[test] + fn spots_stay_out_of_rgb_and_cmyk_flatten() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("d", Size::new(4, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + let plain = crate::flatten(&doc); + doc.channels.push(spot(depth, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 0.5, 0.0, 1.0])); + doc.channels.push(spot(depth, "pink", [1.0, 0.3, 0.6], 1.0, &[1.0, 1.0, 1.0, 1.0])); + assert_eq!(crate::flatten(&doc).px, plain.px, "{mode:?} {depth:?}"); + let mut bare = doc.clone(); + bare.channels.clear(); + assert_eq!(crate::render(&doc, doc.bounds()).px, crate::render(&bare, doc.bounds()).px, "{mode:?} {depth:?}"); + } + } + } + + /// Two overlapping inks over a coloured picture, at every solidity and coverage, in both + /// orders and at 8 and 16 bits: the second ink prints over the first. + #[test] + fn overprint_follows_the_ink_formula_in_panel_order() { + let (a, b) = ([0.0, 0.6, 0.65], [0.9, 0.2, 0.5]); + let under = [0.8, 0.7, 0.3]; + for depth in [SampleType::U8, SampleType::U16] { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for sa in [0.0, 0.5, 1.0] { + for sb in [0.0, 0.5, 1.0] { + let cover = [0.0, 0.5, 1.0]; + let mut doc = Document::new("o", Size::new(3, 1), mode, depth); + doc.channels.push(spot(depth, "a", a, sa, &cover)); + doc.channels.push(spot(depth, "b", b, sb, &[1.0, 0.5, 0.0])); + for swapped in [false, true] { + if swapped { + doc.channels.reverse(); + } + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |_| true); + let vals: Vec<(f32, f32)> = vec![(0.0, 1.0), (0.5, 0.5), (1.0, 0.0)]; + for (x, &(va, vb)) in vals.iter().enumerate() { + let want = if swapped { print(print(under, b, sb, vb), a, sa, va) } else { print(print(under, a, sa, va), b, sb, vb) }; + // 0.5 is stored as 128/255 at 8 bits. + assert!(close(buf.px[x], want, 0.006), "{mode:?} {depth:?} s {sa}/{sb} swapped {swapped} x {x}: {:?} vs {want:?}", buf.px[x]); + } + if swapped { + doc.channels.reverse(); + } + } + // Only the channels `visible` picks print. + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |i| i == 0); + assert!(close(buf.px[2], print(under, a, sa, 1.0), 1e-5)); + } + } + } + } + } + + /// A solid transparent ink over white shows its own colour; a solid opaque one covers any colour. + #[test] + fn solid_inks_show_their_colour() { + let teal = [0.0, 157.0 / 255.0, 165.0 / 255.0]; + let mut doc = Document::new("s", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + doc.channels.push(spot(SampleType::U8, "teal", teal, 0.0, &[1.0])); + let mut buf = Buffer::filled(doc.bounds(), [1.0; 4]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + doc.channels[0].spot = Some((Color::rgb(teal[0], teal[1], teal[2]), 1.0)); + let mut buf = Buffer::filled(doc.bounds(), [0.2, 0.9, 0.1, 1.0]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + } + + #[test] + fn hidden_spot_leaves_the_process_composite_unchanged() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("hidden", Size::new(1, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + doc.channels.push(spot(depth, "opaque", [0.0, 0.6, 0.65], 1.0, &[1.0])); + let before = crate::flatten(&doc); + let mut preview = before.clone(); + overprint(&mut preview, &doc, |_| false); + assert_eq!(preview.px, before.px); + overprint(&mut preview, &doc, |_| true); + assert!(close(preview.px[0], [0.0, 0.6, 0.65], 1e-6)); + assert_eq!(crate::flatten(&doc).px, before.px); + assert_eq!(doc.channels[0].spot, Some((Color::rgb(0.0, 0.6, 0.65), 1.0))); + } + } + } + + /// A CMYK spot colour displays through the document's CMYK profile, as the CMYK composite does. + #[test] + fn cmyk_ink_colours_go_through_the_document_profile() { + let doc = Document::new("c", Size::new(1, 1), ColorMode::Cmyk, SampleType::U8); + let cyan = Color { mode: ColorMode::Cmyk, c: [1.0, 0.0, 0.0, 0.0], alpha: 1.0 }; + let space = crate::cmyk_space(&doc); + let want = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cyan.to_rgb()); + assert_eq!(InkSpace::of(&doc).rgb(cyan), want.map(|v| v.clamp(0.0, 1.0))); + // RGB inks are taken as stored. + assert_eq!(InkSpace::of(&doc).rgb(Color::rgb(0.1, 0.2, 0.3)), [0.1, 0.2, 0.3]); + } + + /// An RGB spot colour is sRGB, like the other alternates: an sRGB document takes it as stored, + /// a linear (32-bit) one gets its linear values, which the canvas encodes back to the colour. + #[test] + fn rgb_ink_colours_convert_into_the_document_profile() { + let teal = Color::rgb(0.0, 0.6, 0.65); + let mut doc = Document::new("l", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + assert_eq!(InkSpace::of(&doc).rgb(teal), [0.0, 0.6, 0.65]); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let got = InkSpace::of(&doc).rgb(teal); + let want = [0.0, 0.6, 0.65].map(photocraft_color::convert::srgb_to_linear); + assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() <= 0.005), "{got:?} vs {want:?}"); + } + + /// The thumbnail prints the spot inks over the composite (matted over white). + #[test] + fn thumbnail_prints_spot_inks() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut doc = Document::new("t", Size::new(4, 1), mode, SampleType::U8); + let plain = thumbnail_with_inks(&doc, 160); + assert_eq!(plain.px, crate::thumbnail_buffer(&doc, 160).px, "no ink: unchanged"); + doc.channels.push(spot(SampleType::U8, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 1.0, 0.0, 0.0])); + let t = thumbnail_with_inks(&doc, 160); + assert!(close(t.px[0], [0.0, 0.6, 0.65], 0.003), "{mode:?} {:?}", t.px[0]); + // No ink: the transparent pixel stays transparent (the writer mattes it later). + assert_eq!(t.px[3], plain.px[3], "{mode:?}"); + // Halved: each thumbnail pixel averages two document pixels. + let t = thumbnail_with_inks(&doc, 2); + assert!(close(t.px[0], print([1.0; 3], [0.0, 0.6, 0.65], 0.0, 1.0), 0.003), "{:?}", t.px); + assert_eq!(t.px[1], crate::thumbnail_buffer(&doc, 2).px[1], "no ink: unchanged"); + } + } + + /// A box bigger than the sample grid averages evenly spaced samples: ink on the left half of + /// a 64-pixel box reads half covered. + #[test] + fn reduce_samples_big_boxes() { + let mut sf = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let vals: Vec = (0..64).map(|x| if x < 32 { 1.0 } else { 0.0 }).collect(); + sf.write_region(Rect::new(0, 0, 64, 1), &vals); + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 1, 1); + assert_eq!(r, [0.5]); + // Small boxes stay exact. + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 16, 1); + assert_eq!(r, (0..16).map(|x| if x < 8 { 1.0 } else { 0.0 }).collect::>()); + } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index e0b68a6b51..aa56917f40 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -21,16 +21,33 @@ pub fn downsample(s: &Surface, k: u32) -> Surface { let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); let w = x1.saturating_sub(x0).max(0) as usize; - let mut row = vec![0u8; w * bpp]; - for oy in y0..y1 { - let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); - for (i, px) in row.chunks_exact_mut(bpp).enumerate() { - // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. - if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { - px.copy_from_slice(from); + // Rows are read in parallel, a chunk at a time, so the scratch stays small. + const CHUNK_ROWS: i32 = 64; + let mut chunk = Vec::new(); + let mut y = y0; + while y < y1 { + let end = y.saturating_add(CHUNK_ROWS).min(y1); + chunk.clear(); + chunk.resize(w * (end - y) as usize * bpp, 0u8); + let fill = |(row, oy): (&mut [u8], i32)| { + let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); + for (i, px) in row.chunks_exact_mut(bpp).enumerate() { + // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. + if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { + px.copy_from_slice(from); + } } + }; + let rows = (w * bpp).max(1); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + chunk.par_chunks_mut(rows).zip(y..end).for_each(fill); } - out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); + #[cfg(target_arch = "wasm32")] + chunk.chunks_mut(rows).zip(y..end).for_each(fill); + out.write_interleaved(Rect::new(x0, y, x1, end), &chunk); + y = end; } out } diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index a640a7b45b..b7f2074b98 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -1186,6 +1186,25 @@ fn layers_panel_mask_target_isolates_the_curve_channel() { assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); } +/// An opened RGB or CMYK document shows its spot channels (they print over the composite) and +/// hides its alpha channels; a Multichannel document's inks are its composite, so their eyes stay off. +#[test] +fn opened_documents_show_their_spot_channels() { + use photocraft_color::{Color, ColorMode, PixelFormat}; + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Multichannel] { + let mut d = Document::new("spots", photocraft_doc::Size::new(4, 4), mode, SampleType::U8); + let fmt = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + d.channels.push(AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(fmt))); + d.channels.push(AlphaChannel { spot: Some((Color::rgb(0.0, 0.6, 0.65), 0.0)), ..AlphaChannel::new("teal", photocraft_raster::Surface::new(fmt)) }); + let mut s = Session::new(); + s.open_document(d, None); + let v = &s.active().unwrap().channel_view; + let spot_on = mode != ColorMode::Multichannel; + assert_eq!((v.alpha_shown(0), v.alpha_shown(1)), (false, spot_on), "{mode:?}"); + assert_eq!(v.visible_colors(color_count(&s.active().unwrap().doc)), color_count(&s.active().unwrap().doc), "{mode:?}: the composite stays on"); + } +} + /// Split Channels closes the original, so as in the reference application it splits only an /// image with a single layer: two layers (even with one hidden) or a lone group are refused and /// the document stays open with its layers; a flattened image or a lone normal layer still diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index 5fb2d7d0f2..a93c580829 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -397,16 +397,16 @@ impl ColorState { pub fn display_transform_for(&self, doc: &Document, display: Option) -> Result> { let src = self.canvas_display_for(doc, display)?.source.clone(); let dst = self.monitor_for(display); - let pv = self.proof(doc.id); - let proof = pv.enabled.then(|| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); + // Borrowed: the canvas asks per frame, and the default proof state allocates a profile. + let pv = self.proof_ref(doc.id).filter(|pv| pv.enabled); + let proof = pv.map(|pv| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); let key = (src.content_hash(), dst.content_hash(), proof); if let Some(t) = self.display_cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return Ok(t.clone()); } - let t = if pv.enabled { - Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper) - } else { - Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC) + let t = match pv { + Some(pv) => Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper), + None => Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC), } .map_err(cms_err)?; let t = Arc::new(t); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index a8f50c3084..e10f5be984 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -82,6 +82,11 @@ impl DocState { let mut history = History::default(); let target = LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() }; history.set_current_layers(target.clone()); + // Spot channels open visible, printing over the composite; alpha channels open hidden. + let channel_view = channel_cmds::ChannelView { + alpha_visible: (0..doc.channels.len()).map(|i| photocraft_compose::multichannel::is_overprint_spot(&doc, i)).collect(), + ..Default::default() + }; let doc = Arc::new(doc); // Photoshop's first snapshot: the document as opened, named after it, so the History // panel's top row can always bring it back (#2966). @@ -100,7 +105,7 @@ impl DocState { view_revision: 0, last_damage: None, coalesce: None, - channel_view: Default::default(), + channel_view, isolated_layers: Vec::new(), symmetry: None, fx_collapsed: Vec::new(), diff --git a/crates/io/src/psd_thumbnail.rs b/crates/io/src/psd_thumbnail.rs index f2165dbb87..e40c782ef0 100644 --- a/crates/io/src/psd_thumbnail.rs +++ b/crates/io/src/psd_thumbnail.rs @@ -20,10 +20,11 @@ pub const MAX_SIDE: u32 = 160; /// Size of the header in front of the JFIF data. pub const HEADER_LEN: usize = 28; -/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`], converted to -/// sRGB through the document's profile, matted over white and JPEG-compressed. +/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`] with its spot inks +/// printed over it, converted to sRGB through the document's profile, matted over white and +/// JPEG-compressed. pub(crate) fn resource(doc: &Document) -> Result { - let mut buf = photocraft_compose::thumbnail_buffer(doc, MAX_SIDE); + let mut buf = photocraft_compose::multichannel::thumbnail_with_inks(doc, MAX_SIDE); let (w, h) = (buf.rect.width(), buf.rect.height()); if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || buf.px.len() != (w as usize) * (h as usize) { return Err(IoError::Unsupported("the image preview could not be rendered".into())); diff --git a/crates/io/tests/it/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs index 7445bbf757..be6fbd360b 100644 --- a/crates/io/tests/it/psd_thumbnail.rs +++ b/crates/io/tests/it/psd_thumbnail.rs @@ -151,3 +151,29 @@ fn the_document_profile_is_honoured() { let mid = px[25 * 50 + 25]; assert!(near(mid, [0.735; 3], 0.03), "{mid:?}"); } + +/// The thumbnail shows the document's spot inks over its composite, as the reference editor's +/// does (psd-tools cmyk-alpha-spot.psd: its spot-only text is in the preview, not in the merged +/// image). +#[test] +fn spot_inks_print_in_the_thumbnail() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut d = doc(mode, SampleType::U8, 64, 32, [1.0, 1.0, 1.0, 1.0], 1.0); + let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut ink = photocraft_raster::Surface::new(fmt); + ink.fill_rect(photocraft_geom::Rect::new(0, 0, 32, 32), &[1.0]); + let teal = photocraft_color::Color::rgb(0.0, 157.0 / 255.0, 165.0 / 255.0); + d.channels.push(photocraft_doc::AlphaChannel { spot: Some((teal, 0.0)), ..photocraft_doc::AlphaChannel::new("light teal", ink) }); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let (w, _, px) = thumbnail(&bytes).unwrap(); + let (inked, bare) = (px[(16 * w + 8) as usize], px[(16 * w + 56) as usize]); + assert!(near(inked, [0.0, 157.0 / 255.0, 165.0 / 255.0], 0.06), "{mode:?} {inked:?}"); + assert!(near(bare, [1.0, 1.0, 1.0], 0.04), "{mode:?} {bare:?}"); + // The merged process planes stay free of the ink: white (no CMYK ink is stored as 255 too). + let merged = PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap(); + let plane = 64 * 32; + for c in 0..mode.color_channels() { + assert_eq!(merged[c * plane + 16 * 64 + 8], 255, "{mode:?} merged channel {c}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 51c9714075..3cdd87d26b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -506,7 +506,7 @@ fn paint_canvas( } } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). - if let Some(tex) = crate::channel_view::ensure(app, ctx, idx) { + if let Some(tex) = crate::channel_view::ensure(app, ctx, idx, output) { // Rendered from the live preview, so over the area the preview is shown in. let a = shown_area; if view.rotation.abs() > 0.05 { @@ -2220,12 +2220,12 @@ mod rotation_preview_isolation_tests { .unwrap(); let before = app.session.active().unwrap().doc.clone(); let ctx = egui::Context::default(); - let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); ensure_filter_preview(&mut app, 0, &ctx).unwrap(); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("filter preview must update channel overlay")[0].image; let st = app.session.active().unwrap(); @@ -2235,16 +2235,16 @@ mod rotation_preview_isolation_tests { delta.clear(); assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(false)); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Preview off restores channel")[0].image; assert_eq!(image.pixels, original); delta.clear(); app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(true)); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); ctx.tex_manager().write().take_delta().clear(); crate::dialogs::cancel(&mut app, dialog).unwrap(); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Cancel restores channel")[0].image; assert_eq!(image.pixels, original); @@ -2382,11 +2382,11 @@ mod rotation_preview_isolation_tests { view.set_color_visible(2, false); } let ctx = egui::Context::default(); - let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); - crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("preview must refresh overlay")[0].image; crate::dialogs::confirm(&mut app, dialog).unwrap(); diff --git a/crates/ui-egui/src/canvas/channel_view.rs b/crates/ui-egui/src/canvas/channel_view.rs index 4d7602e4a1..6a15fb9bdd 100644 --- a/crates/ui-egui/src/canvas/channel_view.rs +++ b/crates/ui-egui/src/canvas/channel_view.rs @@ -1,11 +1,18 @@ -//! Channel display on the canvas: alpha / spot / Quick Mask colour overlays, single colour -//! channel views (grayscale) and hidden colour channels. +//! Channel display on the canvas: alpha / Quick Mask colour overlays, spot inks printed over the +//! composite, single colour channel views (grayscale) and hidden colour channels. //! //! The document composite (GPU or CPU) is untouched. When the Channels panel shows anything other //! than the plain composite, one extra texture is drawn over it: transparent overlay pixels when -//! the composite stays visible, an opaque picture otherwise. The texture is keyed by document -//! revision and view state and updated per damage rectangle while painting, so nothing is -//! recomposited per frame. +//! the composite stays visible, an opaque picture with hidden colour channels. Visible spot +//! channels of an RGB or CMYK document print over the sampled composite where they have ink +//! (opaque, through the canvas display transform); elsewhere the texture stays transparent, so the +//! normal canvas shows. This is a screen approximation, not a press proof: each ink is its stored +//! display colour blended by the Multichannel overprint formula (multiply at solidity 0, cover at +//! solidity 1) over the sampled composite. A solo spot (all colour channels hidden) shows as its +//! grayscale values instead. The texture is keyed by document revision, view state and display, and +//! updated per damage rectangle while painting, so nothing is recomposited per frame. + +use std::borrow::Cow; use egui::{Color32, TextureOptions}; use photocraft_doc::{AlphaChannel, ColorMode, Document}; @@ -33,6 +40,8 @@ struct SampledComposite { struct CompositeSource<'a> { cached: Option<&'a [[f32; 4]]>, fresh: &'a mut Option>, + /// What printed spot inks go through on their way to the screen. + screen: &'a Screen, } pub struct Cache { @@ -44,7 +53,8 @@ pub struct Cache { /// False while the plain composite shows: the texture is kept (so toggling a view back on /// reuses its allocation) but its pixels are stale. valid: bool, - tex: egui::TextureHandle, + /// `None` while the view draws nothing over the canvas (visible spot channels without ink). + tex: Option, source: Option, } @@ -90,9 +100,80 @@ impl Look { } } +/// What the canvas does to composite values on their way to the screen: the sRGB encoding of +/// linear composites, then the monitor transform (with the soft proof under View › Proof Colors). +/// Spot inks printed over the composite go through it, so they match the canvas around them. +#[derive(Clone, Default)] +pub struct Screen { + pub encode_srgb: bool, + pub transform: Option>, +} + +impl Screen { + /// How the canvas shows `doc` on `display` (`None`: the main window's). + pub fn of(app: &PhotocraftApp, doc: &Document, display: Option) -> Screen { + let color = &app.session.color; + let display = display.or(color.main_display); + let Ok(d) = color.canvas_display_for(doc, display) else { return Screen::default() }; + let proof = color.proof_ref(doc.id).is_some_and(|p| p.enabled); + let transform = if proof || d.transform.is_some() { color.display_transform_for(doc, display).ok() } else { None }; + Screen { encode_srgb: d.encode_srgb, transform } + } + + /// A key that changes with [`Screen::of`]. + pub fn key(app: &PhotocraftApp, doc: &Document, display: Option) -> u64 { + let color = &app.session.color; + color.display_signature_for(doc, display.or(color.main_display)) + } + + /// Opaque composite pixels `px` as monitor values. + fn apply(&self, px: &mut [[f32; 4]]) { + if self.encode_srgb { + for p in px.iter_mut() { + for v in &mut p[..3] { + *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 1.0)); + } + } + } + if let Some(t) = &self.transform { + t.apply(px.as_flattened_mut(), 4); + } + } +} + /// Composite RGBA (over white, like Photoshop's channel views) at `factor` stride. /// Render in bands instead of allocating a full-resolution float composite to sample a preview. fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { + let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); + // A downsampled document (#10249): the whole canvas at `factor` from a proxy picks the same + // pixels (`downsample` keeps every factor-th one) for ~1/factor² of the compositing. + if factor > 1 && r == doc.bounds() && proxy_matches(doc) { + // Only Multichannel composites read the extra channels; the selection and Quick Mask + // never show in a composite. Left out, they cost nothing to shrink. + let mut layers_only = doc.clone(); + if doc.mode != ColorMode::Multichannel { + layers_only.channels.clear(); + } + layers_only.selection = None; + layers_only.quick_mask = None; + let p = photocraft_compose::proxy::proxy_document(&layers_only, factor); + let mut out = Vec::with_capacity((w * h) as usize); + let _ = photocraft_compose::render_bands(&p, Rect::new(0, 0, w as i32, h as i32), 0, |band| -> Result<(), ()> { + out.extend(band.px.iter().map(|p| { + let a = p[3].clamp(0.0, 1.0); + [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0] + })); + Ok(()) + }); + if out.len() == (w * h) as usize { + return out; + } + } + composite_sampled(doc, r, factor) +} + +/// [`composite`] sampled from the full-resolution composite, rendered in bands. +fn composite_sampled(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); let mut out = Vec::with_capacity((w * h) as usize); let f = factor as i32; @@ -110,11 +191,86 @@ fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { out } +/// The samples of `r` (see [`composite`]): `cached` when it matches, else rendered, and kept in +/// `computed` for the cache when that is not too big. +fn composite_px<'a>(doc: &Document, r: Rect, factor: u32, cached: Option<&'a [[f32; 4]]>, computed: &mut Option>) -> Cow<'a, [[f32; 4]]> { + let n = r.width().div_ceil(factor) as usize * r.height().div_ceil(factor) as usize; + if let Some(px) = cached.filter(|px| px.len() == n) { + return Cow::Borrowed(px); + } + let px = composite(doc, r, factor); + if n <= MAX_COMPOSITE_CACHE_PIXELS { + *computed = Some(px.clone()); + } + Cow::Owned(px) +} + +/// The samples of `r` (see [`composite`]) at the grid indices `inked` (ascending), in that order. +/// A few inked pixels of a big document render just their bounding box; when that costs no less +/// than the whole composite (a proxy renders it at about 1/factor² of the pixels), the whole one +/// is used, from `cached` or rendered and kept in `computed` for the cache. +fn composite_at(doc: &Document, r: Rect, factor: u32, inked: &[usize], cached: Option<&[[f32; 4]]>, computed: &mut Option>) -> Vec<[f32; 4]> { + let w = r.width().div_ceil(factor) as usize; + let n = w * r.height().div_ceil(factor) as usize; + let pick = |px: &[[f32; 4]]| -> Vec<[f32; 4]> { inked.iter().filter_map(|&k| px.get(k).copied()).collect() }; + if let Some(px) = cached.filter(|px| px.len() == n) { + return pick(px); + } + let (Some(&first), Some(&last)) = (inked.first(), inked.last()) else { return Vec::new() }; + if w == 0 { + return Vec::new(); + } + // The inked samples' bounding box, in samples, then in document pixels (on the same grid). + let (bx0, bx1) = inked.iter().fold((usize::MAX, 0), |(lo, hi), &k| (lo.min(k % w), hi.max(k % w + 1))); + let (by0, by1) = (first / w, last / w + 1); + let f = factor as usize; + let at = |o: i32, s: usize| o.saturating_add((s * f) as i32); + let area = Rect::new(at(r.x0, bx0), at(r.y0, by0), at(r.x0, bx1 - 1).saturating_add(1).min(r.x1), at(r.y0, by1 - 1).saturating_add(1).min(r.y1)); + let proxy = factor > 1 && r == doc.bounds() && proxy_matches(doc); + let whole_cost = r.size().area() / if proxy { u64::from(factor) * u64::from(factor) } else { 1 }; + if area.size().area() < whole_cost { + let (px, bw) = (composite_sampled(doc, area, factor), bx1 - bx0); + return inked.iter().filter_map(|&k| px.get((k / w - by0) * bw + k % w - bx0).copied()).collect(); + } + pick(&composite_px(doc, r, factor, None, computed)) +} + +/// Whether `v` prints spot inks of `doc` over its composite, through the [`Screen`]. +fn prints_inks(doc: &Document, v: &ChannelView) -> bool { + let colors = color_count(doc); + v.visible_colors(colors) == colors && (0..doc.channels.len()).any(|i| v.alpha_shown(i) && photocraft_compose::multichannel::is_overprint_spot(doc, i)) +} + +/// Whether a proxy composite picks the full composite's pixels: no layer effects or vector masks +/// ([`proxy_faithful`](photocraft_compose::proxy::proxy_faithful)), no feathered masks, whose +/// radius is in document pixels, and no pattern fills, whose tiles keep their size on a proxy +/// unless a cache of the same fill is shrunk with it. +fn proxy_matches(doc: &Document) -> bool { + use photocraft_doc::{Fill, LayerContent}; + // A stack, not recursion: groups can nest deeply. Hidden layers (and what they hold) are not + // composited, so they don't count. + let mut stack: Vec<&photocraft_doc::Layer> = doc.layers.iter().filter(|l| l.visible).collect(); + while let Some(l) = stack.pop() { + if photocraft_compose::masks::has_feather(l) { + return false; + } + if let LayerContent::Fill(fill @ Fill::Pattern { .. }) = &l.content + && l.fill_cache.as_ref().is_none_or(|c| c.fill != *fill) + { + return false; + } + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter().filter(|c| c.visible)); + } + } + photocraft_compose::proxy::proxy_faithful(doc) +} + /// The channel view of `r` (document pixels, sampled every `factor` px) as premultiplied pixels, /// or `None` when the plain composite is all there is to show. pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, look: Look, active: Option) -> Option> { let mut unused = None; - render_with_source(doc, v, r, factor, look, active, CompositeSource { cached: None, fresh: &mut unused }) + render_with_source(doc, v, r, factor, look, active, CompositeSource { cached: None, fresh: &mut unused, screen: &Screen::default() }) } fn render_with_source( @@ -161,16 +317,41 @@ fn render_with_source( let vis = v.visible_colors(colors); let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); let n = (w * h) as usize; - let mut overlays: Vec<&AlphaChannel> = (0..doc.channels.len()).filter(|i| v.alpha_shown(*i)).map(|i| &doc.channels[i]).collect(); + let mut overlays: Vec = (0..doc.channels.len()).filter(|i| v.alpha_shown(*i)).collect(); let gray = |s: &Surface| sample(s, r, factor).into_iter().map(|g| [g, g, g, 1.0]).collect(); + // The sampled composite over white: the cached one when it matches, else rendered here (and + // handed back to the cache). + let mut computed = None; + // Visible spot channels of an RGB or CMYK document print over the picture as inks, in panel + // order, before the alpha channel and Quick Mask tints. + let spots: Vec<(photocraft_compose::multichannel::Ink, Vec)> = if vis > 0 { + let spot_ids: Vec = overlays.iter().copied().filter(|&i| photocraft_compose::multichannel::is_overprint_spot(doc, i)).collect(); + overlays.retain(|i| !spot_ids.contains(i)); + // The ink colours are converted once per render, and only when there are inks to print. + let space = (!spot_ids.is_empty()).then(|| photocraft_compose::multichannel::InkSpace::of(doc)); + space.iter().flat_map(|space| spot_ids.iter().filter_map(|&i| doc.channels.get(i)).map(|ch| (space.ink(ch), sample(&ch.surface, r, factor)))).collect() + } else { + Vec::new() + }; + // Over the whole composite, spot inks print (#10249) only where they have ink. + let inked: Vec = if vis == colors && !spots.is_empty() { + (0..n).filter(|&k| spots.iter().any(|(_, vals)| vals.get(k).is_some_and(|v| *v > 0.0))).collect() + } else { + Vec::new() + }; + // No ink, and no alpha channel, Quick Mask or layer mask shown over the composite: nothing to + // draw, so no all-transparent texture either. + if vis == colors && inked.is_empty() && overlays.is_empty() && (doc.quick_mask.is_none() || v.quick_mask_hidden) && shown_mask.is_none() { + return None; + } // Premultiplied RGBA accumulator. With no colour channel left, the channel being edited - // (alpha, Quick Mask, or layer mask) is the picture — grayscale, not a rubylith over nothing. + // (alpha, spot, Quick Mask, or layer mask) is the picture — grayscale, not a rubylith over nothing. let mut solo_quick = false; let mut solo_mask = false; let mut out: Vec<[f32; 4]> = if vis == 0 { if !overlays.is_empty() { let first = overlays.remove(0); - gray(&first.surface) + gray(&doc.channels.get(first)?.surface) } else if let Some(q) = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden) { solo_quick = true; gray(&q.surface) @@ -186,12 +367,10 @@ fn render_with_source( let fmt = doc.pixel_format(); let mode = fmt.mode; let shown: Vec = (0..colors).filter(|k| v.color_visible(*k)).collect(); - let computed = if source.cached.is_some_and(|px| px.len() == n) { None } else { Some(composite(doc, r, factor)) }; - let pixels = source.cached.filter(|px| px.len() == n).or(computed.as_deref())?; - let converted = pixels + let pixels = composite_px(doc, r, factor, source.cached, &mut computed); + pixels .iter() - .copied() - .map(|rgba| { + .map(|&rgba| { let mut native = from_rgba(&fmt, rgba); if shown.len() == 1 && !in_color { let x = native[shown[0]]; @@ -203,21 +382,40 @@ fn render_with_source( if !v.color_visible(k) { // Hidden channels contribute nothing (Lab: neutral a/b, and a neutral // Lightness under Show Channels in Color, so an isolated a/b channel - // tints over mid gray instead of black, as Photoshop does). + // tints over mid gray instead of black, as the reference editor does). *x = if mode == ColorMode::Lab && (k > 0 || in_color) { 0.5 } else { 0.0 }; } } let c = to_rgba(&fmt, &native); [c[0], c[1], c[2], 1.0] }) - .collect(); - if n <= MAX_COMPOSITE_CACHE_PIXELS { - *source.fresh = computed; - } - converted + .collect() } else { vec![[0.0; 4]; n] }; + if !inked.is_empty() { + // The inked pixels become the opaque printed picture, the rest stay transparent so the + // normal canvas (display LUT, proofing, full resolution) shows. Only the inked area of the + // composite is rendered. + let mut printed = composite_at(doc, r, factor, &inked, source.cached, &mut computed); + if printed.len() == inked.len() { + for (ink, vals) in &spots { + ink.apply(&mut printed, inked.iter().map(|&k| vals.get(k).copied().unwrap_or(0.0))); + } + source.screen.apply(&mut printed); + for (&k, p) in inked.iter().zip(printed) { + if let Some(o) = out.get_mut(k) { + *o = p; + } + } + } + } else if vis < colors { + for (ink, vals) in &spots { + ink.apply(&mut out, vals.iter().copied()); + } + } + *source.fresh = computed; + let overlays: Vec<&AlphaChannel> = overlays.into_iter().filter_map(|i| doc.channels.get(i)).collect(); // Rubylith: `color` at `opacity` × coverage over the accumulated pixels. let mut tint = |values: Vec, color: photocraft_color::Color, opacity: f32, coverage: &dyn Fn(f32) -> f32| { let c = color.to_rgb(); @@ -231,8 +429,9 @@ fn render_with_source( }; let quick = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden && !solo_quick); for ch in overlays.into_iter().chain(quick) { + // Spot channels reach here only in Multichannel documents, whose composite already prints + // them: they show more solid as solidity rises. let (color, opacity) = match ch.spot { - // Spot inks show more solid as solidity rises. Some((ink, solidity)) => (ink, 0.5 + 0.5 * solidity), None => (ch.color, ch.opacity), }; @@ -284,8 +483,9 @@ fn view_key(v: &ChannelView, look: Look) -> u64 { s.bytes().fold(look.in_color as u64 + 1, |h, b| h.wrapping_mul(1_099_511_628_211).wrapping_add(b as u64)) } -/// Make the channel-view texture of document `idx` current; `None` = nothing to draw. -pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option { +/// Make the channel-view texture of document `idx`, shown on `display` (`None`: the main +/// window's), current; `None` = nothing to draw. +pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, display: Option) -> Option { let st = app.session.documents().get(idx)?; let view = st.channel_view.clone(); let active = st.active_layer; @@ -306,7 +506,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // not just the panel thumbnails (#2385). The view key carries the flag and the Layer Mask // Display Options, so changing them rebuilds the texture. let look = Look::of(&app.session); - let key = view_key(&view, look); + // Printed spot inks go through the display transform, so its key is part of the view's. + let inks = prints_inks(&doc, &view); + let screen_key = if inks { Screen::key(app, &doc, display) } else { 0 }; + let key = view_key(&view, look) ^ screen_key.rotate_left(17); // Never exceed what the GPU accepts (egui panics on oversized textures). let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE); let factor = doc.size.width.max(doc.size.height).div_ceil(max_side).max(1); @@ -326,14 +529,17 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio && c.revision == revision && c.preview_key == preview_key { - return Some(c.tex.id()); + return c.tex.as_ref().map(|t| t.id()); } + // The display transform itself is looked up only when the view is redrawn. + let screen = if inks { Screen::of(app, &doc, display) } else { Screen::default() }; let damage = app.channel_views.get(&id).and_then(|c| crate::canvas::damage_since(app, idx, (c.revision, c.preview_key), (revision, preview_key), 0, last_damage)); if let Some(c) = app.channel_views.get_mut(&id) && c.key == key && c.factor == factor && c.valid + && let Some(tex) = c.tex.as_mut() && let Some(d) = damage { // Re-render just the damage, snapped to the sampling grid so a downsampled texture @@ -341,26 +547,48 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio let f = factor as i32; let d = d.intersect(&doc.bounds()); let r = Rect::new(d.x0.div_euclid(f) * f, d.y0.div_euclid(f) * f, d.x1, d.y1).intersect(&doc.bounds()); - if !r.is_empty() - && let Some(px) = render(&doc, &view, r, factor, look, active) - { - let img = egui::ColorImage::new([r.width().div_ceil(factor) as usize, r.height().div_ceil(factor) as usize], px); - c.tex.set_partial([(r.x0 / f) as usize, (r.y0 / f) as usize], img, TextureOptions::LINEAR); + if !r.is_empty() { + let size = [r.width().div_ceil(factor) as usize, r.height().div_ceil(factor) as usize]; + // Nothing to draw there (the ink erased) still clears what the texture showed. + let px = render_with_source(&doc, &view, r, factor, look, active, CompositeSource { cached: None, fresh: &mut None, screen: &screen }) + .unwrap_or_else(|| vec![Color32::TRANSPARENT; size[0] * size[1]]); + tex.set_partial([(r.x0 / f) as usize, (r.y0 / f) as usize], egui::ColorImage::new(size, px), TextureOptions::LINEAR); } + let tex_id = tex.id(); c.revision = revision; c.preview_key = preview_key; - return Some(c.tex.id()); + return Some(tex_id); } let b = doc.bounds(); let cached = app.channel_views.get(&id).and_then(|c| c.source.as_ref()).map(|s| s.pixels.as_slice()); let mut fresh = None; - let px = render_with_source(&doc, &view, b, factor, look, active, CompositeSource { cached, fresh: &mut fresh })?; + let px = render_with_source(&doc, &view, b, factor, look, active, CompositeSource { cached, fresh: &mut fresh, screen: &screen }); + let Some(px) = px else { + // Nothing to draw (visible spot channels without ink): remembered, so the next frames + // don't look again, and the texture is dropped. + match app.channel_views.get_mut(&id) { + Some(c) => { + c.tex = None; + c.revision = revision; + c.preview_key = preview_key; + c.key = key; + c.factor = factor; + c.valid = true; + } + None => { + app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex: None, source: None }); + } + } + return None; + }; let new_source = fresh.is_some(); let size = [b.width().div_ceil(factor) as usize, b.height().div_ceil(factor) as usize]; let img = egui::ColorImage::new(size, px); match app.channel_views.get_mut(&id) { - Some(c) if c.tex.size() == size => { - c.tex.set(img, TextureOptions::LINEAR); + Some(c) if c.tex.as_ref().is_some_and(|t| t.size() == size) => { + if let Some(t) = c.tex.as_mut() { + t.set(img, TextureOptions::LINEAR); + } c.revision = revision; c.preview_key = preview_key; c.key = key; @@ -372,8 +600,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio } _ => { let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR); - let source = fresh.map(|pixels| SampledComposite { revision, content, preview_key, factor, pixels }); - app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex, source }); + // A composite still current (checked above) is kept when none was rendered. + let kept = app.channel_views.remove(&id).and_then(|c| c.source); + let source = fresh.map(|pixels| SampledComposite { revision, content, preview_key, factor, pixels }).or(kept); + app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex: Some(tex), source }); } } if new_source { @@ -383,7 +613,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio } } } - app.channel_views.get(&id).map(|c| c.tex.id()) + app.channel_views.get(&id).and_then(|c| c.tex.as_ref()).map(|t| t.id()) } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/channel_view/tests.rs b/crates/ui-egui/src/canvas/channel_view/tests.rs index d8581ebbbb..fa5d84e186 100644 --- a/crates/ui-egui/src/canvas/channel_view/tests.rs +++ b/crates/ui-egui/src/canvas/channel_view/tests.rs @@ -41,7 +41,7 @@ fn channel_eyes_reuse_composite_until_pixels_change() { app.sync_views(); let ctx = egui::Context::default(); app.run("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let id = app.session.active().unwrap().doc.id.0; let first = app.channel_views.get_mut(&id).unwrap().source.as_mut().unwrap(); let original_sample = first.pixels[0]; @@ -49,19 +49,19 @@ fn channel_eyes_reuse_composite_until_pixels_change() { first.pixels[0] = [0.0, 1.0, 0.0, 1.0]; app.run("channel.setVisible", json!({"channel": "green", "visible": false})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); assert_eq!(reused.pixels[0], [0.0, 1.0, 0.0, 1.0], "visibility does not recompose the pixels"); app.run("edit.fill", json!({"color": "#e02010"})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); // A partial damage upload discards the source; a full redraw may replace it. // In either case, cached samples must never refer to the pre-edit pixels. if let Some(source) = app.channel_views.get(&id).unwrap().source.as_ref() { assert_ne!(source.pixels[0], original_sample, "a full redraw replaces stale pixels"); } app.run("channel.setVisible", json!({"channel": "red", "visible": true})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let refreshed = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); let st = app.session.active().unwrap(); assert_eq!(refreshed.revision, st.revision); @@ -75,7 +75,7 @@ fn channel_eyes_reuse_composite_until_pixels_change() { 1, Look::default(), st.active_layer, - CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused }, + CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused, screen: &Screen::default() }, ) .unwrap(); assert_eq!(actual, expected, "reused pixels must preserve channel colours"); @@ -285,15 +285,15 @@ fn ensure_honours_show_channels_in_color() { app.sync_views(); app.run("channel.target", json!({"channel": "green"})).unwrap(); let ctx = egui::Context::default(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let key_off = app.channel_views.values().next().unwrap().key; app.session.execute("prefs.set", json!({"values": {"interface.showChannelsInColor": true}})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let key_on = app.channel_views.values().next().unwrap().key; assert_ne!(key_off, key_on, "the preference changes the cached view key"); // So do the Layer Mask Display Options. app.session.execute("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 70})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); assert_ne!(app.channel_views.values().next().unwrap().key, key_on, "the overlay options change the cached view key"); } @@ -349,3 +349,426 @@ fn rgb_off_shows_alpha_quick_mask_or_the_layer_mask() { let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::BLACK, "the hidden mask reads black, not the red layer"); } + +/// A white-backed `mode`/`depth` document, 3×1, with a 0.8/0.4/0.2 picture and two overlapping +/// spot inks (`a` covers 1, ½, 0 of the pixels; `b` the reverse). +fn spot_doc(mode: ColorMode, depth: photocraft_color::SampleType, sa: f32, sb: f32) -> Document { + use photocraft_color::{Color, PixelFormat}; + let mut doc = Document::with_background("spots", photocraft_geom::Size::new(3, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + for (name, rgb, s, vals) in [("a", [0.0, 0.6, 0.65], sa, [1.0, 0.5, 0.0]), ("b", [0.9, 0.2, 0.5], sb, [0.0, 0.5, 1.0])] { + let mut sf = Surface::new(fmt); + sf.write_region(Rect::new(0, 0, 3, 1), &vals); + doc.channels.push(AlphaChannel { spot: Some((Color::rgb(rgb[0], rgb[1], rgb[2]), s)), ..AlphaChannel::new(name, sf) }); + } + doc +} + +/// Visible spot channels of RGB and CMYK documents print over the composite as inks (#10249), +/// the same as the compositor's overprint, in panel order; not as a tint. +#[test] +fn spot_eyes_overprint_the_composite() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [photocraft_color::SampleType::U8, photocraft_color::SampleType::U16] { + for (sa, sb) in [(0.0, 0.0), (0.5, 1.0), (1.0, 0.5)] { + for swapped in [false, true] { + let mut doc = spot_doc(mode, depth, sa, sb); + if swapped { + doc.channels.reverse(); + } + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let px = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { rect: doc.bounds(), px: composite(&doc, doc.bounds(), 1) }; + photocraft_compose::multichannel::overprint(&mut want, &doc, |_| true); + for (x, (got, w)) in px.iter().zip(&want.px).enumerate() { + assert_eq!(got.a(), 255, "{mode:?} {depth:?}: opaque"); + let w = [q(w[0]), q(w[1]), q(w[2])]; + assert_eq!([got.r(), got.g(), got.b()], w, "{mode:?} {depth:?} s {sa}/{sb} swapped {swapped} x {x}"); + } + // Only the visible ink prints; where it has none the canvas shows through. + v.set_alpha_visible(1, false); + let one = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { rect: doc.bounds(), px: composite(&doc, doc.bounds(), 1) }; + photocraft_compose::multichannel::overprint(&mut want, &doc, |i| i == 0); + let (solid, bare) = if swapped { (2, 0) } else { (0, 2) }; + let w = want.px[solid]; + assert_eq!([one[solid].r(), one[solid].g(), one[solid].b()], [q(w[0]), q(w[1]), q(w[2])]); + assert_eq!(one[bare], Color32::TRANSPARENT); + } + } + } + } +} + +/// Only inked pixels replace the canvas: where every visible spot is 0 the overlay stays +/// transparent, so the normal (colour-managed, full-resolution) canvas shows there, and an empty +/// spot channel draws nothing and renders no composite at all. +#[test] +fn uninked_pixels_leave_the_canvas_alone() { + for factor in [1, 2] { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + doc.size = photocraft_geom::Size::new(4, 2); + let fmt = doc.channels[0].surface.format(); + let mut sparse = Surface::new(fmt); + sparse.write_pixel(0, 0, &[1.0]); + doc.channels[0].surface = sparse; + doc.channels[1].surface = Surface::new(fmt); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let px = render(&doc, &v, doc.bounds(), factor, Look::default(), None).unwrap(); + assert_eq!(px[0], Color32::from_rgb(0, q(0.6), q(0.65)), "/{factor}: solid teal where the ink is"); + assert!(px[1..].iter().all(|p| *p == Color32::TRANSPARENT), "/{factor}: {px:?}"); + // Only the empty spot shown: nothing to print, and no composite rendered for it. + v.set_alpha_visible(0, false); + let mut fresh = None; + let px = render_with_source( + &doc, + &v, + doc.bounds(), + factor, + Look::default(), + None, + CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }, + ); + assert!(px.is_none(), "/{factor}: an empty spot draws no texture"); + assert!(fresh.is_none(), "/{factor}: an empty spot needs no composite"); + } +} + +/// A few inked pixels composite only their bounding box, not the whole document, and print the +/// same as over the whole composite; ink everywhere renders the whole one, kept for the cache. +#[test] +fn sparse_inks_composite_only_their_bounding_box() { + use photocraft_doc::{Layer, LayerContent}; + for factor in [1, 2, 3] { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 0.5, 1.0); + doc.size = photocraft_geom::Size::new(60, 40); + let mut ramp = Surface::new(doc.pixel_format()); + for y in 0..40 { + for x in 0..60 { + ramp.write_pixel(x, y, &[x as f32 / 59.0, y as f32 / 39.0, 0.5, 1.0]); + } + } + doc.layers.push(Layer::new("ramp", LayerContent::Raster(ramp))); + let fmt = doc.channels[0].surface.format(); + let mut sparse = Surface::new(fmt); + sparse.write_pixel(12, 0, &[1.0]); + sparse.write_pixel(18, 6, &[0.5]); + doc.channels[0].surface = sparse; + doc.channels[1].surface = Surface::new(fmt); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let b = doc.bounds(); + let mut fresh = None; + let source = CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }; + let got = render_with_source(&doc, &v, b, factor, Look::default(), None, source).unwrap(); + assert!(fresh.is_none(), "/{factor}: two inked pixels render no whole composite"); + let whole = composite_sampled(&doc, b, factor); + let ink = photocraft_compose::multichannel::Ink::of(&doc, &doc.channels[0]); + let w = 60usize.div_ceil(factor as usize); + let f = factor as usize; + let inked = [(12 / f, 0), (18 / f, 6 / f)]; + for (k, px) in got.iter().enumerate() { + let Some(&(x, y)) = inked.iter().find(|&&(x, y)| y * w + x == k) else { + assert_eq!(*px, Color32::TRANSPARENT, "/{factor} px {k}"); + continue; + }; + let mut want = [whole[k]]; + ink.apply(&mut want, [doc.channels[0].surface.pixel((x * f) as i32, (y * f) as i32)[0]]); + let want = Color32::from_rgb(q(want[0][0]), q(want[0][1]), q(want[0][2])); + assert_eq!(*px, want, "/{factor} ({x}, {y})"); + } + // Ink everywhere: the bounding box is the whole document, rendered once for the cache. + let mut solid = Surface::new(fmt); + solid.fill_rect(b, &[1.0]); + doc.channels[1].surface = solid; + let mut fresh = None; + let source = CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }; + assert!(render_with_source(&doc, &v, b, factor, Look::default(), None, source).is_some()); + assert!(fresh.is_some(), "/{factor}: ink everywhere renders the whole composite"); + } +} + +/// A visible spot channel without ink draws no texture over the canvas, and remembers that; a +/// spot with ink shown later draws one. +#[test] +fn uninked_spot_draws_no_texture() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 8, "background": "white"})).unwrap(); + // No selection: an empty spot channel, shown over the composite. + app.run("channel.newSpot", json!({"color": "#00ffff", "solidity": 0})).unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().doc.id.0; + let st = app.session.active().unwrap(); + assert!(prints_inks(&st.doc, &st.channel_view), "the composite and the spot are both shown"); + assert!(ensure(&mut app, &ctx, 0, None).is_none(), "no ink: nothing drawn"); + assert!(app.channel_views.get(&id).is_some_and(|c| c.tex.is_none() && c.valid), "and remembered"); + assert!(ensure(&mut app, &ctx, 0, None).is_none()); + // A second spot made from a selection has ink: the texture shows it. + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + app.run("channel.newSpot", json!({"color": "#ff00ff", "solidity": 0})).unwrap(); + assert!(ensure(&mut app, &ctx, 0, None).is_some(), "ink: drawn"); + assert!(app.channel_views.get(&id).is_some_and(|c| c.tex.is_some())); +} + +/// Printed inks go through the canvas display transform (monitor profile, Proof Colors), as the +/// canvas around them does. +#[test] +fn printed_inks_follow_the_display_transform() { + use photocraft_cms::{Builtin, TransformOptions}; + let doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + let plain = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let t = photocraft_cms::transform::cached(Builtin::Srgb.profile(), Builtin::DisplayP3.profile(), TransformOptions::default()).unwrap(); + let screen = Screen { encode_srgb: false, transform: Some(t.clone()) }; + let shown = + render_with_source(&doc, &v, doc.bounds(), 1, Look::default(), None, CompositeSource { cached: None, fresh: &mut None, screen: &screen }).unwrap(); + // x 0: solid teal; x 2: no ink of channel 0, so transparent either way. + let mut teal = [0.0f32, 0.6, 0.65, 1.0]; + t.apply(&mut teal, 4); + assert_ne!(plain[0], shown[0]); + assert_eq!(shown[0], Color32::from_rgb(q(teal[0]), q(teal[1]), q(teal[2]))); + assert_eq!((plain[2], shown[2]), (Color32::TRANSPARENT, Color32::TRANSPARENT)); +} + +/// A linear (32-bit) document's composite is sRGB-encoded on its way to the screen. Its inks are +/// sRGB colours converted into the linear profile, so a solid ink shows its stored colour, not a +/// brightened one. +#[test] +fn printed_inks_keep_their_colour_in_a_linear_document() { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::F32, 1.0, 1.0); + doc.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + let encoded = Screen { encode_srgb: true, transform: None }; + let shown = + render_with_source(&doc, &v, doc.bounds(), 1, Look::default(), None, CompositeSource { cached: None, fresh: &mut None, screen: &encoded }).unwrap(); + let (got, want) = ([shown[0].r(), shown[0].g(), shown[0].b()], [0, q(0.6), q(0.65)]); + assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1), "{got:?} vs the stored {want:?}"); +} + +/// Alpha channels and Quick Mask still tint, over the printed inks. +#[test] +fn alpha_tint_draws_over_the_inks() { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + let mut sel = Surface::new(photocraft_color::PixelFormat::new(ColorMode::Grayscale, photocraft_color::SampleType::U8, false)); + sel.write_region(Rect::new(0, 0, 3, 1), &[0.0, 0.0, 0.0]); + doc.channels.push(AlphaChannel::new("Alpha 1", sel)); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(2, true); + let px = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + // Solid opaque teal at x 0, then the default red rubylith at 50 % over it. + let teal = [0.0f32, 0.6, 0.65]; + let red = AlphaChannel::DEFAULT_COLOR.to_rgb(); + let want: Vec = (0..3).map(|j| q(red[j] * 0.5 + teal[j] * 0.5)).collect(); + assert_eq!(vec![px[0].r(), px[0].g(), px[0].b()], want); +} + +/// Editing a spot (colour, solidity, visibility) or the picture under it refreshes the canvas texture. +#[test] +fn spot_edits_refresh_the_overlay() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 8, "background": "white"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#00ffff", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().doc.id.0; + let pixel = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).map(|px| px[0]) + }; + assert!(ensure(&mut app, &ctx, 0, None).is_some()); + assert_eq!(pixel(&app), Some(Color32::from_rgb(0, 255, 255)), "solid transparent cyan over white"); + let edits = [ + ("channel.options", json!({"channel": spot, "color": "#ff00ff"})), + // A gray picture first: a solid ink over white looks the same at any solidity. + ("edit.fill", json!({"color": "#808080"})), + ("channel.options", json!({"channel": spot, "solidity": 100})), + ("channel.setVisible", json!({"channel": spot, "visible": false})), + ]; + let mut last = pixel(&app); + for (cmd, params) in edits { + app.run(cmd, params.clone()).unwrap(); + let shown = ensure(&mut app, &ctx, 0, None); + let now = pixel(&app); + assert_ne!(now, last, "{cmd} {params}"); + let revision = app.session.active().unwrap().revision; + match shown { + Some(_) => assert_eq!(app.channel_views.get(&id).unwrap().revision, revision, "{cmd} {params}"), + None => assert!(now.is_none(), "a hidden spot leaves the plain composite"), + } + last = now; + } +} + +/// The first draw of a big document composites a proxy, not the full canvas (#10249): it must +/// pick the same pixels the full-resolution composite gives, in RGB and CMYK at 8 and 16 bit, +/// with the inks printed over it the same way. +#[test] +fn proxy_composite_matches_the_full_resolution_one() { + use photocraft_color::{BlendMode, SampleType}; + use photocraft_doc::{Layer, LayerContent, LayerMask}; + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + for factor in [2, 3, 4] { + let mut doc = spot_doc(mode, depth, 0.5, 1.0); + doc.size = photocraft_geom::Size::new(37, 23); + let mut s = Surface::new(doc.pixel_format()); + for y in 0..23 { + for x in 0..37 { + let (fx, fy) = (x as f32 / 36.0, y as f32 / 22.0); + s.write_pixel(x, y, &[fx, fy, (fx * fy * 7.0).fract(), 0.3 + 0.7 * fy]); + } + } + let mut layer = Layer::new("pattern", LayerContent::Raster(s)); + layer.blend = BlendMode::Multiply; + layer.opacity = 0.8; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(5, 3, 20, 15), &[0.25]); + layer.mask = Some(mask); + doc.layers.push(layer); + for ch in &mut doc.channels { + let mut sf = Surface::new(ch.surface.format()); + for y in 0..23 { + for x in 0..37 { + sf.write_pixel(x, y, &[((x * 3 + y * 5) % 11) as f32 / 10.0]); + } + } + ch.surface = sf; + } + assert!(proxy_matches(&doc)); + let b = doc.bounds(); + let (proxy, full) = (composite(&doc, b, factor), composite_sampled(&doc, b, factor)); + assert_eq!(proxy.len(), full.len()); + for (i, (p, f)) in proxy.iter().zip(&full).enumerate() { + assert!(p.iter().zip(f).all(|(p, f)| (p - f).abs() < 1e-4), "{mode:?} {depth:?} /{factor} px {i}: {p:?} != {f:?}"); + } + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let got = render(&doc, &v, b, factor, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { + rect: Rect::new(0, 0, 37usize.div_ceil(factor as usize) as i32, 23usize.div_ceil(factor as usize) as i32), + px: full, + }; + let mut small = doc.clone(); + small.channels.iter_mut().for_each(|c| c.surface = photocraft_compose::proxy::downsample(&c.surface, factor)); + photocraft_compose::multichannel::overprint(&mut want, &small, |_| true); + let sw = want.rect.width(); + let inked = |i: usize| { + let (x, y) = ((i as u32 % sw) as i32, (i as u32 / sw) as i32); + small.channels.iter().any(|c| c.surface.pixel(x, y).first().is_some_and(|v| *v > 0.0)) + }; + for (i, (g, w)) in got.iter().zip(&want.px).enumerate() { + let w = if inked(i) { Color32::from_rgba_premultiplied(q(w[0]), q(w[1]), q(w[2]), 255) } else { Color32::TRANSPARENT }; + assert!(g.to_array().iter().zip(w.to_array()).all(|(a, b)| a.abs_diff(b) <= 1), "{mode:?} {depth:?} /{factor} px {i}: {g:?} != {w:?}"); + } + } + } + } +} + +/// Feathered masks blur by document pixels, which a proxy would not scale: those documents keep +/// the full-resolution composite. +#[test] +fn feathered_masks_keep_the_full_resolution_composite() { + use photocraft_doc::{Layer, LayerContent, LayerMask}; + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + assert!(proxy_matches(&doc)); + let mut mask = LayerMask::reveal_all(); + mask.feather = 4.0; + let mut inner = Layer::new("inner", LayerContent::Raster(Surface::new(doc.pixel_format()))); + inner.mask = Some(mask); + doc.layers.push(Layer::group("group", vec![inner])); + assert!(!proxy_matches(&doc)); + // Hidden layers are not composited: a hidden group (or a hidden layer in it) doesn't count. + let last = doc.layers.len() - 1; + doc.layers[last].visible = false; + assert!(proxy_matches(&doc)); + doc.layers[last].visible = true; + if let LayerContent::Group(g) = &mut doc.layers[last].content { + g.children[0].visible = false; + } + assert!(proxy_matches(&doc)); +} + +/// A pattern fill's tiles keep their size on a proxy, so it would sample other pattern pixels: +/// such documents keep the full-resolution composite unless the fill's own cache is shrunk with +/// the proxy. +#[test] +fn uncached_pattern_fills_keep_the_full_resolution_composite() { + use photocraft_doc::{Fill, FillCache, Layer, LayerContent}; + let pattern = Fill::Pattern { name: "p".into(), scale: 1.0, id: "p".into(), angle: 0.0, link: true, phase: (0.0, 0.0) }; + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + doc.layers.push(Layer::group("group", vec![Layer::new("pattern", LayerContent::Fill(pattern.clone()))])); + assert!(!proxy_matches(&doc)); + let mut stale = Layer::new("pattern", LayerContent::Fill(pattern.clone())); + stale.fill_cache = Some(FillCache { fill: Fill::Solid(photocraft_color::Color::WHITE), surface: Surface::new(doc.pixel_format()) }); + doc.layers.pop(); + doc.layers.push(stale); + assert!(!proxy_matches(&doc)); + let mut cached = Layer::new("pattern", LayerContent::Fill(pattern.clone())); + cached.fill_cache = Some(FillCache { fill: pattern, surface: Surface::new(doc.pixel_format()) }); + doc.layers.pop(); + doc.layers.push(cached); + assert!(proxy_matches(&doc)); +} + +/// Solo spot channel view is grayscale and updates with edits on an RGB or CMYK document with +/// no layer mask. +#[test] +fn solo_spot_is_grayscale_and_edits_without_layer_mask() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + let mode_str = if mode == ColorMode::Rgb { "rgb" } else { "cmyk" }; + app.run("file.new", json!({"width": 8, "height": 4, "mode": mode_str})).unwrap(); + // Plain document has a background layer and no layer mask. + assert!(app.session.active().unwrap().doc.layers.iter().all(|l| l.mask.is_none())); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#ff0000", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + // Target the spot channel alone (Channels panel row click). + app.run("channel.target", json!({"channel": spot})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.channel_view.visible_colors(color_count(&st.doc)), 0); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer) + .expect("solo spot channel must render without a layer mask"); + // Selected half (x 0..4) has ink (1.0 -> white in grayscale tone), unselected half is black (0.0). + assert_eq!(px[0], Color32::WHITE, "{mode:?}: inked half is white tone"); + assert_eq!(px[7], Color32::BLACK, "{mode:?}: uninked half is black tone"); + + // Editing the targeted spot channel updates the solo preview. + app.run("select.rect", json!({"x": 4, "y": 0, "width": 4, "height": 4})).unwrap(); + app.run("edit.fill", json!({"color": "#ffffff"})).unwrap(); + let st = app.session.active().unwrap(); + let px2 = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).expect("edited spot channel must render"); + assert_eq!(px2[7], Color32::WHITE, "{mode:?}: newly filled area is now white"); + } +} + +#[test] +fn solo_spot_wins_over_an_unrelated_layer_mask() { + for mode in ["rgb", "cmyk"] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 4, "mode": mode})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#ff0000", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + app.run("channel.target", json!({"channel": spot})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.channel_view.visible_colors(color_count(&st.doc)), 0); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).expect("solo spot renders"); + // A revealed layer mask is all white; the spot has ink on the left half only. + assert_eq!(px[0], Color32::WHITE, "{mode}: inked half"); + assert_eq!(px[7], Color32::BLACK, "{mode}: uninked half shows the spot, not the mask"); + } +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6f63a2d200..7882fc43c9 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -143,7 +143,7 @@ fn a_channel_overlay_follows_a_size_changing_preview() { canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), view.clone(), true); }); output.textures_delta.clear(); - let overlay = crate::channel_view::ensure(&mut app, &ctx, 0).expect("the channel overlay"); + let overlay = crate::channel_view::ensure(&mut app, &ctx, 0, None).expect("the channel overlay"); let mesh = output .shapes .iter() @@ -1729,7 +1729,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { app.ui.tool = Tool::Brush; let ctx = egui::Context::default(); ctx.input_mut(|i| i.max_texture_side = 256); - let tex = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let tex = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let revision = app.session.documents()[0].revision; let steps = app.session.documents()[0].history.past_len(); @@ -1740,7 +1740,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { tool_event(&mut app, ToolEvent::Move { x, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let shown = display_doc(&mut app, 0).0; assert!(shown.layer(layer).unwrap().mask.as_ref().unwrap().value(x as i32, 30) < 0.1); - assert_eq!(crate::channel_view::ensure(&mut app, &ctx, 0), Some(tex)); + assert_eq!(crate::channel_view::ensure(&mut app, &ctx, 0, None), Some(tex)); let mut delta = ctx.tex_manager().write().take_delta(); let uploads = delta.set.get(&tex).unwrap_or_else(|| panic!("{depth}-bit {mode}: live mask texture must upload before release at {x}")); assert_eq!(uploads.len(), 1); @@ -1765,7 +1765,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { assert_eq!(app.session.documents()[0].revision, revision); assert_eq!(app.session.documents()[0].history.past_len(), steps); assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); assert!(ctx.tex_manager().write().take_delta().set.is_empty(), "idle frame does not upload"); } tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, egui::Modifiers::NONE); @@ -1773,7 +1773,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { assert_eq!(st.history.past_len(), steps + 1); assert!(st.doc.layer(layer).unwrap().mask.as_ref().unwrap().value(50, 30) < 0.1); assert_eq!(st.doc.layer(layer).unwrap().content, original.content); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); // The commit takes the live stroke's pixels, which the texture already shows: at // most a partial upload, never the whole mask again. @@ -1781,7 +1781,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { delta.clear(); app.session.undo(); assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).unwrap()[0]; let egui::ImageData::Color(image) = &upload.image; @@ -1826,14 +1826,14 @@ fn channel_views_show_live_targets_and_discarded_strokes() { app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "smoothing": {"amount": 0.0}}})).unwrap(); app.ui.tool = Tool::Brush; let ctx = egui::Context::default(); - let tex = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let tex = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let original = app.session.documents()[0].doc.clone(); let steps = app.session.documents()[0].history.past_len(); tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let shown = display_doc(&mut app, 0).0; - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).expect("live channel upload")[0]; let pos = upload.pos.expect("partial channel upload"); @@ -1872,7 +1872,7 @@ fn channel_views_show_live_targets_and_discarded_strokes() { // document revision did not change. There is no committed damage rectangle to use. app.drag = None; app.live_stroke = None; - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).unwrap()[0]; assert!(upload.pos.is_none(), "discarding a preview refreshes the view"); diff --git a/docs/gaps.md b/docs/gaps.md index 2044af01d4..70e41e698d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -392,10 +392,13 @@ what an agent can't finish alone. write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), DICOM, EPS/DCS, Pixar, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; spot plates beyond PSD and TIFF: spot ink preview in RGB/CMYK documents and PDF Separation - (FILE-215-17); .atn, + Raw; spot plates beyond PSD and TIFF: PDF Separation (FILE-215-17); .atn, .shc, .csh presets (.asl landed 2026-10-20) (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-30, #10249):** visible spot channels of RGB and CMYK documents print over + the canvas composite as inks (the Multichannel overprint formula, in Channels panel order), open + visible, and print in the PSD thumbnail; flatten, export and the merged image stay free of them. + Open: ink order at overlaps and the look at solidity 0.5 and 1 against reference files. - **Progress (2026-10-23, #7415 split):** PSD stores and reads spot channels as Photoshop does (ink 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.16.0, which stored spot ink the other way round, keep their ink. @@ -477,6 +480,8 @@ what an agent can't finish alone. | 2026-10-28 | minor | G2: prepare the GPU compositor in the background; startup performance baseline pending (#9157) | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-11-01 | minor | G9: 11 of 183 preferences do nothing (was 12): Graphics Adapter (`performance.gpuAdapter`) always worked, but startup read it by raw JSON key, which the scorecard prefs audit can't see; it now reads the typed Performance section; no estimate changed | +| 2026-10-30 | minor | G10: visible spot channels of RGB and CMYK documents overprint the canvas composite and the PSD thumbnail, and open visible (#10249); FILE-215-17 stays partial, narrowed to PDF Separation; estimate unchanged | +| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | Frame Tool interaction and persistence work (#8864); documented remaining native Photoshop frame metadata and independent-oracle limits; no percentage changed | diff --git a/docs/scorecard.md b/docs/scorecard.md index c225a85a94..bf6ebcfb9b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -145,7 +145,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. RGB/CMYK documents preview visible spot channels as inks printed over the canvas composite (compose multichannel::overprint, the Multichannel formula, in Channels panel order; ui-egui canvas/channel_view.rs), open with spot eyes on, and print them in the PSD thumbnail (multichannel::thumbnail_with_inks); flatten, export and the merged image stay free of them (#10249). Missing: PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 539dbc7e59..f693728b8a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -175,7 +175,7 @@ parity for the area and are already contained in the dimension rows above. | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | -| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking; no per-camera colour for non-DNG cameras); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; recording uses implicit active layers, unique layer names, whole-text Type steps, bundled Move-tool nudges, relative keyboard document switches and canvas-relative point text (AUTO-219-1, #4514; duplicate names and paragraph boxes remain open); Actions panel remains partial (AUTO-219-2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | @@ -597,6 +597,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | 2026-11-01 | minor | Automation Batch: recursive folders, open documents, extended naming, stop/error log and folder-job progress/cancel; AUTO-219-5 done (#4579); percentages and estimates unchanged | | 2026-11-01 | minor | UI / UX and Workspace rows: 11 of 183 preferences do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see. Percentages and estimates unchanged | | 2026-10-30 | minor | Art History Brush Opacity on the options bar, #10263; no estimate changed | +| 2026-10-30 | minor | Colour management: RGB and CMYK spot channels preview as inks over the composite and in the PSD thumbnail (#10249); FILE-215-17 narrows to PDF Separation; percentages unchanged | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | Frame Tool interaction and persistence work (#8864); documented remaining native Photoshop frame metadata and independent-oracle limits; no percentage changed | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 3a762050a7..e8195e6b51 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -122,7 +122,7 @@ id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" status = "partial" issue = 215 -note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. RGB/CMYK documents preview visible spot channels as inks printed over the canvas composite (compose multichannel::overprint, the Multichannel formula, in Channels panel order; ui-egui canvas/channel_view.rs), open with spot eyes on, and print them in the PSD thumbnail (multichannel::thumbnail_with_inks); flatten, export and the merged image stay free of them (#10249). Missing: PDF Separation" [[item]] id = "FILE-215-18" From e59dcc9c4496328217be95579707cc5f9b4debb0 Mon Sep 17 00:00:00 2001 From: RickiNano <81099017+RickiNano@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:32:49 +0100 Subject: [PATCH 59/63] GPU plan: cache adjustment LUT rows between plans (#11498) (#11539) * GPU plan: cache adjustment LUT rows between plans (#11498) adjustment_program rebuilt the Levels/Curves tone tables on every plan (twice per refresh), about 1-2 ms per Levels layer each time. Keep the rows of Levels, Curves, Color Balance and Hue/Saturation ranges in a 32-entry most-recently-used cache (photocraft_compose::lut_rows, at most 32 x 3 x 16 KiB = 1.5 MiB), built outside the lock. Entries are keyed by a u64 hash of exactly what each row builder reads, fields destructured in full and floats by bits (-0.0 and 0.0 stay apart). Hue/Saturation master sliders don't add entries; Hue/Sat ranges and Color Balance don't depend on depth. Edit > Purge > All drops the cache, and a filled cache enables it. Release, per call: Levels 8/16-bit 1.10 -> 0.003-0.018 ms, Curves 0.16 -> 0.018, Color Balance 0.11 -> 0.001-0.015, Hue/Sat ranges 0.05 -> 0.008-0.018. The tables are identical, so rendering is unchanged. Tests: cached rows match fresh tables bit for bit at 8/16/32-bit, hits and LRU eviction on a local cache, key coverage, and Purge All releasing the cache. Co-Authored-By: Claude Opus 5.5 * compose: rustfmt the LUT row cache tests (#11498) Co-Authored-By: Claude Opus 5.5 * GPU plan: test that plans read cached LUT rows and that curve points key them (#11498) Plant rows no builder makes under a fresh key and check the planner returns them, and check that moving a Curves point changes its key (review on #11539). Co-Authored-By: Claude Opus 5.5 * compose: prevent in-flight LUT builds from undoing purge --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps --- crates/compose/src/lib.rs | 1 + crates/compose/src/lut_rows.rs | 197 ++++++++++++++++++ crates/engine/src/cmd/edit_menu_cmds/purge.rs | 6 + crates/engine/tests/lut_row_cache_purge.rs | 30 +++ crates/gpu/src/plan/lower.rs | 73 ++++++- crates/gpu/src/plan/tests.rs | 127 +++++++++++ 6 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 crates/compose/src/lut_rows.rs create mode 100644 crates/engine/tests/lut_row_cache_purge.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9364806c55..e93e0818f3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -24,6 +24,7 @@ pub mod effects; pub mod fill_layout; pub mod filter_mask; pub mod gradient_fill; +pub mod lut_rows; pub mod masks; pub mod multichannel; pub mod native; diff --git a/crates/compose/src/lut_rows.rs b/crates/compose/src/lut_rows.rs new file mode 100644 index 0000000000..b0e525e786 --- /dev/null +++ b/crates/compose/src/lut_rows.rs @@ -0,0 +1,197 @@ +//! Adjustment LUT rows (4096 entries each) kept between renders, keyed by a hash of what they're +//! built from. The GPU planner rebuilt the Levels tables on every plan, 1–2 ms per layer (#11498); +//! callers hash exactly the builder's inputs, so settings that don't change the rows share an +//! entry. Most recently used first, at most [`CAPACITY`] entries +//! (32 × 3 rows × 16 KiB = 1.5 MiB); [`purge_cache`] empties it (Edit › Purge › All). + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Entries in one LUT row. +pub const ROW_LEN: usize = 4096; + +/// The LUT rows of one adjustment state. +pub type Rows = Vec<[f32; ROW_LEN]>; + +/// Adjustment states the shared cache remembers. +pub const CAPACITY: usize = 32; + +/// A most-recently-used list of row sets by key. +#[derive(Debug)] +pub struct RowCache { + entries: VecDeque<(u64, Arc)>, + capacity: usize, + generation: u64, +} + +impl RowCache { + /// An empty cache holding at most `capacity` row sets. + pub fn new(capacity: usize) -> Self { + Self { entries: VecDeque::new(), capacity, generation: 0 } + } + + /// The rows stored for `key`, now the most recently used. + fn get(&mut self, key: u64) -> Option> { + let entry = self.entries.iter().position(|(k, _)| *k == key).and_then(|i| self.entries.remove(i))?; + let rows = Arc::clone(&entry.1); + self.entries.push_front(entry); + Some(rows) + } + + /// Store `rows` for `key` (unless another builder got there first), dropping the oldest entry + /// when full. + fn insert(&mut self, key: u64, rows: Arc) { + if !self.entries.iter().any(|(k, _)| *k == key) { + self.entries.push_front((key, rows)); + self.entries.truncate(self.capacity); + } + } + + /// Row sets held. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether nothing is held. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Bytes of rows held. + pub fn bytes(&self) -> usize { + self.entries.iter().map(|(_, r)| r.len().saturating_mul(std::mem::size_of::<[f32; ROW_LEN]>())).sum() + } + + /// Drop every entry, returning the bytes they held. + pub fn clear(&mut self) -> usize { + let freed = self.bytes(); + self.entries.clear(); + self.generation = self.generation.wrapping_add(1); + freed + } +} + +/// The rows for `key` in `cache`, built by `build` only when it doesn't hold them. `build` runs +/// outside the lock, so other renders never wait on it. +pub fn cached_in(cache: &Mutex, key: u64, build: impl FnOnce() -> Rows) -> Rows { + let (hit, generation) = { + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + (cache.get(key), cache.generation) + }; + if let Some(rows) = hit { + return rows.to_vec(); + } + let rows = build(); + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + // A purge must not be undone by a build that was already in flight. + if cache.generation == generation { + cache.insert(key, Arc::new(rows.clone())); + } + rows +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(RowCache::new(CAPACITY))) +} + +/// [`cached_in`] the shared cache. +pub fn cached(key: u64, build: impl FnOnce() -> Rows) -> Rows { + cached_in(cache(), key, build) +} + +/// Bytes of rows the shared cache holds. +pub fn cache_bytes() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).bytes() +} + +/// Drop every cached row set (Edit › Purge › All), returning the bytes they held. +pub fn purge_cache() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).clear() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + fn rows(v: f32) -> Rows { + vec![[v; ROW_LEN]; 3] + } + + #[test] + fn a_known_key_is_served_without_building() { + let cache = Mutex::new(RowCache::new(4)); + let builds = Cell::new(0); + let get = |key: u64, v: f32| { + cached_in(&cache, key, || { + builds.set(builds.get() + 1); + rows(v) + }) + }; + assert_eq!(get(1, 0.25)[2][7], 0.25); + assert_eq!(get(1, 0.75)[2][7], 0.25, "the stored rows, not a rebuild"); + assert_eq!(builds.get(), 1); + get(2, 0.5); + assert_eq!(builds.get(), 2); + } + + #[test] + fn the_least_recently_used_entry_is_dropped_first() { + let cache = Mutex::new(RowCache::new(CAPACITY)); + let builds = Cell::new(0); + let get = |key: u64| { + cached_in(&cache, key, || { + builds.set(builds.get() + 1); + rows(0.0) + }) + }; + // A slider drag through many states keeps only the most recent ones. + for key in 0..200 { + get(key); + } + assert_eq!(cache.lock().map(|c| c.len()).unwrap_or(0), CAPACITY); + assert_eq!(builds.get(), 200); + // Key 168 is the oldest kept: reading it makes 169 the oldest, which the next new key drops. + get(168); + get(1000); + assert_eq!(builds.get(), 201); + get(168); + assert_eq!(builds.get(), 201, "kept: it was used recently"); + get(169); + assert_eq!(builds.get(), 202, "dropped: the oldest when the cache was full"); + } + + #[test] + fn purge_discards_in_flight_builds() { + use std::sync::mpsc; + let cache = Mutex::new(RowCache::new(CAPACITY)); + let (started_tx, started_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + std::thread::scope(|scope| { + let cache_ref = &cache; + scope.spawn(move || { + cached_in(cache_ref, 123, || { + started_tx.send(()).unwrap(); + resume_rx.recv().unwrap(); + rows(0.5) + }) + }); + started_rx.recv().unwrap(); + cache.lock().unwrap().clear(); + resume_tx.send(()).unwrap(); + }); + assert!(cache.lock().unwrap().is_empty(), "a build started before purge must not refill the cache"); + } + + #[test] + fn clearing_reports_the_bytes_held() { + let mut cache = RowCache::new(CAPACITY); + cache.insert(1, Arc::new(rows(0.0))); + cache.insert(2, Arc::new(vec![[0.0; ROW_LEN]])); + assert_eq!(cache.bytes(), 4 * ROW_LEN * 4); + assert_eq!(cache.clear(), 4 * ROW_LEN * 4); + assert!(cache.is_empty()); + assert_eq!(cache.bytes(), 0); + } +} diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index b4e9c5cd27..5a9ce8c2c5 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -77,6 +77,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result { freed = freed.saturating_add(strokes); items.push("shape stroke cache"); } + let luts = photocraft_compose::lut_rows::purge_cache(); + if luts > 0 { + freed = freed.saturating_add(luts); + items.push("adjustment table cache"); + } } s.edit_state.fade = None; let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) }; @@ -102,6 +107,7 @@ pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> { || crate::smart_cmds::stack_cache_bytes() > 0 || crate::smart_cmds::source_cache_bytes() > 0 || photocraft_paint::mip_cache_bytes() > 0 + || photocraft_compose::lut_rows::cache_bytes() > 0 { Ok(()) } else { diff --git a/crates/engine/tests/lut_row_cache_purge.rs b/crates/engine/tests/lut_row_cache_purge.rs new file mode 100644 index 0000000000..18d78c0c9f --- /dev/null +++ b/crates/engine/tests/lut_row_cache_purge.rs @@ -0,0 +1,30 @@ +//! One integration-test process keeps its global adjustment-table cache isolated from parallel +//! unit tests. +use photocraft_compose::lut_rows::{self, ROW_LEN}; +use photocraft_engine::Session; +use serde_json::json; + +#[test] +fn purge_all_is_enabled_by_cached_adjustment_tables_and_releases_them() { + let mut session = Session::new(); + assert!(!session.is_enabled("edit.purge.all")); + let rows = lut_rows::cached(7, || vec![[0.5; ROW_LEN]; 3]); + let accounted = lut_rows::cache_bytes(); + assert_eq!(accounted, 3 * ROW_LEN * 4); + assert!(session.is_enabled("edit.purge.all"), "only the adjustment table cache enables purge"); + + let result = session.execute("edit.purge.all", json!({})).unwrap(); + assert_eq!(result["purged"], json!(["adjustment table cache"])); + assert_eq!(result["bytes"].as_u64(), Some(accounted as u64)); + assert_eq!(lut_rows::cache_bytes(), 0); + assert!(!session.is_enabled("edit.purge.all")); + + // The next render builds the tables again. + let mut built = false; + let again = lut_rows::cached(7, || { + built = true; + vec![[0.5; ROW_LEN]; 3] + }); + assert!(built); + assert_eq!(rows, again); +} diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index a38cdde867..202f0216e1 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -82,6 +82,68 @@ fn lut_rows(f: impl Fn(usize, f32) -> f32, rows: usize) -> Vec<[f32; 4096]> { type Program = (i32, [[f32; 4]; 4], Option>); +/// What the LUT rows of `adj` at `depth` are built from, hashed bit for bit (so `-0.0` and `0.0` +/// stay apart): every field the row builder reads and nothing else, so settings outside the +/// tables (Hue/Saturation's master sliders) don't add entries. The settings are destructured in +/// full (Hue/Saturation's builder takes only `ranges`), so a new field fails to compile here until +/// it is placed. `None`: rows aren't cached. +pub(super) fn row_key(adj: &Adjustment, depth: photocraft_color::SampleType) -> Option { + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + let level = |h: &mut std::collections::hash_map::DefaultHasher, c: &LevelsChannel| { + let LevelsChannel { in_black, in_white, gamma, out_black, out_white } = c; + [in_black, in_white, gamma, out_black, out_white].map(|v| v.to_bits()).hash(h); + }; + let curve = |h: &mut std::collections::hash_map::DefaultHasher, c: &[CurvePoint]| { + c.len().hash(h); + for CurvePoint { input, output } in c { + (input.to_bits(), output.to_bits()).hash(h); + } + }; + match adj { + // adjust::tone_luts_depth, which rounds to the depth's quantum. + Adjustment::Levels { master, per_channel, space, black } => { + (0u8, depth, *space as u8).hash(&mut h); + for c in [master, &per_channel[0], &per_channel[1], &per_channel[2], black] { + level(&mut h, c); + } + } + Adjustment::Curves { master, per_channel, space, black, pencil } => { + (1u8, depth, *space as u8, *pencil).hash(&mut h); + for c in [master, &per_channel[0], &per_channel[1], &per_channel[2], black] { + curve(&mut h, c); + } + } + // adjust::hue_range_tables reads only the ranges, at any depth. + Adjustment::HueSaturation { ranges, .. } => { + 2u8.hash(&mut h); + for HueRange { hue, saturation, lightness, bounds } in ranges { + [hue, saturation, lightness].map(|v| v.to_bits()).hash(&mut h); + bounds.map(f32::to_bits).hash(&mut h); + } + } + // adjust::BalanceCurve::rgb, at any depth. + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + (3u8, *preserve_luminosity).hash(&mut h); + for tone in [shadows, midtones, highlights] { + tone.map(f32::to_bits).hash(&mut h); + } + } + _ => return None, + } + Some(h.finish()) +} + +/// LUT rows for `adj` at `depth`, built by `build` once per [`row_key`] and reused by later plans +/// (`compose::lut_rows`): the Levels tables alone took 1–2 ms per layer on every plan (#11498). +fn cached_rows(adj: &Adjustment, depth: photocraft_color::SampleType, build: impl FnOnce() -> Vec<[f32; 4096]>) -> Vec<[f32; 4096]> { + match row_key(adj, depth) { + Some(key) => photocraft_compose::lut_rows::cached(key, build), + None => build(), + } +} + /// A CPU LUT (4096 entries) as one texture row. fn to_row(t: &[f32]) -> [f32; 4096] { let mut row = [0.0f32; 4096]; @@ -173,7 +235,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf } // p0.w: continue the curve past 0..1 (32-bit RGB Curves, #7157). p[0][3] = if adjust::float_tone_extrapolates(adj, Some(depth), mode) { 1.0 } else { 0.0 }; - (7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect())) + (7, p, Some(cached_rows(adj, depth, || adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect()))) } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { let sat = adjust::colorize_saturation(saturation / 100.0, lightness / 100.0, *colorize, Some(depth)); @@ -181,7 +243,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf if !*colorize && ranges.iter().any(|r| !r.is_neutral()) { // Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU. p[1][0] = 1.0; - (8, p, Some(adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect())) + (8, p, Some(cached_rows(adj, depth, || adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect()))) } else { (8, p, None) } @@ -234,8 +296,11 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { // Per-channel curves (compose::adjust::BalanceCurve), through the Levels kernel. - let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); - (7, p, Some(lut_rows(|ch, v| curves[ch].eval(v), 3))) + let rows = || { + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + lut_rows(|ch, v| curves[ch].eval(v), 3) + }; + (7, p, Some(cached_rows(adj, depth, rows))) } Adjustment::SelectiveColor { relative, adjustments } => { // 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact). diff --git a/crates/gpu/src/plan/tests.rs b/crates/gpu/src/plan/tests.rs index 83f286bf2b..00153c2748 100644 --- a/crates/gpu/src/plan/tests.rs +++ b/crates/gpu/src/plan/tests.rs @@ -335,3 +335,130 @@ fn advanced_blending_falls_back_to_the_cpu() { d.layers.push(Layer::group("g", vec![inner])); assert!(plan(&d).is_err(), "knockout inside a group"); } + +/// Adjustments whose LUT rows the planner caches (#11498), with settings away from identity. +fn row_cached_adjustments() -> Vec { + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + let ch = |g: f32| LevelsChannel { in_black: 0.05, in_white: 0.9, gamma: g, out_black: 0.02, out_white: 0.97 }; + let mut ranges = HueRange::defaults(); + ranges[1].hue = 20.0; + let pts = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.4, output: 0.55 }, CurvePoint { input: 1.0, output: 1.0 }]; + vec![ + Adjustment::Levels { master: ch(1.3), per_channel: [ch(0.8), ch(1.0), ch(1.6)], space: ToneSpace::Rgb, black: Default::default() }, + Adjustment::Curves { master: pts.clone(), per_channel: [pts.clone(), vec![], pts], space: ToneSpace::Rgb, black: vec![], pencil: false }, + Adjustment::HueSaturation { hue: 0.0, saturation: 10.0, lightness: 0.0, colorize: false, ranges }, + Adjustment::ColorBalance { shadows: [10.0, 0.0, -5.0], midtones: [0.0, 5.0, 0.0], highlights: [0.0, 0.0, 10.0], preserve_luminosity: true }, + ] +} + +#[test] +fn cached_adjustment_rows_match_freshly_built_tables() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for a in row_cached_adjustments() { + let fresh: Vec> = match &a { + Adjustment::Levels { .. } | Adjustment::Curves { .. } => adjust::tone_luts_depth(&a, Some(depth)).into_iter().take(3).collect(), + Adjustment::HueSaturation { ranges, .. } => adjust::hue_range_tables(ranges).into_iter().collect(), + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + (0..3).map(|ch| (0..4096).map(|k| curves[ch].eval(k as f32 / 4095.0)).collect()).collect() + } + _ => panic!("not a row-cached adjustment"), + }; + // The first plan may fill the cache, the second reads it: both give the fresh rows. + for _ in 0..2 { + let (_, _, rows) = adjustment_program(&a, Transfer::Srgb, depth, ColorMode::Rgb); + let rows = rows.expect("LUT rows"); + if matches!(a, Adjustment::Levels { .. }) && depth == SampleType::F32 { + continue; // The analytic float Levels kernel takes coefficients, not tables. + } + assert_eq!(rows.len(), fresh.len(), "{} at {depth:?}", a.label()); + for (row, f) in rows.iter().zip(&fresh) { + assert!(row.iter().zip(f).all(|(x, y)| x.to_bits() == y.to_bits()), "{} at {depth:?}", a.label()); + } + } + } + } +} + +#[test] +fn row_keys_cover_exactly_what_the_rows_are_built_from() { + use photocraft_doc::adjust::LevelsChannel; + let key = |a: &Adjustment, depth| lower::row_key(a, depth).expect("a row-cached adjustment"); + let [levels, curves, hue_sat, balance] = <[Adjustment; 4]>::try_from(row_cached_adjustments()).expect("four adjustments"); + // The same settings give the same key on every plan, so later plans read the stored rows. + for a in [&levels, &curves, &hue_sat, &balance] { + assert_eq!(key(a, SampleType::U8), key(&a.clone(), SampleType::U8), "{}", a.label()); + } + // Levels and Curves round to the document's quantum, so their keys hold the depth. + assert_ne!(key(&levels, SampleType::U8), key(&levels, SampleType::U16)); + assert_ne!(key(&curves, SampleType::U16), key(&curves, SampleType::F32)); + // Hue/Saturation range tables and Color Balance curves don't depend on depth. + assert_eq!(key(&hue_sat, SampleType::U8), key(&hue_sat, SampleType::F32)); + assert_eq!(key(&balance, SampleType::U8), key(&balance, SampleType::U16)); + // Dragging the master Hue/Saturation sliders leaves the range tables (and their entry) alone. + let Adjustment::HueSaturation { ranges, .. } = &hue_sat else { panic!("Hue/Saturation") }; + let master = Adjustment::HueSaturation { hue: 40.0, saturation: -30.0, lightness: 12.0, colorize: false, ranges: ranges.clone() }; + assert_eq!(key(&hue_sat, SampleType::U8), key(&master, SampleType::U8)); + let mut moved = ranges.clone(); + moved[4].bounds[2] += 1.0; + let moved = Adjustment::HueSaturation { hue: 0.0, saturation: 10.0, lightness: 0.0, colorize: false, ranges: moved }; + assert_ne!(key(&hue_sat, SampleType::U8), key(&moved, SampleType::U8)); + // Values `==` can't tell apart still get their own rows. + let black = |out_black: f32| { + let ch = LevelsChannel { out_black, ..Default::default() }; + Adjustment::Levels { master: ch.clone(), per_channel: [ch.clone(), ch.clone(), ch], space: ToneSpace::Rgb, black: Default::default() } + }; + assert_ne!(key(&black(0.0), SampleType::U8), key(&black(-0.0), SampleType::U8)); + // Every field the builders read changes the key. + let Adjustment::Curves { master, per_channel, black, .. } = &curves else { panic!("Curves") }; + let pencil = Adjustment::Curves { master: master.clone(), per_channel: per_channel.clone(), space: ToneSpace::Rgb, black: black.clone(), pencil: true }; + let lab = Adjustment::Curves { master: master.clone(), per_channel: per_channel.clone(), space: ToneSpace::Lab, black: black.clone(), pencil: false }; + assert_ne!(key(&curves, SampleType::U8), key(&pencil, SampleType::U8)); + assert_ne!(key(&curves, SampleType::U8), key(&lab, SampleType::U8)); + let mut nudged = master.clone(); + nudged[1].output += 0.01; + let point = Adjustment::Curves { master: nudged, per_channel: per_channel.clone(), space: ToneSpace::Rgb, black: black.clone(), pencil: false }; + assert_ne!(key(&curves, SampleType::U8), key(&point, SampleType::U8), "a curve point's value"); + let Adjustment::ColorBalance { shadows, midtones, highlights, .. } = balance.clone() else { panic!("Color Balance") }; + let plain = Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity: false }; + assert_ne!(key(&balance, SampleType::U8), key(&plain, SampleType::U8)); + // Levels and Curves don't share keys with each other, and adjustments without rows have none. + assert_ne!(key(&Adjustment::identity_levels(), SampleType::U8), key(&Adjustment::identity_curves(), SampleType::U8)); + assert_eq!(lower::row_key(&Adjustment::Vibrance { vibrance: 10.0, saturation: 0.0 }, SampleType::U8), None); +} + +#[test] +fn planning_reads_cached_rows_instead_of_rebuilding_them() { + use photocraft_compose::lut_rows; + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + // Settings no other test plans, so the planted rows are this test's own; the few keys this + // test binary adds can't evict them from the shared cache. + let mut ranges = HueRange::defaults(); + ranges[2].hue = 33.0; + let point = |input, output| CurvePoint { input, output }; + let adjustments = [ + Adjustment::Levels { + master: LevelsChannel { gamma: 1.37, ..Default::default() }, + per_channel: Default::default(), + space: ToneSpace::Rgb, + black: Default::default(), + }, + Adjustment::Curves { + master: vec![point(0.0, 0.0), point(0.3, 0.61), point(1.0, 1.0)], + per_channel: Default::default(), + space: ToneSpace::Rgb, + black: vec![], + pencil: false, + }, + Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges }, + Adjustment::ColorBalance { shadows: [17.0, 0.0, 0.0], midtones: [0.0; 3], highlights: [0.0; 3], preserve_luminosity: true }, + ]; + for (i, a) in adjustments.iter().enumerate() { + // Rows no builder makes: the planner returns them only if it reads the cache. + let planted = vec![[0.25 + i as f32 / 8.0; lut_rows::ROW_LEN]; 3]; + let key = lower::row_key(a, SampleType::U8).expect("a row-cached adjustment"); + assert!(lut_rows::cached(key, || planted.clone()) == planted, "{}: a key no other test uses", a.label()); + let (_, _, rows) = adjustment_program(a, Transfer::Srgb, SampleType::U8, ColorMode::Rgb); + assert!(rows.as_ref() == Some(&planted), "{}: the plan rebuilt rows the cache held", a.label()); + } +} From 5b1b6cf13d88edef9ee9b26fb9f3fd8f14c03a01 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 01:49:40 +0100 Subject: [PATCH 60/63] Merge fix-up: check entity markup in main's bounded embedded-SVG parse too nesting_depth now returns a Result; main's parse_image_bounded_with gets the same entity-markup rejection as the other callers. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 372e933e6e..1127971e11 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -627,7 +627,7 @@ fn parse_image_bounded_with( if left.is_none() { return Err(overrun(IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))); } - let nesting = nesting_depth(&xml, xml_left); + let nesting = nesting_depth(&xml, xml_left)?; if nesting > xml_left { return Err(IoError::Svg(format!("elements are nested more than {xml_left} levels deep"))); } From 88704d23501f92492f8d19bb8503b31da8d32495 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 02:06:41 +0100 Subject: [PATCH 61/63] Merge fix-up: the turned crop's hidden pixels after Layer 0 replaces the Background Since #10867 the non-deleting crop turns a 2000 px layer to 2001 px bounds, so the budget refusal names 200100 x 200100 px. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/image_cmds/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index 66878c6d46..c7e3189254 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -843,7 +843,10 @@ fn crop_target_budgets_the_hidden_pixels_it_resamples() { p["angle"] = json!(a); } let err = s.execute("image.crop", p).unwrap_err().to_string(); - assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{angle:?}: {err}"); + // Since the crop turns the Background into Layer 0 (#10867), the turned layer's + // bounds are 2001 px a side, resampled x100 to 200100. + let want = if angle.is_some() { "200100 x 200100 px is 40040 megapixels" } else { "200000 x 200000 px is 40000 megapixels" }; + assert!(err.contains(want) && err.contains("Delete Cropped Pixels"), "{angle:?}: {err}"); assert_eq!(doc(&s).size, Size::new(2000, 2000), "{angle:?}: the document is unchanged"); assert_eq!(s.active().unwrap().history.past_len(), past, "{angle:?}: no history step"); // The turned crop rotates first: the painting symmetry turns back with the document. From 9b02eac2c4d9cf7b0fca0ead13864ebb8d1cb14a Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 6 Nov 2026 07:10:18 +0600 Subject: [PATCH 62/63] Export to files: a Custom JPEG quality in Artboards, Layers and Layer Comps to Files (#11481) (#11558) * Export to files: a Custom JPEG quality in Artboards, Layers and Layer Comps to Files (#11481) The engine commands already took `quality` (0..12), but their dialogs offered no way to set it. Each dialog now has a Quality row with a Custom checkbox, shared with Batch: quality is sent only for JPEG with Custom on, so an export left alone writes exactly what it wrote before (the source JPEG's own quality, else the encoder default), and PNG, WebP (still lossless), PSD, TIFF and BMP never get one. Alt+Reset restores the Custom state too. The fields follow the reference dialog's order. Co-Authored-By: Claude Opus 5.5 * Tests: the export quality tests go through the dialog's own controls (#11481) Custom and Quality are now ticked and typed through their widgets, a new test switches one dialog from JPEG to PNG and WebP and back and checks the Custom quality survives, and Alt+Reset is clicked through the same path the app uses instead of calling reset_form. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/export_quality_tests.rs | 230 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus/view_cmds/dialogs.rs | 65 ++++- 3 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/export_quality_tests.rs diff --git a/crates/ui-egui/src/export_quality_tests.rs b/crates/ui-egui/src/export_quality_tests.rs new file mode 100644 index 0000000000..1f1bfd88ae --- /dev/null +++ b/crates/ui-egui/src/export_quality_tests.rs @@ -0,0 +1,230 @@ +//! #11481: File › Export › Artboards to Files (and Layers / Layer Comps to Files) has a JPEG +//! Quality field behind a Custom checkbox, as Batch has (#3956). The quality reaches the command +//! only for a JPEG with Custom on: left alone, an export writes what the command writes with no +//! quality (the source JPEG's own quality, else the encoder default), a WebP stays lossless and +//! PNG has none. +use crate::PhotocraftApp; +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::{Value, json}; + +const ARTBOARDS: &str = "file.export.artboardsToFiles"; +const EXPORTS: [&str; 3] = [ARTBOARDS, "file.export.layersToFiles", "file.export.layerCompsToFiles"]; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()) + }); + let app = h.state_mut(); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + // A noisy layer inside the board, so the JPEG quality shows in the file. + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#8A42B3"})).unwrap(); + app.run("filter.noise.addNoise", json!({"amount": 60})).unwrap(); + app.run("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap(); + app.run("layerComp.new", json!({"name": "Comp"})).unwrap(); + app.sync_views(); + h +} + +fn open(h: &mut Harness<'_, PhotocraftApp>, id: &str, dir: &str, format: &str) -> u64 { + let ctx = h.ctx.clone(); + let dialog = crate::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("dir".into(), json!(dir)); + fields.insert("prefix".into(), json!("out")); + fields.insert("format".into(), json!(format)); + h.run_steps(4); + dialog +} + +fn params(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Value { + crate::filter_dialog::params_of(&h.state_mut().ui.dialog_mut(dialog).unwrap().fields) +} + +fn exported(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Vec { + let r = crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + std::fs::read(r["files"][0].as_str().unwrap()).unwrap() +} + +/// The same export run straight through the command, into `dir`. +fn direct(h: &mut Harness<'_, PhotocraftApp>, dir: &str, format: &str, quality: Option) -> Vec { + let mut p = json!({"dir": dir, "prefix": "out", "format": format}); + if let Some(q) = quality { + p["quality"] = json!(q); + } + let r = h.state_mut().run(ARTBOARDS, p).unwrap(); + std::fs::read(r["files"][0].as_str().unwrap()).unwrap() +} + +/// Whether the Quality number field (not its row label) takes input. +fn quality_enabled(h: &Harness<'_, PhotocraftApp>) -> bool { + let fields: Vec = + h.query_all_by_label("Quality").filter(|n| n.accesskit_node().role() != Role::Label).map(|n| !n.accesskit_node().is_disabled()).collect(); + assert!(!fields.is_empty(), "the dialog shows a Quality field"); + assert!(fields.iter().all(|e| *e == fields[0]), "{fields:?}"); + fields[0] +} + +/// Whether the Custom checkbox is ticked. +fn custom_ticked(h: &Harness<'_, PhotocraftApp>) -> bool { + h.get_by_role_and_label(Role::CheckBox, "Custom").accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) +} + +/// The value the Quality field shows. +fn shown_quality(h: &Harness<'_, PhotocraftApp>) -> Option { + h.get_by_role_and_label(Role::SpinButton, "Quality").accesskit_node().numeric_value() +} + +fn tick_custom(h: &mut Harness<'_, PhotocraftApp>) { + h.get_by_role_and_label(Role::CheckBox, "Custom").click(); + h.run_steps(2); +} + +/// Types `text` into the Quality field and commits it with Tab, as a user does. +fn type_quality(h: &mut Harness<'_, PhotocraftApp>, text: &str) { + h.get_by_role_and_label(Role::SpinButton, "Quality").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + for ch in text.chars() { + h.event(egui::Event::Text(ch.to_string())); + h.run_steps(1); + } + h.key_press(egui::Key::Tab); + h.run_steps(2); +} + +/// Holds Alt, which turns Cancel into Reset, and clicks it. +fn alt_reset(h: &mut Harness<'_, PhotocraftApp>) { + let alt = egui::Modifiers::ALT; + h.event(egui::Event::ModifiersChanged(alt)); + h.run_steps(2); + let at = h.get_by_label("Reset").rect().center(); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: alt }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); +} + +fn tmp() -> (tempfile::TempDir, String) { + let t = tempfile::tempdir().unwrap(); + let s = t.path().to_string_lossy().into_owned(); + (t, s) +} + +#[test] +fn artboards_to_files_sends_a_custom_jpeg_quality() { + let mut h = harness(); + let (_a, via_dialog) = tmp(); + let (_b, via_command) = tmp(); + let mut sizes = Vec::new(); + for quality in [0, 3, 12] { + let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg"); + assert!(!custom_ticked(&h) && !quality_enabled(&h), "JPEG with Custom off: the field is greyed out"); + tick_custom(&mut h); + assert!(custom_ticked(&h) && quality_enabled(&h), "JPEG with Custom on: the field takes input"); + assert_eq!(params(&mut h, dialog)["quality"], json!(10), "Custom starts at quality 10"); + type_quality(&mut h, &quality.to_string()); + assert_eq!(shown_quality(&h), Some(quality as f64), "the field shows the typed quality"); + assert_eq!(params(&mut h, dialog)["quality"], json!(quality), "the typed quality reaches the command"); + let bytes = exported(&mut h, dialog); + assert!(bytes == direct(&mut h, &via_command, "jpg", Some(quality)), "quality {quality}: the file the command writes at that quality"); + sizes.push(bytes.len()); + } + assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2], "a higher quality writes a bigger JPEG: {sizes:?}"); +} + +#[test] +fn an_untouched_jpeg_export_writes_what_the_command_writes_with_no_quality() { + let mut h = harness(); + let (_a, via_dialog) = tmp(); + let (_b, via_command) = tmp(); + let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg"); + assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "no quality is sent"); + let bytes = exported(&mut h, dialog); + assert!(bytes == direct(&mut h, &via_command, "jpg", None), "the untouched dialog keeps the command's own JPEG quality"); +} + +#[test] +fn export_to_files_quality_leaves_png_and_webp_as_they_were() { + let mut h = harness(); + let (_a, dir) = tmp(); + for id in EXPORTS { + for format in ["png", "webp"] { + let dialog = open(&mut h, id, &dir, format); + // Custom on from an earlier JPEG choice still sends nothing for these formats. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true)); + h.run_steps(2); + assert!(!quality_enabled(&h), "{id} {format}: the field is greyed out"); + assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "{id} {format}: no quality is sent"); + let bytes = exported(&mut h, dialog); + if format == "webp" { + // The default WebP stays lossless (a VP8L bitstream, not lossy VP8). + assert!(bytes.windows(4).any(|w| w == b"VP8L"), "{id}: the WebP stays lossless"); + } + } + // Back to JPEG with Custom on, the field sends its quality again. + let dialog = open(&mut h, id, &dir, "jpg"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true)); + h.run_steps(2); + assert!(quality_enabled(&h), "{id}"); + assert_eq!(params(&mut h, dialog)["quality"], json!(10), "{id}"); + crate::dialogs::cancel(h.state_mut(), dialog).unwrap(); + } +} + +/// A Custom quality survives a switch to another format and back in the same dialog. +#[test] +fn the_custom_quality_survives_a_format_round_trip() { + let mut h = harness(); + let (_a, dir) = tmp(); + let dialog = open(&mut h, ARTBOARDS, &dir, "jpg"); + tick_custom(&mut h); + type_quality(&mut h, "4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4)); + for format in ["png", "webp"] { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!(format)); + h.run_steps(2); + assert!(!quality_enabled(&h), "{format}: the field is greyed out"); + assert!(params(&mut h, dialog)["quality"].is_null(), "{format}: no quality is sent"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(2); + assert!(custom_ticked(&h) && quality_enabled(&h), "back from {format}: Custom is still on"); + assert_eq!(shown_quality(&h), Some(4.0), "back from {format}: the field still shows 4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4), "back from {format}: quality 4 is sent"); + } +} + +/// Alt+Reset (Alt held, Cancel clicked as Reset) puts the Custom checkbox and its quality back to +/// how the dialog opened, and keeps the dialog open. +#[test] +fn alt_reset_restores_the_custom_quality() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let dialog = crate::menus::invoke(h.state_mut(), &ctx, ARTBOARDS, json!({})).unwrap()["dialog"].as_u64().unwrap(); + // JPEG chosen in the dialog, after it opened: Reset puts the format back too. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(4); + tick_custom(&mut h); + type_quality(&mut h, "4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4)); + alt_reset(&mut h); + assert!(h.state().ui.dialogs.iter().any(|d| d.id == dialog), "Reset keeps the dialog open"); + let p = params(&mut h, dialog); + assert_eq!((&p["format"], &p["quality"]), (&json!("png"), &Value::Null), "the opening format, no quality"); + assert!(!custom_ticked(&h) && !quality_enabled(&h), "Custom is off again"); + assert_eq!(shown_quality(&h), Some(10.0), "the field shows the opening quality"); + // Choosing JPEG again still sends no quality until Custom is ticked. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(2); + assert!(!custom_ticked(&h), "JPEG after Reset: Custom stays off"); + assert!(params(&mut h, dialog)["quality"].is_null(), "JPEG after Reset: no quality is sent"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7a5f226a5..783b952cd6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -927,6 +927,8 @@ mod window_frame_tests; #[cfg(test)] mod browse_field_tests; #[cfg(test)] +mod export_quality_tests; +#[cfg(test)] mod options_hit_area_tests; #[cfg(test)] mod select_and_mask_button_tests; diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 57ade54a13..16cefbd87b 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -66,8 +66,10 @@ pub(super) fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: if let Value::Object(m) = fields { f.extend(m); } - // Snapshot user-facing params at open so Alt+Reset can restore them. - let orig: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + // Snapshot user-facing params at open so Alt+Reset can restore them, with the Custom + // quality rows' inactive state. + let orig: Map = + f.iter().filter(|(k, _)| !k.starts_with("__") || RESET_SHELL_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); f.insert("__formOrig".into(), Value::Object(orig)); app.ui.open_dialog(DialogKind::Command, f) } @@ -166,18 +168,21 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { ui.label(label(&k)); let mut custom = v.as_f64().is_some(); - let mut quality = v.as_f64().or_else(|| f.get("__batchQuality").and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; - ui.horizontal(|ui| { - crate::widgets::checkbox(ui, &mut custom, tl!("Custom")); - ui.add_enabled_ui(custom, |ui| crate::widgets::value_field(ui, &mut quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); - }); + let mut quality = v.as_f64().or_else(|| f.get(BATCH_QUALITY).and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, true); // None preserves the command's existing JPEG and lossless WebP defaults. // The inactive custom value is shell state, never a command parameter. - f.insert("__batchQuality".into(), json!(quality)); + f.insert(BATCH_QUALITY.into(), json!(quality)); f.insert(k, if custom { json!(quality) } else { Value::Null }); ui.end_row(); continue; } + if k == "quality" && f.contains_key(JPEG_QUALITY) { + ui.label(label(&k)); + jpeg_quality_field(ui, f); + ui.end_row(); + continue; + } let new = match &v { Value::Array(_) | Value::Object(_) | Value::Null => continue, Value::Bool(b) => { @@ -246,6 +251,42 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { camera_data(ui, f); } +/// Batch's inactive Custom quality (#3956): shell state, never a command parameter. +const BATCH_QUALITY: &str = "__batchQuality"; +/// An export-to-files dialog's Custom checkbox and its quality (#11481), kept while the format +/// is not JPEG or Custom is off: shell state, never command parameters. +const JPEG_CUSTOM: &str = "__jpegCustom"; +const JPEG_QUALITY: &str = "__jpegQuality"; +/// The shell keys Alt+Reset restores along with a form's params. +const RESET_SHELL_KEYS: [&str; 3] = [BATCH_QUALITY, JPEG_CUSTOM, JPEG_QUALITY]; + +/// A Custom checkbox and the 0..12 Quality field it enables (Batch, the exports to files). Off, +/// the command gets no quality and keeps its own defaults; `usable` false greys out both. +fn custom_quality_row(ui: &mut egui::Ui, custom: &mut bool, quality: &mut f32, usable: bool) { + ui.add_enabled_ui(usable, |ui| { + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, custom, tl!("Custom")); + ui.add_enabled_ui(*custom, |ui| crate::widgets::value_field(ui, quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); + }); + }); +} + +/// Layers, Layer Comps and Artboards to Files: the JPEG quality, sent only for a JPEG with Custom +/// on. Left alone, the export writes what the command writes with no quality (the source JPEG's +/// own quality, else the encoder default), as Batch does; a quality would also make a WebP lossy +/// (its default is lossless), and PNG, PSD, TIFF and BMP have none. +fn jpeg_quality_field(ui: &mut egui::Ui, f: &mut Map) { + let jpeg = f.get("format").and_then(Value::as_str).is_some_and(|x| x.eq_ignore_ascii_case("jpg") || x.eq_ignore_ascii_case("jpeg")); + let sent = f.get("quality").and_then(Value::as_f64); + let mut custom = sent.is_some() || f.get(JPEG_CUSTOM).and_then(Value::as_bool).unwrap_or(false); + let mut quality = sent.or_else(|| f.get(JPEG_QUALITY).and_then(Value::as_f64)).unwrap_or(10.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, jpeg); + let quality = quality.round().clamp(0.0, 12.0) as i64; + f.insert(JPEG_CUSTOM.into(), json!(custom)); + f.insert(JPEG_QUALITY.into(), json!(quality)); + f.insert("quality".into(), if jpeg && custom { json!(quality) } else { Value::Null }); +} + /// File Info's read-only Camera Data section (`__camera`, from the document's EXIF/XMP). fn camera_data(ui: &mut egui::Ui, f: &Map) { let Some(camera) = f.get("__camera").and_then(Value::as_object).filter(|c| !c.is_empty()) else { return }; @@ -428,7 +469,8 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; dialog( app, - json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true, "trim": false}), + json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "quality": null, JPEG_CUSTOM: false, + JPEG_QUALITY: 10, "visibleOnly": true, "trim": false, "__order": ["dir", "prefix", "visibleOnly", "format", "quality", "trim"]}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}), ) } @@ -436,9 +478,10 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; let prefix = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string(); let fields = if id == "file.export.layerCompsToFiles" { - json!({"dir": dir, "prefix": prefix, "format": "png", "selectedOnly": false}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "selectedOnly": false, + "__order": ["dir", "prefix", "selectedOnly", "format", "quality"]}) } else { - json!({"dir": dir, "prefix": prefix, "format": "png"}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "__order": ["dir", "prefix", "format", "quality"]}) }; dialog(app, fields, json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]})) } From 3d60ad01f941519307c755eed8e0896d6274cde6 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 6 Nov 2026 07:20:08 +0600 Subject: [PATCH 63/63] Channels: the panel menu has the channel commands, New Spot Channel among them (#11451) (#11557) * Channels: the panel menu has the channel commands, New Spot Channel among them (#11451) The Channels panel's menu had no channel items, so New Spot Channel... and the rest could only be reached from a row's right-click menu. It now lists New Channel..., Duplicate Channel, Delete Channel, New Spot Channel..., Merge Spot Channel, Channel Options..., Split Channels and Merge Channels..., each acting on the targeted channel and greyed where it can't: Duplicate takes a single colour, alpha or spot channel, the composite of a one-channel image, the Quick Mask or a targeted layer mask. Channel Options... in Quick Mask mode now opens the targeted alpha or spot channel's options, not Quick Mask Options. The panel menus run their commands through the menu bar's enabled rules, so the Layers panel menu greys its dialog items the same way too. The labels are in every catalog. Co-Authored-By: Claude Opus 5.5 * Channels: New Channel... and Merge Channels... ask first, from the panel and row menus (#11451) The panel menu's Merge Channels... ran channel.merge with no parameters, and the engine merged the first grayscale documents into RGB and closed them with no choice; New Channel... added a channel straight away. Both now open a dialog when called without parameters, as New Spot Channel... and Channel Options... already did: New Channel... reuses the Channel Options dialog (name, colour indicates, colour and opacity), and a new Merge Channels dialog picks the mode and the open grayscale document for each channel. The channel rows' menu had the same two items and goes through the same path. With parameters (scripts, the New button) both still run at once. The Merge Spot Channel test now checks pixels, so a delete couldn't pass it. Co-Authored-By: Claude Opus 5.5 * Channels: Merge Channels waits for three grayscale documents and the dialog offers only modes they fill (#11451) With two grayscale documents open, Merge Channels... was enabled and its dialog repeated a document for the third channel; OK then failed in the engine and the dialog closed. The engine's rule now needs three documents of one size (no mode merges fewer), so the menus grey the item below that. The dialog offers only the modes the open documents can fill (RGB and Lab from three, CMYK from four), greys OK with the reason while the picks repeat, keeps itself open when OK is refused, and comes back with the engine's message if the merge itself fails. Co-Authored-By: Claude Opus 5.5 * Channels: the Merge Channels dialog keeps its picks by document id (#11451) The dialog kept the picked source documents by tab index, and background work can still run while it is open, so a document moved or closed in the meantime could leave another one at a picked index; OK would then merge, and close, the wrong document. The picks are now document ids, resolved to the current tabs when OK is pressed: a closed pick, or one that is no longer a grayscale document of the same size, is refused with the reason and the dialog stays open. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/channel_cmds.rs | 3 +- crates/engine/src/cmd/channel_cmds/tests.rs | 18 + crates/ui-egui/src/dialogs.rs | 8 +- .../ui-egui/src/dialogs/channel_options_ui.rs | 33 +- .../ui-egui/src/dialogs/merge_channels_ui.rs | 365 ++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 15 + crates/ui-egui/src/i18n/bs.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/da.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 15 + crates/ui-egui/src/i18n/el.tsv | 15 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hi.tsv | 15 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/id.tsv | 15 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 15 + crates/ui-egui/src/i18n/nb.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 15 + crates/ui-egui/src/i18n/pl.tsv | 15 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sr.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 15 + crates/ui-egui/src/i18n/uk.tsv | 15 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 15 + crates/ui-egui/src/i18n/zh-hant.tsv | 15 + crates/ui-egui/src/menus/invoke.rs | 22 +- crates/ui-egui/src/panels/channels_panel.rs | 143 ++++++- .../src/panels/channels_panel/tests.rs | 209 ++++++++++ crates/ui-egui/src/panels/layer_row_ui.rs | 10 +- crates/ui-egui/src/panels/layers_panel_ui.rs | 14 +- crates/ui-egui/src/panels/panel_docking.rs | 4 + .../ui-egui/src/panels/panel_docking/tests.rs | 40 ++ docs/ui-parity.md | 4 +- 40 files changed, 1136 insertions(+), 37 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/merge_channels_ui.rs diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 093c683fa1..a860b50db5 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -54,9 +54,10 @@ fn can_split(s: &Session) -> std::result::Result<(), String> { Ok(()) } } +/// Merge Channels needs a document per colour channel: three at least (RGB and Lab; CMYK four). fn can_merge(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if mergeable(s).len() >= 2 { Ok(()) } else { Err("Merge Channels needs at least two open grayscale documents of the same size".into()) } + if mergeable(s).len() >= 3 { Ok(()) } else { Err("Merge Channels needs at least three open grayscale documents of the same size".into()) } } // --------------------------------------------------------------------------------------------- diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index b7f2074b98..3a56aefc3b 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -1238,3 +1238,21 @@ fn split_refuses_an_image_with_more_than_one_layer() { assert!(s.execute("channel.split", json!({})).is_err()); assert_eq!(s.documents().len(), 1); } + +/// Merge Channels is offered from three grayscale documents of one size, the fewest a mode +/// (RGB or Lab) merges; two never merge (#11451). +#[test] +fn merge_channels_waits_for_three_grayscale_documents() { + let mut s = Session::new(); + for _ in 0..2 { + s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + } + s.execute("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap(); + s.set_active(0); + let why = s.disabled_reason("channel.merge").expect("two of one size are not enough"); + assert!(why.contains("three"), "{why}"); + s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + assert!(s.is_enabled("channel.merge")); + s.execute("channel.merge", json!({"mode": "lab"})).unwrap(); + assert_eq!(s.documents().len(), 2, "the three of one size merged; the other is left"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6ddd3b7347..0cfeb7fdc1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -317,6 +317,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id) -> Option { let mut outcome: Option = None; match d.kind { + _ if merge_channels_ui::owns(fields) => merge_channels_ui::body(ui, fields), _ if crate::file_ui::owns(fields) => crate::file_ui::body(app, ui, fields, d.id), _ if crate::color_picker_ui::owns(fields) => { outcome = crate::color_picker_ui::body(ui, fields); @@ -454,7 +455,7 @@ fn button_row( } else { let ok_label = ok_label(d); // Dialogs that need an input first (a preset name, a selection) grey OK out. - let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields); + let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields) && merge_channels_ui::can_confirm(fields); let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok); // Photoshop: holding Alt turns Cancel into Reset (the dialog stays open). // Dialogs opt in via `dialog_alt_reset::supported` (per marker). @@ -860,6 +861,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::stroke_ui::owns, crate::channel_options_ui::owns, crate::channel_options_ui::owns_new_spot, + merge_channels_ui::owns, crate::shape_dialog::owns, crate::delete_layer_prompt::owns, crate::disk_change_prompt::owns, @@ -1011,6 +1013,8 @@ pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::guide_layout_ui::owns(&d.fields)) { crate::guide_layout_ui::validate(app, &d.fields)?; } + // Merge Channels with too few, repeated, closed or changed documents stays open (#11451). + merge_channels_ui::preflight(app, id)?; Ok(()) } @@ -1054,6 +1058,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result /// The rest of the command dialogs: files, colour, preferences and export, then any other command. fn confirm_command_more(app: &mut PhotocraftApp, d: Dialog) -> Result { match d.kind { + _ if merge_channels_ui::owns(&d.fields) => merge_channels_ui::confirm(app, &d.fields), _ if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields), _ if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields), _ if crate::color_picker_ui::owns(&d.fields) => { @@ -1212,6 +1217,7 @@ pub mod layer_picker; pub mod layer_style; pub mod liquify_ui; pub mod mask_trash_prompt; +pub mod merge_channels_ui; pub mod new_doc_ui; pub mod new_group_ui; pub mod new_guide_ui; diff --git a/crates/ui-egui/src/dialogs/channel_options_ui.rs b/crates/ui-egui/src/dialogs/channel_options_ui.rs index 3a3453e840..834ef82022 100644 --- a/crates/ui-egui/src/dialogs/channel_options_ui.rs +++ b/crates/ui-egui/src/dialogs/channel_options_ui.rs @@ -5,6 +5,9 @@ //! //! On the layer mask (double-click its Channels row, or Mask Options… on its Layers thumbnail) //! it is Photoshop's Layer Mask Display Options: just the rubylith's colour and opacity. +//! +//! The Channels panel's New Channel… is the same dialog for a channel still to be made (name, +//! Masked / Selected Areas, colour, opacity); OK runs `channel.new` (#11451). use egui::{Align2, Sense, pos2, vec2}; use photocraft_doc::ColorIndicates; @@ -15,6 +18,8 @@ use crate::theme::Tokens; pub const COMMAND: &str = "channel.options"; const MARK: &str = "__channelOptions"; +pub const NEW_CHANNEL: &str = "channel.new"; +const NEW_CHANNEL_MARK: &str = "__newChannel"; pub fn owns(fields: &Map) -> bool { fields.contains_key(MARK) @@ -75,6 +80,28 @@ pub fn open(app: &mut PhotocraftApp, channel: &Value) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) } +/// The New Channel fields: Channel Options' for a channel still to be made (the next "Alpha N" +/// name, Masked Areas, red at 50%), or `None` without a document. OK runs `channel.new`. +pub fn new_channel_fields(app: &PhotocraftApp) -> Option> { + let doc = &app.session.active()?.doc; + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert(NEW_CHANNEL_MARK.into(), json!(true)); + f.insert("__command".into(), json!(NEW_CHANNEL)); + f.insert("__label".into(), json!("New Channel…")); + f.insert("name".into(), json!(photocraft_engine::channel_cmds::next_alpha_name(doc, "Alpha"))); + f.insert("indicates".into(), json!("masked")); + f.insert("color".into(), json!("#ff0000")); + f.insert("opacity".into(), json!(50.0)); + Some(f) +} + +/// Opens New Channel, or `None` without a document. +pub fn open_new_channel(app: &mut PhotocraftApp) -> Option { + let f = new_channel_fields(app)?; + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + /// The `channel.options` params for the dialog's fields: spot channels take `solidity`, the /// others `opacity`. pub fn params(f: &Map) -> Value { @@ -92,7 +119,7 @@ pub fn params(f: &Map) -> Value { } pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - app.run(COMMAND, params(f)) + app.run(if f.contains_key(NEW_CHANNEL_MARK) { NEW_CHANNEL } else { COMMAND }, params(f)) } fn label(ui: &mut egui::Ui, text: &str) { @@ -122,8 +149,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.indent("channel-options-indicates", |ui| { ui.radio_value(&mut indicates, "masked".to_string(), tl!("Masked Areas")); ui.radio_value(&mut indicates, "selected".to_string(), tl!("Selected Areas")); - // Quick Mask can't be a spot channel. - if !quick { + // Quick Mask can't be a spot channel; New Spot Channel… makes spot channels. + if !quick && !f.contains_key(NEW_CHANNEL_MARK) { ui.radio_value(&mut indicates, "spot".to_string(), tl!("Spot Color")); } }); diff --git a/crates/ui-egui/src/dialogs/merge_channels_ui.rs b/crates/ui-egui/src/dialogs/merge_channels_ui.rs new file mode 100644 index 0000000000..c7d97512a0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/merge_channels_ui.rs @@ -0,0 +1,365 @@ +//! Channels panel › Merge Channels…: the mode (RGB, CMYK or Lab Color) and, for each of its +//! channels, the open grayscale document that becomes it. Photoshop asks in two steps (the mode, +//! then the documents); here both are one dialog. Only the modes the open documents can fill are +//! offered (RGB and Lab need three, CMYK four), OK waits for a different document per channel, +//! and a merge that still fails opens the dialog again with the reason. OK runs the engine's +//! `channel.merge`, which closes the sources; agents call it directly with params (#11451). +//! +//! The dialog names documents by their [`photocraft_doc::DocId`], not their tab index: work that +//! runs while it is open (a script moving or closing a document) can shift the indices, and OK +//! must merge, and close, the documents that were picked. They are looked up again at OK. + +use photocraft_doc::ColorMode; +use photocraft_engine::channel_cmds; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::theme::Tokens; + +pub const COMMAND: &str = "channel.merge"; +const MARK: &str = "__mergeChannels"; +/// The most channels a mode has (CMYK). +const SLOTS: usize = 4; +const MODES: [(&str, &str); 3] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color"), ("lab", "Lab Color")]; +const TOO_FEW: &str = "Not enough grayscale documents for this mode."; +const REPEATED: &str = "Pick a different document for each channel."; +const CLOSED: &str = "A picked document is no longer open."; +const CHANGED: &str = "A picked document is no longer a grayscale document of the same size."; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +fn mode_of(key: &str) -> ColorMode { + match key { + "cmyk" => ColorMode::Cmyk, + "lab" => ColorMode::Lab, + _ => ColorMode::Rgb, + } +} + +/// The modes `count` documents can fill, in the menu's order. +fn modes(count: usize) -> Vec<(String, &'static str)> { + MODES.iter().filter(|(key, _)| mode_of(key).color_channels() <= count).map(|(key, label)| (key.to_string(), *label)).collect() +} + +/// The documents that can be merged, by document id and name: the open grayscale ones of the +/// active document's size, the active one first, as the engine picks them without params. +fn sources(app: &PhotocraftApp) -> Vec<(u64, String)> { + let Some(active) = app.session.active() else { return Vec::new() }; + let docs = app.session.documents(); + let fits = |i: usize| docs.get(i).filter(|d| d.doc.mode == ColorMode::Grayscale && d.doc.size == active.doc.size); + let current = app.session.active_index(); + let others = (0..docs.len()).filter(|i| Some(*i) != current); + current.into_iter().chain(others).filter_map(|i| fits(i).map(|d| (d.doc.id.0, d.doc.name.clone()))).collect() +} + +fn mode_field(f: &Map) -> &str { + f.get("mode").and_then(Value::as_str).unwrap_or("rgb") +} + +fn sources_field(f: &Map) -> Vec<(u64, String)> { + f.get("__sources").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() +} + +/// The picked document ids, one per channel of the mode. +fn picks(f: &Map) -> Vec { + let all: Vec = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + all.into_iter().take(mode_of(mode_field(f)).color_channels()).collect() +} + +/// The dialog's fields: the first mode the documents can fill (RGB Color from three) and the +/// merge-able documents in order, one per channel, or `None` without a document. +pub fn fields(app: &PhotocraftApp) -> Option> { + app.session.active()?; + let sources = sources(app); + let picks: Vec = sources.iter().take(SLOTS).map(|(id, _)| *id).collect(); + let mode = modes(sources.len()).first().map_or_else(|| "rgb".to_string(), |(key, _)| key.clone()); + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("Merge Channels…")); + f.insert("__sources".into(), json!(sources)); + f.insert("mode".into(), json!(mode)); + f.insert("documents".into(), json!(picks)); + Some(f) +} + +/// Opens Merge Channels, or `None` without a document. +pub fn open(app: &mut PhotocraftApp) -> Option { + let f = fields(app)?; + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + +/// Why OK is greyed (English, for `tl!`): the mode needs more documents than are offered, or the +/// picks repeat a document or name one the dialog did not offer. `None` when OK can merge, and +/// for another dialog's fields. +pub fn problem(f: &Map) -> Option<&'static str> { + if !owns(f) { + return None; + } + let n = mode_of(mode_field(f)).color_channels(); + let sources = sources_field(f); + let picks = picks(f); + if sources.len() < n || picks.len() < n { + return Some(TOO_FEW); + } + let mut seen = std::collections::HashSet::new(); + let offered = |p: &u64| sources.iter().any(|(id, _)| id == p); + if picks.iter().any(|p| !seen.insert(*p) || !offered(p)) { + return Some(REPEATED); + } + None +} + +/// Whether OK is live: always for other dialogs, here while [`problem`] finds none. +pub fn can_confirm(f: &Map) -> bool { + problem(f).is_none() +} + +/// The picked documents' current tab indices, looked up by id; refused (English, for `tl!`) +/// when one was closed, or is no longer a grayscale document of the others' size. +fn resolve(app: &PhotocraftApp, f: &Map) -> Result, &'static str> { + if let Some(why) = problem(f) { + return Err(why); + } + let docs = app.session.documents(); + let indices = picks(f).iter().map(|id| docs.iter().position(|d| d.doc.id.0 == *id).ok_or(CLOSED)).collect::, _>>()?; + let first = indices.first().and_then(|&i| docs.get(i)).map(|d| d.doc.size); + let fits = |i: &usize| docs.get(*i).is_some_and(|d| d.doc.mode == ColorMode::Grayscale && Some(d.doc.size) == first); + if indices.iter().all(fits) { Ok(indices) } else { Err(CHANGED) } +} + +/// Checked before the dialog closes (`dialogs::confirm_preflight`), so a refused OK keeps it +/// open with the reason under the pickers: too few or repeated documents, or a picked one that +/// was closed or changed while the dialog was open. +pub fn preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { + let Some(f) = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| &d.fields).filter(|f| owns(f)) else { return Ok(()) }; + let Err(why) = resolve(app, f) else { return Ok(()) }; + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.insert("__error".into(), json!(why)); + } + Err(why.to_string()) +} + +/// The `channel.merge` params for the dialog's fields: the mode and the picked documents' current +/// indices, one per channel. +pub fn params(app: &PhotocraftApp, f: &Map) -> Result { + let documents = resolve(app, f).map_err(str::to_string)?; + Ok(json!({ "mode": mode_field(f), "documents": documents })) +} + +/// Runs the merge. Should it still be refused, the dialog opens again with its picks and the +/// reason, rather than going away. +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let result = params(app, f).and_then(|p| app.run(COMMAND, p)); + if let Err(e) = &result { + let mut again = f.clone(); + again.insert("__error".into(), json!(e)); + app.ui.open_dialog(crate::state::DialogKind::Command, again); + } + result +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + ui.spacing_mut().item_spacing.y = 6.0; + let sources = sources_field(f); + let mut mode = mode_field(f).to_string(); + let modes = modes(sources.len()); + ui.horizontal(|ui| { + ui.label(tl!("Mode:")); + if crate::widgets::dropdown(ui, "merge-channels-mode", tl!("Mode:"), &mut mode, &modes, 160.0) { + f.insert("mode".into(), json!(mode)); + f.remove("__error"); + } + }); + crate::widgets::section_label(ui, tl!("Specify Channels:")); + crate::widgets::hairline(ui); + let options: Vec<(u64, &str)> = sources.iter().map(|(id, name)| (*id, name.as_str())).collect(); + let mut picks: Vec = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let mut changed = false; + for (k, channel) in channel_cmds::color_names(mode_of(&mode)).iter().enumerate() { + let Some(pick) = picks.get_mut(k) else { break }; + ui.horizontal(|ui| { + let caption = format!("{}:", tl!(channel)); + ui.add_sized([84.0, 22.0], egui::Label::new(&caption)); + changed |= crate::widgets::dropdown_literal(ui, &format!("merge-channels-{k}"), &caption, pick, &options, 200.0); + }); + } + if changed { + f.insert("documents".into(), json!(picks)); + f.remove("__error"); + } + // Why OK is greyed, or why the last OK was refused (the dialog's reasons are catalogued; the + // engine's own are shown as they come). + let why: Option<&str> = problem(f); + let why = why.or_else(|| f.get("__error").and_then(Value::as_str)).map(|w| tl!(w).to_string()); + if let Some(why) = why { + ui.label(egui::RichText::new(why).color(Tokens::get(ui.ctx()).danger).size(11.0)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app(docs: usize) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for _ in 0..docs { + app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + } + app + } + + fn id(app: &PhotocraftApp, index: usize) -> u64 { + app.session.documents()[index].doc.id.0 + } + + fn offered(f: &Map) -> Vec { + modes(sources_field(f).len()).into_iter().map(|(key, _)| key).collect() + } + + /// The merged document's first pixel as 8-bit RGB. + fn merged_pixel(app: &PhotocraftApp) -> [u8; 3] { + let doc = &app.session.active().unwrap().doc; + let px = photocraft_compose::render(doc, doc.bounds()).px[0]; + [px[0], px[1], px[2]].map(|v| (v * 255.0).round() as u8) + } + + /// Three grayscale documents filled black, mid gray and white, then the active one is `active`. + fn shaded(active: usize) -> PhotocraftApp { + let mut app = app(3); + for (i, gray) in ["#000000", "#808080", "#ffffff"].into_iter().enumerate() { + app.session.set_active(i); + app.run("edit.fill", json!({"color": gray})).unwrap(); + } + app.session.set_active(active); + app + } + + #[test] + fn fields_list_the_sources_by_id_and_params_take_their_indices() { + let mut app = app(3); + app.run("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap(); + app.run("file.new", json!({"width": 8, "height": 8, "mode": "rgb"})).unwrap(); + app.session.set_active(2); + let f = fields(&app).unwrap(); + let ids = [id(&app, 2), id(&app, 0), id(&app, 1)]; + assert_eq!(f["__sources"].as_array().unwrap().iter().map(|s| s[0].as_u64().unwrap()).collect::>(), ids, "the active one first"); + assert_eq!((f["mode"].clone(), f["documents"].clone()), (json!("rgb"), json!(ids)), "one each, none repeated"); + assert_eq!(params(&app, &f).unwrap(), json!({"mode": "rgb", "documents": [2, 0, 1]})); + assert!(fields(&PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())).is_none()); + } + + /// Three documents fill RGB and Lab, not CMYK; four fill CMYK too. + #[test] + fn only_the_modes_the_documents_fill_are_offered() { + let f = fields(&app(3)).unwrap(); + assert_eq!(offered(&f), ["rgb", "lab"]); + assert!(can_confirm(&f)); + let mut cmyk = f.clone(); + cmyk.insert("mode".into(), json!("cmyk")); + assert_eq!(problem(&cmyk), Some(TOO_FEW), "CMYK from three documents (as automation could ask) greys OK"); + let app = app(4); + let f = fields(&app).unwrap(); + assert_eq!(offered(&f), ["rgb", "cmyk", "lab"]); + let mut cmyk = f.clone(); + cmyk.insert("mode".into(), json!("cmyk")); + assert!(can_confirm(&cmyk)); + assert_eq!(params(&app, &cmyk).unwrap()["documents"], json!([3, 0, 1, 2]), "the active (last opened) one first"); + } + + /// A document picked for two channels greys OK with the reason; a refused OK keeps the dialog + /// open and merges nothing. + #[test] + fn repeated_picks_grey_ok_and_keep_the_dialog_open() { + let mut app = app(3); + let (a, b) = (id(&app, 0), id(&app, 1)); + let id = open(&mut app).unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("documents".into(), json!([a, a, b])); + assert_eq!(problem(&d.fields), Some(REPEATED)); + assert!(!can_confirm(&d.fields)); + d.fields.insert("documents".into(), json!([a, b, 999_999])); + assert_eq!(problem(&d.fields), Some(REPEATED), "a document the dialog did not offer"); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(app.ui.dialogs.iter().any(|d| d.id == id), "the dialog stays open"); + assert_eq!(app.session.documents().len(), 3, "nothing merged or closed"); + assert!(can_confirm(&Map::new()), "other dialogs are not affected"); + } + + /// Documents moved while the dialog is open: OK still merges the ones picked, by id, into the + /// channels picked for them. + #[test] + fn ok_merges_the_picked_documents_after_a_reorder() { + let mut app = shaded(0); + let (black, gray, white) = (id(&app, 0), id(&app, 1), id(&app, 2)); + let dialog = open(&mut app).unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!([black, white, gray])); + // A script reorders the tabs while the dialog is open: white first, black last. + app.session.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); + app.session.execute("document.move", json!({"document": 1, "to": 2})).unwrap(); + assert_eq!([id(&app, 0), id(&app, 1), id(&app, 2)], [white, gray, black]); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!((app.session.documents().len(), app.session.active().unwrap().doc.mode), (1, ColorMode::Rgb)); + assert_eq!(merged_pixel(&app), [0, 255, 128], "red, green and blue from the documents picked, not their old tabs"); + } + + /// A picked document closed while the dialog is open: OK refuses with the reason, keeps the + /// dialog and closes nothing, even when another same-size grayscale document took its tab. + #[test] + fn a_closed_pick_is_refused_and_no_other_document_merges() { + let mut app = shaded(0); + app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + app.session.set_active(0); + let spare = id(&app, 3); + let dialog = open(&mut app).unwrap(); + let picked: Vec = (0..3).map(|i| id(&app, i)).collect(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!(picked)); + app.session.execute("file.close", json!({"document": 1})).unwrap(); + assert_eq!(id(&app, 2), spare, "the spare document is where a picked one was"); + let why = crate::dialogs::confirm(&mut app, dialog).unwrap_err(); + assert_eq!(why, CLOSED); + let d = app.ui.dialogs.iter().find(|d| d.id == dialog).expect("the dialog stays open"); + assert_eq!(d.fields["__error"], json!(CLOSED), "with the reason under the pickers"); + assert_eq!(app.session.documents().len(), 3, "nothing merged or closed"); + assert!(app.session.documents().iter().any(|d| d.doc.id.0 == spare)); + } + + /// A picked document turned into another mode while the dialog is open is refused too; should + /// the engine refuse a merge after OK, the dialog comes back with the reason. + #[test] + fn a_changed_pick_or_a_refused_merge_keeps_the_dialog() { + let mut app = app(3); + let dialog = open(&mut app).unwrap(); + let f = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + app.run("image.mode.rgb", json!({})).unwrap(); + assert_eq!(crate::dialogs::confirm(&mut app, dialog).unwrap_err(), CHANGED); + assert!(app.ui.dialogs.iter().any(|d| d.id == dialog)); + app.ui.dialogs.clear(); + assert!(confirm(&mut app, &f).is_err()); + let again = &app.ui.dialogs.last().expect("the dialog again").fields; + assert!(owns(again) && again["__error"].is_string()); + assert_eq!(app.session.documents().len(), 3); + } + + #[test] + fn body_renders_every_mode_and_the_reason() { + let app = app(4); + let ids: Vec = (0..4).map(|i| id(&app, i)).collect(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (mode, docs, error) in + [("rgb", json!(ids[..3]), None), ("cmyk", json!([ids[0], ids[1], ids[2], ids[2]]), None), ("lab", json!(ids[1..]), Some(CLOSED))] + { + let mut f = fields(&app).unwrap(); + f.insert("mode".into(), json!(mode)); + f.insert("documents".into(), docs); + if let Some(e) = error { + f.insert("__error".into(), json!(e)); + } + ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear(); + assert!(owns(&f)); + } + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 69ee8346ab..9703ae5b3d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3564,3 +3564,18 @@ curves Smooth تنعيم Pause For إيقاف مؤقت لمدة seconds ثانية Create a single history state إنشاء حالة سجل واحدة + +# Channels panel menu (#11451) + New Channel… قناة جديدة… + Duplicate Channel تكرار القناة + Delete Channel حذف القناة + New Spot Channel… قناة لون موضعي جديدة… + Merge Spot Channel دمج قناة اللون الموضعي + Channel Options… خيارات القناة… + Split Channels تقسيم القنوات + Merge Channels… دمج القنوات… + Specify Channels: تحديد القنوات: + Not enough grayscale documents for this mode. لا توجد مستندات كافية بتدرج الرمادي لهذا الوضع. + Pick a different document for each channel. اختر مستندًا مختلفًا لكل قناة. + A picked document is no longer open. أحد المستندات المختارة لم يعد مفتوحًا. + A picked document is no longer a grayscale document of the same size. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 5d16b97df2..1381bb150c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4605,3 +4605,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Boja tačke Shapes can't use patterns yet Oblici još ne mogu koristiti uzorke Edit Brush… Uredi četkicu… + +# Channels panel menu (#11451) + Channel Options… Opcije kanala… + Specify Channels: Odredi kanale: + Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj način. + Pick a different document for each channel. Odaberite drugi dokument za svaki kanal. + A picked document is no longer open. Jedan odabrani dokument više nije otvoren. + A picked document is no longer a grayscale document of the same size. Jedan odabrani dokument više nije dokument u nijansama sive iste veličine. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e11ad45163..bb9d6463f0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4313,3 +4313,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Nahradit obrázek… The frame is locked Rámeček je zamčený The frame is unavailable Rámeček není dostupný + +# Channels panel menu (#11451) + Channel Options… Možnosti kanálu… + Specify Channels: Určit kanály: + Not enough grayscale documents for this mode. Pro tento režim není dost dokumentů ve stupních šedi. + Pick a different document for each channel. Pro každý kanál vyberte jiný dokument. + A picked document is no longer open. Jeden z vybraných dokumentů už není otevřený. + A picked document is no longer a grayscale document of the same size. Jeden z vybraných dokumentů už není dokument ve stupních šedi stejné velikosti. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index dadc00b8aa..c84caa3fe8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4787,3 +4787,11 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet Pause For Pause i seconds sekunder Create a single history state Opret én historiktilstand + +# Channels panel menu (#11451) + Channel Options… Kanalindstillinger… + Specify Channels: Angiv kanaler: + Not enough grayscale documents for this mode. Der er ikke nok dokumenter i gråtoner til denne tilstand. + Pick a different document for each channel. Vælg et andet dokument til hver kanal. + A picked document is no longer open. Et af de valgte dokumenter er ikke længere åbent. + A picked document is no longer a grayscale document of the same size. Et af de valgte dokumenter er ikke længere et dokument i gråtoner i samme størrelse. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 801c7b38f8..1918f49a17 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3599,3 +3599,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is unavailable Der Rahmen ist nicht verfügbar X X Y Y + +# Channels panel menu (#11451) + New Channel… Neuer Kanal… + Duplicate Channel Kanal duplizieren + Delete Channel Kanal löschen + New Spot Channel… Neuer Schmuckfarbenkanal… + Merge Spot Channel Schmuckfarbenkanal zusammenfügen + Channel Options… Kanaloptionen… + Split Channels Kanäle teilen + Merge Channels… Kanäle zusammenfügen… + Specify Channels: Kanäle angeben: + Not enough grayscale documents for this mode. Nicht genug Graustufen-Dokumente für diesen Modus. + Pick a different document for each channel. Wählen Sie für jeden Kanal ein anderes Dokument. + A picked document is no longer open. Ein ausgewähltes Dokument ist nicht mehr geöffnet. + A picked document is no longer a grayscale document of the same size. Ein ausgewähltes Dokument ist kein Graustufen-Dokument derselben Größe mehr. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4ef92c6162..ad261185bb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4268,3 +4268,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is unavailable Το πλαίσιο δεν είναι διαθέσιμο X X Y Y + +# Channels panel menu (#11451) + New Channel… Νέο κανάλι… + Duplicate Channel Δημιουργία αντιγράφου καναλιού + Delete Channel Διαγραφή καναλιού + New Spot Channel… Νέο κανάλι πλακάτου χρώματος… + Merge Spot Channel Συγχώνευση καναλιού πλακάτου χρώματος + Channel Options… Επιλογές καναλιού… + Split Channels Διαχωρισμός καναλιών + Merge Channels… Συγχώνευση καναλιών… + Specify Channels: Καθορισμός καναλιών: + Not enough grayscale documents for this mode. Δεν υπάρχουν αρκετά έγγραφα σε κλίμακα του γκρι για αυτή τη λειτουργία. + Pick a different document for each channel. Επιλέξτε διαφορετικό έγγραφο για κάθε κανάλι. + A picked document is no longer open. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον ανοιχτό. + A picked document is no longer a grayscale document of the same size. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 117026a6d9..8f8c2d3ae8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4477,3 +4477,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Reemplazar imagen… The frame is locked El marco está bloqueado The frame is unavailable El marco no está disponible + +# Channels panel menu (#11451) + Channel Options… Opciones de canal… + Specify Channels: Especificar canales: + Not enough grayscale documents for this mode. No hay suficientes documentos en escala de grises para este modo. + Pick a different document for each channel. Elija un documento distinto para cada canal. + A picked document is no longer open. Uno de los documentos elegidos ya no está abierto. + A picked document is no longer a grayscale document of the same size. Uno de los documentos elegidos ya no es un documento en escala de grises del mismo tamaño. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 53086c2582..90de37b0bf 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4788,3 +4788,11 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen Pause For Tauko seconds sekuntia Create a single history state Luo yksi historiatila + +# Channels panel menu (#11451) + Channel Options… Kanavan asetukset… + Specify Channels: Määritä kanavat: + Not enough grayscale documents for this mode. Harmaasävyasiakirjoja ei ole tarpeeksi tähän tilaan. + Pick a different document for each channel. Valitse jokaiselle kanavalle eri asiakirja. + A picked document is no longer open. Yksi valituista asiakirjoista ei ole enää auki. + A picked document is no longer a grayscale document of the same size. Yksi valituista asiakirjoista ei ole enää samankokoinen harmaasävyasiakirja. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1a937b8fb9..75b955f0d7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4600,3 +4600,11 @@ shape Appearance Apparence Replace Image… Remplacer l’image… The frame is locked Le cadre est verrouillé The frame is unavailable Le cadre n’est pas disponible + +# Channels panel menu (#11451) + Channel Options… Options de canal… + Specify Channels: Indiquer les canaux : + Not enough grayscale documents for this mode. Pas assez de documents en niveaux de gris pour ce mode. + Pick a different document for each channel. Choisissez un document différent pour chaque canal. + A picked document is no longer open. Un des documents choisis n'est plus ouvert. + A picked document is no longer a grayscale document of the same size. Un des documents choisis n'est plus un document en niveaux de gris de même taille. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b1fc4631e0..f7a712b0e4 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -645,3 +645,18 @@ cameraRaw Yellow पीला Pause For इतने समय रुकें seconds सेकंड Create a single history state एक ही इतिहास स्थिति बनाएँ + +# Channels panel menu (#11451) + New Channel… नया चैनल… + Duplicate Channel चैनल को डुप्लीकेट करें + Delete Channel चैनल हटाएँ + New Spot Channel… नया स्पॉट चैनल… + Merge Spot Channel स्पॉट चैनल मर्ज करें + Channel Options… चैनल विकल्प… + Split Channels चैनल्स विभाजित करें + Merge Channels… चैनल्स मर्ज करें… + Specify Channels: चैनल्स निर्दिष्ट करें: + Not enough grayscale documents for this mode. इस मोड के लिए पर्याप्त ग्रेस्केल डॉक्यूमेंट नहीं हैं। + Pick a different document for each channel. हर चैनल के लिए अलग डॉक्यूमेंट चुनें। + A picked document is no longer open. चुना गया एक डॉक्यूमेंट अब खुला नहीं है। + A picked document is no longer a grayscale document of the same size. चुना गया एक डॉक्यूमेंट अब उसी आकार का ग्रेस्केल डॉक्यूमेंट नहीं है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 14606a1e70..debfdd6747 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4484,3 +4484,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Kép cseréje… The frame is locked A keret zárolva van The frame is unavailable A keret nem érhető el + +# Channels panel menu (#11451) + Channel Options… Csatornabeállítások… + Specify Channels: Csatornák megadása: + Not enough grayscale documents for this mode. Nincs elég szürkeárnyalatos dokumentum ehhez a módhoz. + Pick a different document for each channel. Minden csatornához másik dokumentumot válasszon. + A picked document is no longer open. Az egyik kiválasztott dokumentum már nincs megnyitva. + A picked document is no longer a grayscale document of the same size. Az egyik kiválasztott dokumentum már nem azonos méretű szürkeárnyalatos dokumentum. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bc6766f662..c6af32b023 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3590,3 +3590,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is locked Bingkai terkunci The frame is unavailable Bingkai tidak tersedia Frame Bingkai + +# Channels panel menu (#11451) + New Channel… Channel Baru… + Duplicate Channel Duplikat Channel + Delete Channel Hapus Channel + New Spot Channel… Spot Channel Baru… + Merge Spot Channel Gabungkan Spot Channel + Channel Options… Opsi Channel… + Split Channels Pisahkan Channel + Merge Channels… Gabungkan Channel… + Specify Channels: Tentukan Channel: + Not enough grayscale documents for this mode. Dokumen grayscale tidak cukup untuk mode ini. + Pick a different document for each channel. Pilih dokumen yang berbeda untuk setiap channel. + A picked document is no longer open. Salah satu dokumen yang dipilih sudah tidak terbuka. + A picked document is no longer a grayscale document of the same size. Salah satu dokumen yang dipilih bukan lagi dokumen grayscale dengan ukuran yang sama. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 312d902b87..b120d5a32c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4676,3 +4676,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Sostituisci immagine… The frame is locked La cornice è bloccata The frame is unavailable La cornice non è disponibile + +# Channels panel menu (#11451) + Channel Options… Opzioni canale… + Specify Channels: Specifica canali: + Not enough grayscale documents for this mode. Documenti in scala di grigio insufficienti per questo metodo. + Pick a different document for each channel. Scegli un documento diverso per ogni canale. + A picked document is no longer open. Uno dei documenti scelti non è più aperto. + A picked document is no longer a grayscale document of the same size. Uno dei documenti scelti non è più un documento in scala di grigio delle stesse dimensioni. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e80417f2be..36c5bc1470 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4245,3 +4245,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… 画像を置き換え… The frame is locked フレームはロックされています The frame is unavailable フレームを利用できません + +# Channels panel menu (#11451) + Channel Options… チャンネルオプション… + Specify Channels: チャンネルを指定: + Not enough grayscale documents for this mode. このモードに必要なグレースケールのドキュメントが足りません。 + Pick a different document for each channel. チャンネルごとに別のドキュメントを選択してください。 + A picked document is no longer open. 選択したドキュメントの一つが開かれていません。 + A picked document is no longer a grayscale document of the same size. 選択したドキュメントの一つが、同じサイズのグレースケールのドキュメントではなくなりました。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0423429641..81d92d6ecd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3790,3 +3790,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is unavailable 프레임을 사용할 수 없습니다 X X Y Y + +# Channels panel menu (#11451) + New Channel… 새 채널… + Duplicate Channel 채널 복제 + Delete Channel 채널 삭제 + New Spot Channel… 새 별색 채널… + Merge Spot Channel 별색 채널 병합 + Channel Options… 채널 옵션… + Split Channels 채널 분할 + Merge Channels… 채널 병합… + Specify Channels: 채널 지정: + Not enough grayscale documents for this mode. 이 모드에 필요한 회색 음영 문서가 부족합니다. + Pick a different document for each channel. 채널마다 다른 문서를 선택하십시오. + A picked document is no longer open. 선택한 문서 중 하나가 더 이상 열려 있지 않습니다. + A picked document is no longer a grayscale document of the same size. 선택한 문서 중 하나가 더 이상 같은 크기의 회색 음영 문서가 아닙니다. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 55f348ff1e..05057c9f12 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4787,3 +4787,11 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Pause For Pause i seconds sekunder Create a single history state Opprett én historikktilstand + +# Channels panel menu (#11451) + Channel Options… Kanalalternativer… + Specify Channels: Angi kanaler: + Not enough grayscale documents for this mode. Ikke nok dokumenter i gråtoner for denne modusen. + Pick a different document for each channel. Velg et annet dokument for hver kanal. + A picked document is no longer open. Et av de valgte dokumentene er ikke lenger åpent. + A picked document is no longer a grayscale document of the same size. Et av de valgte dokumentene er ikke lenger et dokument i gråtoner av samme størrelse. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ef2df8db00..0788559d67 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4322,3 +4322,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Afbeelding vervangen… The frame is locked Het kader is vergrendeld The frame is unavailable Het kader is niet beschikbaar + +# Channels panel menu (#11451) + New Channel… Nieuw kanaal… + Duplicate Channel Kanaal dupliceren + Delete Channel Kanaal verwijderen + New Spot Channel… Nieuw steunkleurkanaal… + Merge Spot Channel Steunkleurkanaal samenvoegen + Channel Options… Kanaalopties… + Split Channels Kanalen splitsen + Merge Channels… Kanalen samenvoegen… + Specify Channels: Kanalen opgeven: + Not enough grayscale documents for this mode. Niet genoeg grijswaardendocumenten voor deze modus. + Pick a different document for each channel. Kies voor elk kanaal een ander document. + A picked document is no longer open. Een gekozen document is niet meer geopend. + A picked document is no longer a grayscale document of the same size. Een gekozen document is geen grijswaardendocument van hetzelfde formaat meer. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 41cd0e671d..eb95ddf40b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4185,3 +4185,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Zastąp obraz… The frame is locked Ramka jest zablokowana The frame is unavailable Ramka jest niedostępna + +# Channels panel menu (#11451) + New Channel… Nowy kanał… + Duplicate Channel Duplikuj kanał + Delete Channel Usuń kanał + New Spot Channel… Nowy kanał kolorów dodatkowych… + Merge Spot Channel Scal kanał kolorów dodatkowych + Channel Options… Opcje kanału… + Split Channels Podziel kanały + Merge Channels… Scal kanały… + Specify Channels: Określ kanały: + Not enough grayscale documents for this mode. Za mało dokumentów w skali szarości dla tego trybu. + Pick a different document for each channel. Wybierz inny dokument dla każdego kanału. + A picked document is no longer open. Jeden z wybranych dokumentów nie jest już otwarty. + A picked document is no longer a grayscale document of the same size. Jeden z wybranych dokumentów nie jest już dokumentem w skali szarości o tym samym rozmiarze. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index db66f03f90..8579545a54 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4491,3 +4491,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Substituir imagem… The frame is locked A moldura está bloqueada The frame is unavailable A moldura não está disponível + +# Channels panel menu (#11451) + Channel Options… Opções de canal… + Specify Channels: Especificar canais: + Not enough grayscale documents for this mode. Não há documentos em tons de cinza suficientes para este modo. + Pick a different document for each channel. Escolha um documento diferente para cada canal. + A picked document is no longer open. Um dos documentos escolhidos não está mais aberto. + A picked document is no longer a grayscale document of the same size. Um dos documentos escolhidos não é mais um documento em tons de cinza do mesmo tamanho. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e54f6ff397..4b32187aa2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4762,3 +4762,11 @@ shape Appearance Оформление Replace Image… Заменить изображение… The frame is locked Рамка заблокирована The frame is unavailable Рамка недоступна + +# Channels panel menu (#11451) + Channel Options… Параметры канала… + Specify Channels: Указать каналы: + Not enough grayscale documents for this mode. Недостаточно документов в градациях серого для этого режима. + Pick a different document for each channel. Выберите отдельный документ для каждого канала. + A picked document is no longer open. Один из выбранных документов больше не открыт. + A picked document is no longer a grayscale document of the same size. Один из выбранных документов больше не является документом в градациях серого того же размера. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9b5eef1478..ce8b82ae26 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4643,3 +4643,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Замени слику… The frame is locked Оквир је закључан The frame is unavailable Оквир није доступан + +# Channels panel menu (#11451) + Channel Options… Opcije kanala… + Specify Channels: Odredi kanale: + Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj režim. + Pick a different document for each channel. Izaberite drugi dokument za svaki kanal. + A picked document is no longer open. Jedan izabrani dokument više nije otvoren. + A picked document is no longer a grayscale document of the same size. Jedan izabrani dokument više nije dokument u nijansama sive iste veličine. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 928a735904..e7f1f83489 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4785,3 +4785,11 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Pause For Paus i seconds sekunder Create a single history state Skapa ett enda historikläge + +# Channels panel menu (#11451) + Channel Options… Kanalalternativ… + Specify Channels: Ange kanaler: + Not enough grayscale documents for this mode. Det finns inte tillräckligt många gråskaledokument för det här läget. + Pick a different document for each channel. Välj ett annat dokument för varje kanal. + A picked document is no longer open. Ett av de valda dokumenten är inte längre öppet. + A picked document is no longer a grayscale document of the same size. Ett av de valda dokumenten är inte längre ett gråskaledokument i samma storlek. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d319a3614c..331eb4e2b3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4299,3 +4299,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Görüntüyü Değiştir… The frame is locked Çerçeve kilitli The frame is unavailable Çerçeve kullanılamıyor + +# Channels panel menu (#11451) + New Channel… Yeni Kanal… + Duplicate Channel Kanalı Çoğalt + Delete Channel Kanalı Sil + New Spot Channel… Yeni Spot Kanal… + Merge Spot Channel Spot Kanalı Birleştir + Channel Options… Kanal Seçenekleri… + Split Channels Kanalları Ayır + Merge Channels… Kanalları Birleştir… + Specify Channels: Kanalları Belirt: + Not enough grayscale documents for this mode. Bu mod için yeterli gri tonlamalı belge yok. + Pick a different document for each channel. Her kanal için farklı bir belge seçin. + A picked document is no longer open. Seçilen belgelerden biri artık açık değil. + A picked document is no longer a grayscale document of the same size. Seçilen belgelerden biri artık aynı boyutta gri tonlamalı bir belge değil. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1621be051..cf51dca7f8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4278,3 +4278,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Замінити зображення… The frame is locked Рамку заблоковано The frame is unavailable Рамка недоступна + +# Channels panel menu (#11451) + New Channel… Новий канал… + Duplicate Channel Дублювати канал + Delete Channel Видалити канал + New Spot Channel… Новий канал плашкового кольору… + Merge Spot Channel Об'єднати канал плашкового кольору + Channel Options… Параметри каналу… + Split Channels Розділити канали + Merge Channels… Об'єднати канали… + Specify Channels: Вказати канали: + Not enough grayscale documents for this mode. Недостатньо документів у градаціях сірого для цього режиму. + Pick a different document for each channel. Виберіть окремий документ для кожного каналу. + A picked document is no longer open. Один із вибраних документів більше не відкрито. + A picked document is no longer a grayscale document of the same size. Один із вибраних документів більше не є документом у градаціях сірого того самого розміру. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index de99217a09..bda47054db 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4074,3 +4074,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Thay ảnh… The frame is locked Khung đã bị khóa The frame is unavailable Khung không khả dụng + +# Channels panel menu (#11451) + Channel Options… Tùy chọn kênh… + Specify Channels: Chỉ định kênh: + Not enough grayscale documents for this mode. Không đủ tài liệu thang xám cho chế độ này. + Pick a different document for each channel. Chọn một tài liệu khác cho mỗi kênh. + A picked document is no longer open. Một tài liệu đã chọn không còn mở. + A picked document is no longer a grayscale document of the same size. Một tài liệu đã chọn không còn là tài liệu thang xám cùng kích thước. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f89abd7117..4a165910b9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3256,3 +3256,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Frame 框架 X X Y Y + +# Channels panel menu (#11451) + New Channel… 新建通道… + Duplicate Channel 复制通道 + Delete Channel 删除通道 + New Spot Channel… 新建专色通道… + Merge Spot Channel 合并专色通道 + Channel Options… 通道选项… + Split Channels 分离通道 + Merge Channels… 合并通道… + Specify Channels: 指定通道: + Not enough grayscale documents for this mode. 灰度文档不足,无法使用此模式。 + Pick a different document for each channel. 请为每个通道选择不同的文档。 + A picked document is no longer open. 所选的一个文档已不再打开。 + A picked document is no longer a grayscale document of the same size. 所选的一个文档已不再是同样大小的灰度文档。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c3c1fdac34..3d10a7aa6a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3212,3 +3212,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Frame 框架 X X Y Y + +# Channels panel menu (#11451) + New Channel… 新增色版… + Duplicate Channel 複製色版 + Delete Channel 刪除色版 + New Spot Channel… 新增特別色色版… + Merge Spot Channel 合併特別色色版 + Channel Options… 色版選項… + Split Channels 分離色版 + Merge Channels… 合併色版… + Specify Channels: 指定色版: + Not enough grayscale documents for this mode. 灰階文件不足,無法使用此模式。 + Pick a different document for each channel. 請為每個色版選擇不同的文件。 + A picked document is no longer open. 所選的一個文件已不再開啟。 + A picked document is no longer a grayscale document of the same size. 所選的一個文件已不再是相同大小的灰階文件。 diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index e1f52d934c..0539b4cb0a 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -415,20 +415,36 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(json!({"dialog": crate::new_group_ui::open(app, id)})) } // Channels › Channel Options… without params: the dialog on the targeted alpha/spot - // channel, Layer Mask Display Options on a targeted layer mask, or Quick Mask Options while - // in Quick Mask (with params: the engine directly). + // channel (also in Quick Mask mode, where its row is the selected one), Quick Mask Options + // in Quick Mask otherwise, or Layer Mask Display Options on a targeted layer mask (with + // params: the engine directly). crate::channel_options_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { let mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document is open")?; let channel = match st.channel_view.target { - _ if st.doc.quick_mask.is_some() => json!("quickMask"), photocraft_engine::channel_cmds::ChannelTarget::Alpha(i) => json!(i), + _ if st.doc.quick_mask.is_some() => json!("quickMask"), _ if mask => json!("mask"), _ => return Err("target an alpha or spot channel, or a layer mask, first".into()), }; let dialog = crate::channel_options_ui::open(app, &channel).ok_or("no such channel")?; Ok(json!({"dialog": dialog})) } + // Channels › New Channel… without params: its dialog (with params: the engine). The + // panel's New button makes one at once, as Photoshop's does. + crate::channel_options_ui::NEW_CHANNEL if params.as_object().is_none_or(|o| o.is_empty()) => { + let dialog = crate::channel_options_ui::open_new_channel(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } + // Channels › Merge Channels… without params: its dialog, which picks the mode and the + // documents (with params: the engine). + crate::dialogs::merge_channels_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { + if let Some(Err(why)) = photocraft_engine::commands::find(id).map(|c| (c.enabled)(&app.session)) { + return Err(why); + } + let dialog = crate::dialogs::merge_channels_ui::open(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } // Channels › New Spot Channel… without params: its dialog (with params: the engine). crate::channel_options_ui::NEW_SPOT if params.as_object().is_none_or(|o| o.is_empty()) => { let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; diff --git a/crates/ui-egui/src/panels/channels_panel.rs b/crates/ui-egui/src/panels/channels_panel.rs index 3baa1b99bf..fd6ad08178 100644 --- a/crates/ui-egui/src/panels/channels_panel.rs +++ b/crates/ui-egui/src/panels/channels_panel.rs @@ -90,13 +90,15 @@ struct RowInteractions<'a> { masked: Option<&'a photocraft_doc::Layer>, actions: &'a mut Vec<(String, Value)>, mask_click: &'a mut Option, + /// Whether Merge Channels… can run (enough grayscale documents of one size). + merge_enabled: bool, } fn handle_row_interactions(ui: &mut egui::Ui, mut row: RowInteractions<'_>) { let rename_id = egui::Id::unique(("chan-rename", row.doc.id.0, format!("{:?}", row.row.reference()))); handle_channel_row_click(ui, &mut row); handle_channel_row_double_click(ui, &mut row, rename_id); - row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.actions)); + row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.merge_enabled, row.actions)); } fn handle_channel_row_click(ui: &egui::Ui, row: &mut RowInteractions<'_>) { @@ -167,7 +169,14 @@ fn handle_alpha_double_click(ui: &mut egui::Ui, row: &mut RowInteractions<'_>, i } } -fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, masked: Option<&photocraft_doc::Layer>, actions: &mut Vec<(String, Value)>) { +fn draw_row_menu( + ui: &mut egui::Ui, + row: Row, + doc: &photocraft_doc::Document, + masked: Option<&photocraft_doc::Layer>, + merge_enabled: bool, + actions: &mut Vec<(String, Value)>, +) { crate::widgets::menu_scroll(ui, |ui| { ui.set_min_width(210.0); match row { @@ -177,11 +186,11 @@ fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, ma Row::LayerMask => draw_mask_menu(ui, masked, actions), } ui.separator(); - item(ui, actions, "New Channel…", "channel.new", json!({})); + item(ui, actions, "New Channel…", "ui.newChannel", json!({})); item(ui, actions, "New Spot Channel…", "ui.newSpotChannel", json!({})); ui.separator(); item(ui, actions, "Split Channels", "channel.split", json!({})); - item(ui, actions, "Merge Channels…", "channel.merge", json!({})); + item_enabled(ui, actions, "Merge Channels…", "ui.mergeChannels", json!({}), merge_enabled); }); } @@ -288,14 +297,14 @@ fn row_presentation(row: Row, context: &RowPresentationContext<'_>, mask_visible !context.view.quick_mask_hidden, context.view.target == ChannelTarget::Composite, ), - Row::LayerMask => { - (context.masked.map_or_else(String::new, |layer| format!("{} Mask", layer.name)), usize::MAX, None, mask_visible, context.mask_targeted) - } + Row::LayerMask => (context.masked.map_or_else(String::new, mask_row_name), usize::MAX, None, mask_visible, context.mask_targeted), }; RowPresentation { name, thumb_idx, slot, visible, selected } } pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + // Greyed in the rows' menu as in the panel menu and the command palette. + let merge_enabled = crate::menus::is_enabled(app, "channel.merge"); let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { ui.add_space(6.0); @@ -316,10 +325,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The active layer's mask (Photoshop lists it, in italics, below the colour channels). let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()); let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?))); - let mask_targeted = masked.is_some() - && (app.ui.mask_target || view.target == ChannelTarget::LayerMask) - && (view.target == ChannelTarget::Composite || view.target == ChannelTarget::LayerMask) - && !quick; + let mask_targeted = mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick); // Multichannel images are their ink channels only (no composite or colour rows). let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel; let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] }; @@ -436,6 +442,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { masked, actions: &mut actions, mask_click: &mut mask_click, + merge_enabled, }, ); } @@ -467,13 +474,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }; // Photoshop's order from the left: Load as selection, Save selection, New, Delete. widgets::panel_footer(ui, |ui| { - let target_ref = match view.target { - ChannelTarget::Alpha(i) => json!(i), - ChannelTarget::Color(k) => json!({ "color": k }), - ChannelTarget::Composite => json!("composite"), - ChannelTarget::LayerMask => json!("mask"), - ChannelTarget::VectorMask => json!("vectorMask"), - }; + let target_ref = target_reference(view.target); let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")); if trash.clicked() { actions.push(("channel.delete".into(), json!({}))); @@ -510,6 +511,20 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } continue; } + // The row menu's New Channel… and Merge Channels… ask first, as the panel menu's do + // (the footer's New button makes a channel at once). + let asks = match id.as_str() { + "ui.newChannel" => Some("channel.new"), + "ui.mergeChannels" => Some("channel.merge"), + _ => None, + }; + if let Some(cmd) = asks { + if let Err(e) = crate::menus::invoke(app, &ctx, cmd, json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + continue; + } if id == "ui.newSpotChannel" { crate::channel_options_ui::open_new_spot(app); continue; @@ -526,6 +541,91 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The engine reference of the targeted channel (`channel.*` and `select.loadSelection` take it). +fn target_reference(target: ChannelTarget) -> Value { + match target { + ChannelTarget::Alpha(i) => json!(i), + ChannelTarget::Color(k) => json!({ "color": k }), + ChannelTarget::Composite => json!("composite"), + ChannelTarget::LayerMask => json!("mask"), + ChannelTarget::VectorMask => json!("vectorMask"), + } +} + +/// Whether the selected layer's mask row is the one shown targeted: the layer has a mask, and it +/// (not the composite) is what painting edits, outside Quick Mask. +fn mask_row_targeted(has_mask: bool, mask_target: bool, target: ChannelTarget, quick: bool) -> bool { + has_mask && (mask_target || target == ChannelTarget::LayerMask) && matches!(target, ChannelTarget::Composite | ChannelTarget::LayerMask) && !quick +} + +/// The layer mask row's name, " Mask", as Photoshop lists it. +fn mask_row_name(layer: &photocraft_doc::Layer) -> String { + format!("{} Mask", layer.name) +} + +/// The Channels panel's own items at the top of its panel menu (☰, #11451), in Photoshop's +/// order: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, +/// Channel Options…, Split Channels, Merge Channels…. The ones that act on one channel take the +/// targeted row and are greyed without one (a colour composite, several colour channels). New +/// Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs, as from +/// the command palette; Split Channels runs at once. +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + use crate::layers_panel_ui::run_menu_command; + let target = MenuTarget::of(app); + run_menu_command(app, ui, tl!("New Channel…"), "channel.new", Some(json!({}))); + run_menu_command(app, ui, tl!("Duplicate Channel"), "channel.duplicate", target.duplicate); + run_menu_command(app, ui, tl!("Delete Channel"), "channel.delete", target.alpha.map(|i| json!({ "channel": i }))); + ui.separator(); + run_menu_command(app, ui, tl!("New Spot Channel…"), "channel.newSpot", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Spot Channel"), "channel.mergeSpot", target.spot.map(|i| json!({ "channel": i }))); + run_menu_command(app, ui, tl!("Channel Options…"), "channel.options", target.options.then(|| json!({}))); + ui.separator(); + run_menu_command(app, ui, tl!("Split Channels"), "channel.split", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Channels…"), "channel.merge", Some(json!({}))); +} + +/// What the panel menu's single-channel items act on: the targeted row. +#[derive(Default)] +struct MenuTarget { + /// The `channel.duplicate` params: the targeted row as its footer's New button copies it. + duplicate: Option, + /// The targeted alpha or spot channel. + alpha: Option, + /// The targeted spot channel. + spot: Option, + /// Whether Channel Options… has something to show (an alpha / spot channel, Quick Mask or a + /// layer mask), as the menu bar's command decides. + options: bool, +} + +impl MenuTarget { + fn of(app: &PhotocraftApp) -> Self { + let mask = app.layer_mask_targeted(); + let Some(st) = app.session.active() else { return Self::default() }; + let view = &st.channel_view; + let alpha = match view.target { + ChannelTarget::Alpha(i) if i < st.doc.channels.len() => Some(i), + _ => None, + }; + let quick = st.doc.quick_mask.is_some(); + let masked = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| l.mask.is_some()); + let duplicate = match view.target { + // The composite targeted in Quick Mask mode: the Quick Mask row is the selected one. + ChannelTarget::Composite if quick => Some(json!({ "channel": "quickMask" })), + _ if mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick) => { + masked.and_then(|layer| drop_action(Row::LayerMask, &mask_row_name(layer), Some(layer.id.0), Footer::New)).map(|(_, p)| p) + } + ChannelTarget::Alpha(_) if alpha.is_some() => Some(json!({ "channel": target_reference(view.target) })), + ChannelTarget::Color(_) if view.selected_colors.len() <= 1 => Some(json!({ "channel": target_reference(view.target) })), + // A one-channel image (Grayscale, Bitmap, Duotone): the composite is its channel. + ChannelTarget::Composite if channel_cmds::color_count(&st.doc) == 1 => Some(json!({ "channel": "composite" })), + _ => None, + }; + let spot = alpha.filter(|&i| st.doc.channels.get(i).is_some_and(|c| c.spot.is_some())); + Self { duplicate, alpha, spot, options: alpha.is_some() || quick || mask } + } +} + /// The footer buttons a row can be dropped on. #[derive(Clone, Copy)] enum Footer { @@ -575,7 +675,12 @@ pub fn fit_thumb(cell: Rect, w: u32, h: u32) -> (Rect, Rect) { } fn item(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value) { - if ui.button(tl!(&label)).clicked() { + item_enabled(ui, actions, label, cmd, p, true); +} + +/// [`item`], greyed unless `enabled`. +fn item_enabled(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value, enabled: bool) { + if ui.add_enabled(enabled, egui::Button::new(tl!(&label))).clicked() { actions.push((cmd.into(), p)); ui.close(); } diff --git a/crates/ui-egui/src/panels/channels_panel/tests.rs b/crates/ui-egui/src/panels/channels_panel/tests.rs index 3d57762d03..123efd3ade 100644 --- a/crates/ui-egui/src/panels/channels_panel/tests.rs +++ b/crates/ui-egui/src/panels/channels_panel/tests.rs @@ -440,3 +440,212 @@ fn recording_a_colour_row_click_saves_as_photoshop_select() { assert_eq!(reloaded, &vec![("channel.target".to_string(), json!({"channel": {"color": name}}))], "{mode}"); } } + +/// The panel menu's single-channel items act on the targeted channel (#11451): a targeted spot +/// channel can be duplicated, given options, merged or deleted; a colour channel duplicated. +#[test] +fn panel_menu_items_act_on_the_targeted_channel() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "fromSelection": false})).unwrap(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(!h.get_by_label(label).accesskit_node().is_disabled(), "{label} acts on the targeted spot channel"); + } + h.get_by_label("Channel Options…").click(); + h.run_steps(2); + assert!(h.state().ui.dialogs.iter().any(|d| crate::channel_options_ui::owns(&d.fields) && d.fields["channel"] == json!(0))); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink", "Ink copy"]); + h.get_by_label("Delete Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy"], "the targeted channel is deleted"); + h.state_mut().run("channel.target", json!({"channel": {"color": 1}})).unwrap(); + h.run_steps(2); + assert!(h.get_by_label("Delete Channel").accesskit_node().is_disabled(), "colour channels aren't deleted here"); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy", "Green copy"]); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); +} + +fn alpha_names(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect() +} + +fn menu_app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24, "mode": mode})).unwrap(); + app +} + +fn menu_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + h +} + +fn menu_disabled(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> bool { + use egui_kittest::kittest::{NodeT, Queryable}; + h.get_by_label(label).accesskit_node().is_disabled() +} + +fn menu_click(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, label: &str) { + use egui_kittest::kittest::Queryable; + h.get_by_label(label).click(); + h.run_steps(2); + assert!(!h.state().ui.status_error, "{label}: {}", h.state().ui.status); +} + +/// A grayscale image's composite is its one channel: Duplicate Channel copies it, as the row +/// menu does; an RGB composite stays greyed. +#[test] +fn panel_menu_duplicates_the_gray_composite() { + assert!(menu_disabled(&menu_harness(menu_app("rgb")), "Duplicate Channel"), "nothing to duplicate on the RGB composite"); + let mut h = menu_harness(menu_app("grayscale")); + assert!(!menu_disabled(&h, "Duplicate Channel")); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Gray copy"]); +} + +/// Duplicate Channel copies a targeted Quick Mask or layer mask, as dropping the row on New does +/// (the mask's copy is " Mask copy"). +#[test] +fn panel_menu_duplicates_quick_mask_and_layer_mask() { + let mut app = menu_app("rgb"); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the Quick Mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(h.state().session.active().unwrap().doc.channels.len(), 1); + + let mut app = menu_app("rgb"); + app.run("layer.new.layer", json!({"name": "Masked"})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the layer mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Masked Mask copy"]); +} + +/// In Quick Mask mode, Channel Options… on a targeted alpha channel opens that channel's options, +/// from the panel menu and the command palette (`menus::invoke`) alike; with the composite +/// targeted it is Quick Mask's. +#[test] +fn channel_options_in_quick_mask_take_the_targeted_alpha() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!("quickMask"), "the composite targeted: Quick Mask Options"); + app.ui.dialogs.clear(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!(0), "the dispatcher opens the alpha's options"); + app.ui.dialogs.clear(); + let mut h = menu_harness(app); + menu_click(&mut h, "Channel Options…"); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["channel"], json!(0), "so does the panel menu"); +} + +/// Duplicate Channel waits for one channel: not several colour channels at once. Without a +/// document every item is greyed. +#[test] +fn panel_menu_greys_items_without_one_channel_or_a_document() { + let mut app = menu_app("rgb"); + app.run("channel.target", json!({"channel": {"color": 0}})).unwrap(); + app.run("channel.target", json!({"channel": {"color": 1}, "extend": true})).unwrap(); + assert_eq!(app.session.active().unwrap().channel_view.selected_colors.len(), 2); + assert!(menu_disabled(&menu_harness(app), "Duplicate Channel"), "two colour channels are targeted"); + let h = menu_harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + for label in [ + "New Channel…", + "Duplicate Channel", + "Delete Channel", + "New Spot Channel…", + "Merge Spot Channel", + "Channel Options…", + "Split Channels", + "Merge Channels…", + ] { + assert!(menu_disabled(&h, label), "{label} waits for a document"); + } +} + +/// Merge Spot Channel prints the targeted spot channel into the image and removes it: a solid +/// blue ink over the left half turns those pixels blue and leaves the rest white. +#[test] +fn panel_menu_merges_the_targeted_spot_channel() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "color": "#0000ff", "solidity": 100})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("channel.target", json!({"channel": 1})).unwrap(); + let pixel = |app: &PhotocraftApp, x: usize, y: usize| { + let doc = &app.session.active().unwrap().doc; + let comp = photocraft_compose::render(doc, doc.bounds()); + let p = comp.px[y * doc.size.width as usize + x]; + [p[0], p[1], p[2]].map(|v| (v * 255.0).round() as u8) + }; + assert_eq!((pixel(&app, 4, 4), pixel(&app, 24, 4)), ([255, 255, 255], [255, 255, 255]), "the ink is not in the image yet"); + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Spot Channel"); + assert_eq!(alpha_names(h.state()), ["Alpha 1"], "the spot channel is merged, the alpha kept"); + assert_eq!(pixel(h.state(), 4, 4), [0, 0, 255], "the ink is printed where the channel had it"); + assert_eq!(pixel(h.state(), 24, 4), [255, 255, 255], "and nowhere else"); +} + +/// Merge Channels… asks first, as in Photoshop: the dialog picks the mode and the document for +/// each channel; nothing merges until OK. With params the command runs directly. +#[test] +fn panel_menu_merge_channels_opens_its_dialog() { + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Channels…"); + assert_eq!(h.state().session.documents().len(), 3, "nothing is merged before OK"); + let d = h.state().ui.dialogs.last().expect("the Merge Channels dialog"); + assert_eq!(d.fields["__label"], json!("Merge Channels…")); + let id = d.id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("mode".into(), json!("lab")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let docs = h.state().session.documents(); + assert_eq!(docs.len(), 1, "the sources close"); + assert_eq!(docs[0].doc.mode, photocraft_doc::ColorMode::Lab, "in the mode picked"); + + let ctx = egui::Context::default(); + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let r = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap(); + assert!(r["dialog"].is_u64() && app.session.documents().len() == 3, "the dispatcher asks too"); + app.ui.dialogs.clear(); + crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({"mode": "rgb"})).unwrap(); + assert!(app.ui.dialogs.is_empty() && app.session.documents().len() == 1, "explicit params merge directly"); +} + +/// Two grayscale documents fill no mode: Merge Channels… is greyed in the panel menu, and the +/// dispatcher says why instead of opening the dialog; a third one enables it. +#[test] +fn merge_channels_waits_for_three_grayscale_documents() { + let mut app = menu_app("grayscale"); + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + let ctx = egui::Context::default(); + let why = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap_err(); + assert!(why.contains("three") && app.ui.dialogs.is_empty(), "{why}"); + let mut h = menu_harness(app); + assert!(menu_disabled(&h, "Merge Channels…"), "two documents are not enough"); + h.state_mut().run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + h.run_steps(2); + assert!(!menu_disabled(&h, "Merge Channels…"), "three are"); +} diff --git a/crates/ui-egui/src/panels/layer_row_ui.rs b/crates/ui-egui/src/panels/layer_row_ui.rs index 82d924bacf..060b12a223 100644 --- a/crates/ui-egui/src/panels/layer_row_ui.rs +++ b/crates/ui-egui/src/panels/layer_row_ui.rs @@ -277,21 +277,21 @@ pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { } } ui.separator(); - run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage"); + run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage", Some(json!({}))); ui.separator(); if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), egui::Button::new(tl!("Lock Layers…"))).clicked() { app.ui.layers_panel_dialog = Some(LayersPanelDialog::LockLayers { transparency: false, pixels: false, position: false, artboard: false, all: true }); ui.close(); } - run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer"); + run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer", Some(json!({}))); let any_hidden = app.session.active().is_some_and(|st| st.selected_layers().iter().any(|id| st.doc.layer(*id).is_some_and(|l| !l.visible))); if any_hidden { - run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers"); + run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers", Some(json!({}))); } else { - run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers"); + run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers", Some(json!({}))); } ui.separator(); - run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard"); + run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard", Some(json!({}))); ui.separator(); ui.menu_button(tl!("Filter Options"), |ui| { let mut show = app.ui.layers_panel_options.show_filters; diff --git a/crates/ui-egui/src/panels/layers_panel_ui.rs b/crates/ui-egui/src/panels/layers_panel_ui.rs index d640e3850d..5bf63ac814 100644 --- a/crates/ui-egui/src/panels/layers_panel_ui.rs +++ b/crates/ui-egui/src/panels/layers_panel_ui.rs @@ -27,11 +27,17 @@ pub fn restore_options(app: &mut PhotocraftApp) { } } -/// The real menu command dispatcher handles rename's in-place editor and other shell routing. -pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str) { - if ui.add_enabled(app.session.is_enabled(id), egui::Button::new(label)).clicked() { +/// A panel-menu item running menu command `id` with `params` (`{}` for none) through the real +/// menu command dispatcher, which handles rename's in-place editor, opens a command's dialog where +/// it has one and does other shell routing. Greyed when `params` is `None` (nothing to act on) or +/// where the menu bar greys the command. +pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str, params: Option) { + let on = params.is_some() && crate::menus::is_enabled(app, id); + if ui.add_enabled(on, egui::Button::new(label)).clicked() + && let Some(params) = params + { let ctx = ui.ctx().clone(); - if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { app.ui.status = e; app.ui.status_error = true; } diff --git a/crates/ui-egui/src/panels/panel_docking.rs b/crates/ui-egui/src/panels/panel_docking.rs index ebddecde2c..6029ae52b4 100644 --- a/crates/ui-egui/src/panels/panel_docking.rs +++ b/crates/ui-egui/src/panels/panel_docking.rs @@ -1206,6 +1206,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::histogram_panel::panel_menu(app, ui); ui.separator(); } + "channels" => { + crate::channels_panel::panel_menu(app, ui); + ui.separator(); + } _ => {} } } diff --git a/crates/ui-egui/src/panels/panel_docking/tests.rs b/crates/ui-egui/src/panels/panel_docking/tests.rs index fb2fb9bd79..886e6f0f8a 100644 --- a/crates/ui-egui/src/panels/panel_docking/tests.rs +++ b/crates/ui-egui/src/panels/panel_docking/tests.rs @@ -763,6 +763,46 @@ fn actions_panel_menu_includes_load_and_save_actions() { assert!(menu.query_all_by_label("New Action…").count() > 0); } +/// The Channels panel menu (☰) has the channel items, not only the rows' context menu (#11451): +/// New Spot Channel… and New Channel… open their dialogs, and OK in New Channel adds a channel. +#[test] +fn channels_panel_menu_opens_new_spot_and_adds_a_channel() { + use egui_kittest::kittest::NodeT; + let mut app = self::app(); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut ready = false; + let mut menu = Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + } + panel_menu(app, ui, "channels"); + }, + app, + ); + menu.run_steps(2); + let disabled = |menu: &Harness<'static, PhotocraftApp>, label: &str| menu.get_by_label(label).accesskit_node().is_disabled(); + // The composite is targeted: nothing to duplicate, delete or give options to. + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(disabled(&menu, label), "{label} waits for a targeted channel"); + } + menu.get_by_label("New Spot Channel…").click(); + menu.run_steps(2); + let dialogs = &menu.state().ui.dialogs; + assert!(dialogs.iter().any(|d| crate::channel_options_ui::owns_new_spot(&d.fields)), "New Spot Channel… opens its dialog"); + menu.state_mut().ui.dialogs.clear(); + menu.get_by_label("New Channel…").click(); + menu.run_steps(2); + assert!(menu.state().session.active().unwrap().doc.channels.is_empty(), "New Channel… asks first"); + let dialog = menu.state().ui.dialogs.last().expect("the New Channel dialog"); + assert_eq!(dialog.fields["__label"], json!("New Channel…")); + let id = dialog.id; + crate::dialogs::confirm(menu.state_mut(), id).unwrap(); + assert_eq!(menu.state().session.active().unwrap().doc.channels.len(), 1, "OK adds an alpha channel"); + assert!(!menu.state().ui.status_error, "{}", menu.state().ui.status); +} + #[test] fn custom_workspace_saves_shared_layout_and_timeline_without_embedding_flags() { let mut app = app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80664d314b..7031163bfb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -263,11 +263,13 @@ Paragraph; the rest open as in-app floating windows. | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes; with a row selected, the Delete button and the ⌫/Delete key remove that filter, not the smart object | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271), `smart_ui::keys` (#4751) | | Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | +| Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…; the single-channel items act on the targeted row (a colour, alpha or spot channel, Quick Mask, the layer mask, a one-channel image's composite) and are greyed without one; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… (only the modes the open grayscale documents fill, a different document per channel; greyed below three documents) open their dialogs, here and in the rows' menu, and Split Channels runs at once | done (2026-11-06) | `channels_panel.rs` `panel_menu`, `channels_panel/tests.rs`, `panel_docking/tests.rs`, `dialogs/merge_channels_ui.rs`; #11451. Panel Options (thumbnail size) to come | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Channels panel menu (☰): the channel items, not only the rows' context menu; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs (New Channel and Merge Channels are new), and Channel Options… in Quick Mask mode opens a targeted alpha channel's options (#11451). No percentage changed | | 2026-11-02 | minor | Edit › Transform › Distort: the side handles move their edge freely, both its corners by the drag, stopping before the box folds as a corner does; Perspective's side handles skew along their edge, as in Photoshop (#10085). Not a new command, so no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | | 2026-10-31 | minor | Layers panel filter-type dropdown: Color filters by the layers' label colour (#5898); Kind, Name, Mode, Color, Smart Object and Artboard, the other three missing; no percentage changed |