collective

Browse / github.com/storytold/photocraft / patch

fix(ui-egui): the rasterize prompt no longer replays the click that opened it (#8806)

[Bug]: Tools perform unintended actions after confirming Smart Object rasterization ## Description When attempting to use a tool on a Smart Object, PhotoCraft displays a confirmation dialog asking whether to rasterize the Smart Object. After confirming the dialog, an unintended action occurs at the position where the user initially clicked on the canvas, before the dialog appeared. This issue has been reproduced with the Brush Tool, Eraser Tool, and Paint Bucket Tool. ## Steps to reproduce 1. Open a document containing a Smart Object. 2. Select the Brush Tool, Eraser Tool, or Paint Bucket Tool and click on the Smart Object. 3. When the rasterization confirmation dialog a

Status
open
Score
0.00
Adopters
0 other operators
Author
Higgs32584 (imported from pull request #9864, open)
Base
07bd794686ea4db0b557c978d58173a62760e4ca
Size
+27 −32 in 5 files
Review
pending
Shared
2026-10-28
Id
34ebe90d72e7a4b2e45ce822

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/dialogs/rasterize_prompt.rs crates/ui-egui/src/dialogs/rasterize_prompt/tests.rs docs/control-protocol.md docs/scorecard.md scorecard/tools.toml

diff --git a/crates/ui-egui/src/dialogs/rasterize_prompt.rs b/crates/ui-egui/src/dialogs/rasterize_prompt.rs--- a/crates/ui-egui/src/dialogs/rasterize_prompt.rs+++ b/crates/ui-egui/src/dialogs/rasterize_prompt.rs@@ -4,9 +4,9 @@ //! with a status-bar message. //! //! The prompt is an ordinary dialog in the UI state (`ui.inspect` shows it, `ui.dialog.confirm`-//! and `ui.dialog.cancel` answer it). OK runs the matching `layer.rasterize.*` command, then paints-//! where the click was, so rasterizing and painting are two history states, as in Photoshop.-//! Cancel does nothing.+//! and `ui.dialog.cancel` answer it). OK runs the matching `layer.rasterize.*` command and nothing+//! else: the click that opened the prompt is not replayed, as in Photoshop (#8806), so the next+//! click paints. Cancel does nothing. //! //! A filter on a type layer asks the same question (#4303), with Photoshop's Convert to Smart //! Object / Rasterize / Cancel: the layer is converted or rasterized, then the filter is chosen@@ -16,7 +16,6 @@ use serde_json::{Map, Value, json};  use crate::PhotocraftApp;-use crate::canvas::ToolEvent; use crate::state::{DialogKind, Tool};  const MARK: &str = "__rasterize";@@ -112,7 +111,7 @@ pub fn intercept(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64, pressure:     true } -/// Open the prompt for `layer`; OK rasterizes it, then `tool` paints at `at` (x, y, pressure).+/// Open the prompt for `layer`; OK rasterizes it. `tool` and `at` (x, y, pressure) only record the press that opened it. pub fn open(app: &mut PhotocraftApp, kind: Kind, layer: photocraft_doc::LayerId, tool: Tool, at: [f64; 3]) -> u64 {     let mut f = Map::new();     f.insert(MARK.into(), json!(kind.key()));@@ -198,7 +197,7 @@ pub fn body(ui: &mut egui::Ui, f: &Map<String, Value>) {     }); } -/// OK: rasterize, then paint at the click (two history states).+/// OK: rasterize the layer. The click that opened the prompt does not paint. pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {     let kind = f.get(MARK).and_then(Value::as_str).and_then(Kind::from_key).ok_or("not a rasterize prompt")?;     let layer = f.get("layer").and_then(Value::as_u64).ok_or("the prompt has no layer")?;@@ -206,21 +205,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,         return confirm_filter(app, f, filter, layer);     }     let r = app.run(kind.command(), json!({ "layer": layer }))?;-    let tool = f.get("tool").and_then(Value::as_str).and_then(Tool::from_name);-    let at: Vec<f64> = f.get("at").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();-    let (Some(tool), Some(&x), Some(&y)) = (tool, at.first(), at.get(1)) else {-        return Ok(json!({ "rasterized": r, "painted": false }));-    };-    let pressure = at.get(2).copied().unwrap_or(1.0) as f32;-    // The same tool, layer and point as when the prompt opened.-    app.ui.tool = tool;-    let before = app.session.active().map(|st| st.history.past_len());-    if app.session.active().and_then(|st| st.active_layer) == Some(photocraft_doc::LayerId(layer)) && x.is_finite() && y.is_finite() {-        crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: pressure.clamp(0.0, 1.0) }, egui::Modifiers::NONE);-        crate::canvas::tool_event(app, ToolEvent::Up { x, y }, egui::Modifiers::NONE);-    }-    let painted = app.session.active().map(|st| st.history.past_len()) != before;-    Ok(json!({ "rasterized": r, "painted": painted }))+    // The press that opened the prompt is not replayed: Photoshop only converts the layer, the+    // next click paints (#8806).+    Ok(json!({ "rasterized": r })) }  /// The filter prompt's answer: convert or rasterize the layer, then queue the filter fordiff --git a/crates/ui-egui/src/dialogs/rasterize_prompt/tests.rs b/crates/ui-egui/src/dialogs/rasterize_prompt/tests.rs--- a/crates/ui-egui/src/dialogs/rasterize_prompt/tests.rs+++ b/crates/ui-egui/src/dialogs/rasterize_prompt/tests.rs@@ -3,7 +3,7 @@ use photocraft_doc::LayerContent; use serde_json::json;  use super::*;-use crate::canvas::tool_event;+use crate::canvas::{ToolEvent, tool_event};  /// A document whose active layer is a `kind` layer over a white Background. fn app_with(kind: Kind) -> PhotocraftApp {@@ -46,8 +46,10 @@ fn prompt(app: &PhotocraftApp) -> Option<&crate::state::Dialog> {     app.ui.dialogs.iter().find(|d| owns(&d.fields)) } +/// #8806: OK on the prompt rasterizes and adds one history state; the held click is not+/// replayed, and the next click paints with no prompt. #[test]-fn ok_rasterizes_then_paints_as_two_history_states() {+fn ok_rasterizes_without_replaying_the_click() {     for kind in Kind::ALL {         for tool in [Tool::Brush, Tool::Pencil] {             let mut app = app_with(kind);@@ -59,12 +61,16 @@ fn ok_rasterizes_then_paints_as_two_history_states() {             assert_eq!(past(&app), before, "nothing happens until the answer");             let id = d.id;             let r = crate::dialogs::confirm(&mut app, id).unwrap();-            assert_eq!(r["painted"], json!(true), "{kind:?}/{tool:?}");+            assert!(r.get("rasterized").is_some());             assert_eq!(active_content(&app), "Pixel");-            assert_eq!(past(&app), before + 2, "{kind:?}/{tool:?}: rasterize, then paint");+            assert_eq!(past(&app), before + 1, "{kind:?}/{tool:?}: rasterize only, the held click is not replayed");+            // The held click left no paint; the next click paints.             let st = app.session.active().unwrap();             let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(30, 20);-            assert!(c[2] > 0.99 && c[0] < 0.01, "{kind:?}/{tool:?}: painted blue at the click: {c:?}");+            assert!(c[2] < 0.5 || c[0] > 0.5, "{kind:?}/{tool:?}: nothing painted by the held click: {c:?}");+            click(&mut app, 30.0, 20.0);+            assert!(prompt(&app).is_none());+            assert_eq!(past(&app), before + 2, "{kind:?}/{tool:?}: the next click paints");             // One undo takes the paint back; the layer stays rasterized.             app.session.undo();             assert_eq!(active_content(&app), "Pixel");@@ -127,6 +133,8 @@ fn only_pixel_painting_asks() {     assert!(app.session.active().unwrap().doc.layers.iter().any(|l| matches!(l.content, LayerContent::Text(_)))); } +/// A prompt with missing, stale or malformed fields returns an error instead of panicking; a+/// bad tool or point still just rasterizes. #[test] fn a_stale_or_bad_prompt_fails_gracefully() {     let mut app = app_with(Kind::Type);@@ -141,13 +149,13 @@ fn a_stale_or_bad_prompt_fails_gracefully() {         let id = app.ui.open_dialog(crate::state::DialogKind::Command, f.as_object().unwrap().clone());         assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{f}");     }-    // A bad tool or point rasterizes without painting.+    // A bad tool or point still just rasterizes.     let mut app = app_with(Kind::Fill);     let layer = app.session.active().unwrap().active_layer.unwrap();     let id = open(&mut app, Kind::Fill, layer, Tool::Brush, [f64::NAN, 1.0, 1.0]);     app.ui.dialog_mut(id).unwrap().fields.insert("tool".into(), json!("no such tool"));     let r = crate::dialogs::confirm(&mut app, id).unwrap();-    assert_eq!(r["painted"], json!(false));+    assert!(r.get("rasterized").is_some());     assert_eq!(active_content(&app), "Pixel"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -74,7 +74,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i   - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart.   - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).   - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized.-  - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.+  - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command only (`{"rasterized"}`; the click that opened the prompt is not replayed, as in Photoshop); `ui.dialog.cancel` does nothing.   - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point.diff --git a/docs/scorecard.md b/docs/scorecard.md--- a/docs/scorecard.md+++ b/docs/scorecard.md@@ -143,7 +143,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | | TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, #4385); a selection over 256x256 shows a coarse result solved scaled down first, then the command's exact result as a second job (#6680; 24 MP document, release: 600x400 coarse 0.26 s then exact 0.8 s, 1500x1000 0.38 s then 4.1 s, 3000x2000 0.69 s then 9.4 s; was no preview above 256x256). Tests: content_aware_move_previews_large_selections_coarse_then_exact_at_all_depths, content_aware_move_coarse_preview_handles_transform_extend_and_limits, a_preview_within_the_full_resolution_budget_is_the_plain_solve_bit_for_bit, a_large_selection_shows_a_coarse_result_then_the_exact_one |-| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |+| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize; OK converts it and the next click paints | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes only, the click that opened the prompt is not replayed (as in Photoshop, #8806), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Mixer Brush, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Mixer Brush uses its incremental transaction and commits the displayed result (#3627) | | TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) |diff --git a/scorecard/tools.toml b/scorecard/tools.toml--- a/scorecard/tools.toml+++ b/scorecard/tools.toml@@ -35,10 +35,10 @@ note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend,  [[item]] id = "TOOL-213-5"-target = "Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke"+target = "Painting a type/shape/smart/fill layer offers to rasterize; OK converts it and the next click paints" status = "done" issue = 213-note = "rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm"+note = "rasterize_prompt.rs: OK rasterizes only, the click that opened the prompt is not replayed (as in Photoshop, #8806), Cancel does nothing; drivable via ui.dialog.confirm"  [[item]] id = "TOOL-213-6"