diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 11dd013b59..a2843b1ab8 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -23,6 +23,7 @@ use crate::widgets; const PROXY_SIDE: usize = 1600; const LEFT_W: f32 = 48.0; const RIGHT_W: f32 = 292.0; +const REDO_STACK_LIMIT: usize = 100; /// Options shown in the properties panel (and settable over the control channel). #[derive(Clone, Debug)] @@ -123,6 +124,7 @@ pub struct LiquifyDialog { cell: f64, pub field: LiquifyField, pub strokes: Vec, + redo: Vec, /// The stroke being drawn and its last point. cur: Option<(LiquifyStroke, [f64; 3])>, proxy: ProxyImage, @@ -160,6 +162,7 @@ impl LiquifyDialog { "tool": self.opts.tool, "size": self.opts.size, "strokes": self.strokes.len() + usize::from(self.cur.is_some()), + "redo": self.redo.len(), "meshSize": self.cell, "maxDisplacement": self.field.max_displacement(), "proxy": [self.proxy.w, self.proxy.h], @@ -189,6 +192,7 @@ impl LiquifyDialog { } fn begin(&mut self, p: [f64; 3], now: f64) { + self.redo.clear(); let mut s = self.template(); s.points.push(p.to_vec()); let t0 = crate::gpu_canvas::now_ms(); @@ -221,6 +225,7 @@ impl LiquifyDialog { /// Applies a whole-field operation (Reconstruct…, mask buttons) as a stroke. fn global(&mut self, tool: LiquifyTool, amount: Option) { self.end(); + self.redo.clear(); let mut s = LiquifyStroke::new(tool, 1.0); s.amount = amount; let d = self.field.apply_stroke(&s); @@ -238,14 +243,29 @@ impl LiquifyDialog { fn undo(&mut self) { self.end(); - if self.strokes.pop().is_some() { + if let Some(stroke) = self.strokes.pop() { + if self.redo.len() == REDO_STACK_LIMIT { + self.redo.remove(0); + } + self.redo.push(stroke); self.rebuild(); } } + fn redo(&mut self) { + self.end(); + if let Some(stroke) = self.redo.pop() { + let d = self.field.apply_stroke(&stroke); + self.strokes.push(stroke); + self.mark(d, true); + self.render_dirty(); + } + } + fn restore_all(&mut self) { self.cur = None; self.strokes.clear(); + self.redo.clear(); self.rebuild(); } @@ -308,6 +328,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> cell, field: LiquifyField::new(canvas, cell), strokes: Vec::new(), + redo: Vec::new(), cur: None, proxy, out, @@ -377,6 +398,9 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if flag("undo") == Some(true) { d.undo(); } + if flag("redo") == Some(true) { + d.redo(); + } if flag("restoreAll") == Some(true) { d.restore_all(); } @@ -416,7 +440,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) { pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(d) = app.distort.liquify.as_mut() else { return }; - if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + d.redo(); + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { d.undo(); } if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) { @@ -826,6 +853,7 @@ mod tests { } let d = app.distort.liquify.as_ref().unwrap(); assert_eq!(d.strokes.len(), 2); + assert_eq!(d.redo.len(), 0); // Replaying the recorded strokes gives exactly the interactive field. let replay = LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes); assert_eq!(replay, d.field); @@ -833,6 +861,11 @@ mod tests { // Undo inside the dialog drops the last stroke. control(&mut app, &json!({"undo": true})).unwrap(); assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1); + assert_eq!(app.distort.liquify.as_ref().unwrap().redo.len(), 1); + control(&mut app, &json!({"redo": true})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 2); + assert_eq!(app.distort.liquify.as_ref().unwrap().redo.len(), 0); + control(&mut app, &json!({"undo": true})).unwrap(); let before = app.session.active().unwrap().history.past_len(); control(&mut app, &json!({"commit": true})).unwrap(); assert!(app.distort.liquify.is_none()); @@ -867,6 +900,52 @@ mod tests { assert_eq!(d.out, expected, "dirty upload source must match the rebuilt field"); } + #[test] + fn liquify_redo_restores_undone_stroke_and_a_new_stroke_clears_redo() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap(); + let draw = |app: &mut PhotocraftApp, x: f64| { + for ev in + [ToolEvent::Down { x, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: x + 14.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: x + 14.0, y: 40.0 }] + { + pointer(app, ev, egui::Modifiers::NONE); + } + }; + + draw(&mut app, 30.0); + draw(&mut app, 70.0); + control(&mut app, &json!({"undo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!((d.strokes.len(), d.redo.len()), (1, 1)); + + control(&mut app, &json!({"redo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!((d.strokes.len(), d.redo.len()), (2, 0)); + assert_eq!(LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes), d.field); + + control(&mut app, &json!({"undo": true})).unwrap(); + draw(&mut app, 50.0); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!((d.strokes.len(), d.redo.len()), (2, 0)); + control(&mut app, &json!({"redo": true})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 2, "redo after a new stroke is a no-op"); + } + + #[test] + fn liquify_redo_stack_is_bounded() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + let d = app.distort.liquify.as_mut().unwrap(); + d.strokes = (0..=REDO_STACK_LIMIT).map(|_| LiquifyStroke::new(LiquifyTool::ForwardWarp, 1.0)).collect(); + for _ in 0..=REDO_STACK_LIMIT { + d.undo(); + } + assert_eq!(d.redo.len(), REDO_STACK_LIMIT); + } + #[test] fn shortcut_handler_undoes_liquify_even_when_egui_owns_keyboard_focus() { let ctx = egui::Context::default(); @@ -890,6 +969,20 @@ mod tests { }); out.textures_delta.clear(); assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 0); + let raw = egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Z, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, + }], + ..Default::default() + }; + let _ = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + }); + assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1, "Cmd+Shift+Z redoes the last stroke"); } #[test] diff --git a/docs/scorecard.md b/docs/scorecard.md index e66ef87c90..5bcc897e98 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 12 | 46 scenarios: 3 within budget, 22 over budget or failing, 13 tracked only, 4 not run, 8 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 | -| [Tools](#tools) | 3 | 14 | 13 | | +| [Tools](#tools) | 4 | 14 | 12 | | | [Workspace and UI](#workspace-and-ui) | 0 | 4 | 19 | settings that do nothing: 60 of 134 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) | @@ -149,7 +149,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | -| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs has undo only | +| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | ## Workspace and UI diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 93f05f408d..ff1b797b0e 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -211,6 +211,6 @@ note = "Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dial [[item]] id = "TOOL-214-16" target = "Liquify: redo (Cmd+Shift+Z)" -status = "missing" +status = "done" issue = 214 -note = "liquify_ui.rs has undo only" +note = "liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo"