diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 709421a..dfef8a7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -6,14 +6,21 @@ use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; use super::*; +use crate::undoctl::Control; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { vec![ // U undoes the last operation at once (Edit > Undo, Cmd+Z); UNDO asks how many. CommandSpec::new("u", "Undo", run_u).menu(&["Edit", "Undo"]).key("Cmd+Z").params("{}").noundo(), - CommandSpec::new("undo", "Undo", run_undo).params("{count?: n}").noundo().interactive(|_| Ok(Box::new(UndoM::default()))), - CommandSpec::new("redo", "Redo", run_redo).menu(&["Edit", "Redo"]).key("Cmd+Shift+Z").alias(&["mredo"]).params("{count?: n}").noundo(), + CommandSpec::new("undo", "Undo", run_undo) + .params( + "{count?: n} | {mode: mark|begin|end} | {mode: back, confirm?: bool (no mark: undo everything; default true)} | {mode: auto, on: bool} | {mode: control, control: all|none|one|combine|layer, on?: bool (combine/layer)}", + ) + .noundo() + .interactive(|_| Ok(Box::new(UndoM::default()))), + CommandSpec::new("redo", "Redo", run_redo).menu(&["Edit", "Redo"]).key("Cmd+Shift+Z").params("{count?: n}").noundo(), + CommandSpec::new("mredo", "Redo", run_mredo).params("{count?: n} | {all: true}").noundo().interactive(|_| Ok(Box::new(MredoM))), CommandSpec::new("cutclip", "Cut", run_cut) .menu(&["Edit", "Cut"]) .key("Cmd+X") @@ -67,82 +74,310 @@ fn end_running_command(s: &mut Session) { } } +/// The UNDO options of the JSON form (`mode`) and the command line (keywords). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UndoMode { + Count, + Mark, + Back, + Begin, + End, + Auto, + Control, +} + +fn undo_mode(m: &str) -> Option { + Some(match m.trim().to_ascii_lowercase().as_str() { + "" | "count" => UndoMode::Count, + "mark" => UndoMode::Mark, + "back" => UndoMode::Back, + "begin" | "group" => UndoMode::Begin, + "end" => UndoMode::End, + "auto" => UndoMode::Auto, + "control" => UndoMode::Control, + _ => return None, + }) +} + +fn on_param(p: &Value, key: &str) -> Option { + let v = p.get(key)?; + v.as_bool().or_else(|| v.as_i64().map(|i| i != 0)).or_else(|| match v.as_str()?.trim().to_ascii_lowercase().as_str() { + "on" | "yes" | "y" | "true" | "1" => Some(true), + "off" | "no" | "n" | "false" | "0" => Some(false), + _ => None, + }) +} + +/// The JSON result of an UNDO option: `message` plus the new UNDOCTL bits and mark count. +fn undo_state(s: &Session, message: impl Into) -> Result { + let st = s.state()?; + Ok(json!({ "message": message.into(), "undoctl": st.undo_ctl.bits(), "marks": st.undo_marks() })) +} + +fn undo_off() -> EngineError { + EngineError::Other("Undo is turned off (UNDO Control None); UNDO Control All turns it on.".into()) +} + fn run_undo(s: &mut Session, p: &Value) -> Result { end_running_command(s); - let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000); + let mode = match p.get("mode") { + None | Some(Value::Null) => UndoMode::Count, + Some(v) => v.as_str().and_then(undo_mode).ok_or_else(|| bad("undo", "mode: count, mark, back, begin, end, auto or control"))?, + }; + let control = s.state()?.undo_ctl.control; + if control != Control::All && !matches!(mode, UndoMode::Count | UndoMode::Control | UndoMode::Auto) { + return Err(EngineError::Other(format!("UNDO {mode:?} needs UNDO Control All."))); + } + match mode { + UndoMode::Count => { + if control == Control::None { + return Err(undo_off()); + } + let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000); + // Undoing inside a BEgin group ends it: the whole group is undone as one step. + s.state_mut()?.end_undo_group(); + let mut labels = Vec::new(); + for _ in 0..n { + match s.undo()? { + Some(l) => labels.push(l), + None => break, + } + } + if labels.is_empty() { + return Ok(json!({ "message": "Everything has been undone" })); + } + Ok(json!({ "undone": labels, "message": labels.join(", ").to_string() })) + } + UndoMode::Mark => { + s.state_mut()?.undo_mark(); + undo_state(s, "Mark set") + } + UndoMode::Back => undo_back(s, on_param(p, "confirm").unwrap_or(true)), + UndoMode::Begin => { + let ended = s.state_mut()?.begin_undo_group(); + undo_state(s, if ended { "Group ended; new group begun" } else { "Group begun" }) + } + UndoMode::End => { + let ended = s.state_mut()?.end_undo_group(); + undo_state(s, if ended { "Group ended" } else { "No group to end" }) + } + UndoMode::Auto => { + let on = on_param(p, "on").ok_or_else(|| bad("undo", "`on` (true/false) is required for mode auto"))?; + s.state_mut()?.undo_ctl.auto = on; + undo_state(s, format!("UNDO Auto {}", if on { "on" } else { "off" })) + } + UndoMode::Control => { + let c = str_param(p, "control").unwrap_or("all").trim().to_ascii_lowercase(); + let st = s.state_mut()?; + let msg = match c.as_str() { + "all" => { + st.set_undo_control(Control::All); + "UNDO Control All" + } + "none" => { + st.set_undo_control(Control::None); + "UNDO Control None: undo is off" + } + "one" => { + st.set_undo_control(Control::One); + "UNDO Control One: only the last step can be undone" + } + "combine" => { + st.undo_ctl.combine_view = on_param(p, "on").unwrap_or(true); + "UNDO Control Combine set" + } + "layer" => { + st.undo_ctl.combine_layer = on_param(p, "on").unwrap_or(true); + "UNDO Control Layer set" + } + _ => return Err(bad("undo", "control: all, none, one, combine or layer")), + }; + undo_state(s, msg) + } + } +} + +/// UNDO Back: undo to the last mark (which goes with it), or with no mark everything when +/// `confirm` is true. +fn undo_back(s: &mut Session, confirm: bool) -> Result { + let st = s.state_mut()?; + st.end_undo_group(); + let depth = st.undo.len(); + st.undo_ctl.marks.retain(|m| *m <= depth); + let target = match st.undo_ctl.marks.pop() { + Some(m) => m, + None if confirm => 0, + None => return undo_state(s, "Nothing undone"), + }; let mut labels = Vec::new(); - for _ in 0..n { + // Each step pops one entry, so this ends within the stack's depth. + while s.state()?.undo.len() > target { match s.undo()? { Some(l) => labels.push(l), None => break, } } - if labels.is_empty() { - return Ok(json!({ "message": "Everything has been undone" })); - } - Ok(json!({ "undone": labels, "message": labels.join(", ").to_string() })) + let msg = match (labels.is_empty(), target) { + (true, 0) => "Everything has been undone".to_string(), + (true, _) => "Back at the mark".to_string(), + (false, _) => labels.join(", "), + }; + let mut r = undo_state(s, msg)?; + r["undone"] = json!(labels); + Ok(r) } fn run_u(s: &mut Session, _p: &Value) -> Result { run_undo(s, &json!({})) } -/// UNDO at the command line: "Enter the number of operations to undo" (default 1). Its other -/// options are not available yet; Auto and Control still take their value, so it isn't run as a -/// command. +/// What the UNDO prompt machine asks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum UndoAsk { + /// "Enter the number of operations to undo or [Auto/Control/BEgin/End/Mark/Back]". + #[default] + Main, + Auto, + Control, + Combine, + Layer, + /// Back with no mark: undo everything? + BackAll, +} + +/// UNDO at the command line: a number of operations (default 1) or Auto, Control (All, None, One, +/// Combine, Layer), BEgin, End, Mark or Back. With Control None only Control is offered, with One +/// a count or Control. #[derive(Default)] struct UndoM { - /// Auto or Control was chosen: its value is being asked for. - option: Option<&'static str>, + ask: UndoAsk, +} + +impl UndoM { + fn finish(s: &mut Session, r: Result) -> Result { + match r { + Ok(v) => { + if let Some(m) = v.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + } + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Done) + } } impl Interactive for UndoM { fn name(&self) -> &'static str { "UNDO" } - fn prompt(&self, _s: &Session) -> Prompt { - match self.option { - Some("Auto") => Prompt::new("Enter UNDO Auto mode", Accept::TEXT).kw(&["ON", "OFF"]).default("ON"), - Some(_) => Prompt::new("Enter an UNDO control option", Accept::TEXT).kw(&["All", "None", "One", "Combine", "Layer"]).default("All"), - None => Prompt::new("Enter the number of operations to undo", Accept::NUMBER) + fn prompt(&self, s: &Session) -> Prompt { + let control = s.state().map(|st| st.undo_ctl.control).unwrap_or_default(); + let ask = if control == Control::None && self.ask == UndoAsk::Main { UndoAsk::Control } else { self.ask }; + let yes_no = |b: bool| if b { "Yes" } else { "No" }; + let ctl = s.state().map(|st| st.undo_ctl.clone()).unwrap_or_default(); + match ask { + UndoAsk::Auto => Prompt::new("Enter UNDO Auto mode", Accept::TEXT).kw(&["ON", "OFF"]).default(if ctl.auto { "ON" } else { "OFF" }), + UndoAsk::Control => { + Prompt::new("Enter an UNDO control option", Accept::TEXT).kw(&["All", "None", "One", "Combine", "Layer"]).default("All") + } + UndoAsk::Combine => { + Prompt::new("Combine zooms and pans into one undo step?", Accept::TEXT).kw(&["Yes", "No"]).default(yes_no(ctl.combine_view)) + } + UndoAsk::Layer => { + Prompt::new("Combine layer property changes into one undo step?", Accept::TEXT).kw(&["Yes", "No"]).default(yes_no(ctl.combine_layer)) + } + UndoAsk::BackAll => Prompt::new("No mark: undo everything?", Accept::TEXT).kw(&["Yes", "No"]).default("Yes"), + UndoAsk::Main if control == Control::One => { + Prompt::new("Enter the number of operations to undo", Accept::NUMBER).kw(&["Control"]).default("1") + } + UndoAsk::Main => Prompt::new("Enter the number of operations to undo", Accept::NUMBER) .kw(&["Auto", "Control", "BEgin", "End", "Mark", "Back"]) .default("1"), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(o) = self.option { - s.echo(format!("UNDO {o}: not available yet.")); - return Ok(Step::Done); + let prompt = self.prompt(s); + if s.state()?.undo_ctl.control == Control::None && self.ask == UndoAsk::Main { + self.ask = UndoAsk::Control; } - let n = match i { - Input::Text(t) => { - t.trim().parse::().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))? - } - Input::Enter => 1, - Input::Keyword(k) => { - match k.as_str() { - "Auto" => self.option = Some("Auto"), - "Control" => self.option = Some("Control"), - _ => { - s.echo(format!("UNDO {k}: not available yet.")); - return Ok(Step::Done); - } - } - return Ok(Step::Continue); - } + let kw = match &i { + Input::Cancel => return Ok(Step::Cancel), + Input::Enter => prompt.default.clone(), + Input::Keyword(k) | Input::Text(k) => prompt.match_keyword(k), _ => return Ok(Step::Continue), }; - let r = run_undo(s, &json!({ "count": n }))?; - if let Some(m) = r.get("message").and_then(Value::as_str) { - s.echo(m.to_string()); + let yes = kw.as_deref() == Some("Yes"); + match self.ask { + UndoAsk::Main => { + // Enter takes the default count, not a keyword. + if let Some(k) = kw.as_deref().filter(|_| !matches!(i, Input::Enter)) { + match k { + "Auto" => self.ask = UndoAsk::Auto, + "Control" => self.ask = UndoAsk::Control, + "Back" if s.state()?.undo_marks() == 0 => self.ask = UndoAsk::BackAll, + _ => { + let r = run_undo(s, &json!({ "mode": k.to_ascii_lowercase() })); + return Self::finish(s, r); + } + } + return Ok(Step::Continue); + } + let n = match &i { + Input::Text(t) => { + t.trim().parse::().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))? + } + _ => 1, + }; + let r = run_undo(s, &json!({ "count": n })); + Self::finish(s, r) + } + UndoAsk::Auto => { + let on = match kw.as_deref() { + Some("ON") => true, + Some("OFF") => false, + _ => return Err(EngineError::Other("Enter ON or OFF.".into())), + }; + let r = run_undo(s, &json!({ "mode": "auto", "on": on })); + Self::finish(s, r) + } + UndoAsk::Control => match kw.as_deref() { + Some("Combine") => { + self.ask = UndoAsk::Combine; + Ok(Step::Continue) + } + Some("Layer") => { + self.ask = UndoAsk::Layer; + Ok(Step::Continue) + } + Some(c) => { + let r = run_undo(s, &json!({ "mode": "control", "control": c.to_ascii_lowercase() })); + Self::finish(s, r) + } + None => Err(EngineError::Other("Enter All, None, One, Combine or Layer.".into())), + }, + UndoAsk::Combine | UndoAsk::Layer => { + if kw.is_none() { + return Err(EngineError::Other("Answer Yes or No.".into())); + } + let control = if self.ask == UndoAsk::Combine { "combine" } else { "layer" }; + let r = run_undo(s, &json!({ "mode": "control", "control": control, "on": yes })); + Self::finish(s, r) + } + UndoAsk::BackAll => { + if kw.is_none() { + return Err(EngineError::Other("Answer Yes or No.".into())); + } + let r = run_undo(s, &json!({ "mode": "back", "confirm": yes })); + Self::finish(s, r) + } } - Ok(Step::Done) } } -fn run_redo(s: &mut Session, p: &Value) -> Result { +/// Redo up to `n` undone steps. +fn redo_steps(s: &mut Session, n: u64) -> Result { end_running_command(s); - let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000); let mut labels = Vec::new(); for _ in 0..n { match s.redo()? { @@ -153,6 +388,52 @@ fn run_redo(s: &mut Session, p: &Value) -> Result { Ok(json!({ "redone": labels, "message": if labels.is_empty() { "Nothing to redo".to_string() } else { labels.join(", ") } })) } +fn run_redo(s: &mut Session, p: &Value) -> Result { + redo_steps(s, p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000)) +} + +/// MREDO: `{count}` steps, or `{all: true}` every undone step. +fn run_mredo(s: &mut Session, p: &Value) -> Result { + if p.get("all").and_then(Value::as_bool).unwrap_or(false) { + let n = s.state()?.redo.len() as u64; + return redo_steps(s, n.max(1)); + } + run_redo(s, p) +} + +/// MREDO at the command line: a number of actions, All or Last. +struct MredoM; + +impl Interactive for MredoM { + fn name(&self) -> &'static str { + "MREDO" + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Enter number of actions", Accept::NUMBER).kw(&["All", "Last"]) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let p = match &i { + Input::Cancel => return Ok(Step::Cancel), + Input::Enter => return Ok(Step::Done), + Input::Keyword(k) | Input::Text(k) => match self.prompt(s).match_keyword(k).as_deref() { + Some("All") => json!({ "all": true }), + Some(_) => json!({ "count": 1 }), + None => { + let n = + k.trim().parse::().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))?; + json!({ "count": n }) + } + }, + _ => return Ok(Step::Continue), + }; + let r = run_mredo(s, &p)?; + if let Some(m) = r.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + Ok(Step::Done) + } +} + fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { let d = s.doc()?; let ents: Vec = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0345d23..bc340c6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -267,6 +267,8 @@ fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&st st.saved = st.doc.clone(); st.path = Some(path.to_string()); st.title = file_name(path); + // A BEgin group still open when the drawing is saved ends there. + st.end_undo_group(); Ok(bytes.len()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ec2f1ec..2734b14 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod select; pub mod snap; pub mod spatial; pub mod sysvars; +pub mod undoctl; pub mod units; use std::sync::Arc; @@ -88,6 +89,8 @@ pub struct DocState { pub doc: Arc, pub undo: Vec, pub redo: Vec, + /// UNDO marks, BEgin/End group and Control (see [`undoctl`]). + pub undo_ctl: undoctl::UndoCtl, pub selection: Vec, pub previous_selection: Vec, pub path: Option, @@ -114,6 +117,7 @@ impl DocState { doc, undo: Vec::new(), redo: Vec::new(), + undo_ctl: undoctl::UndoCtl::default(), selection: Vec::new(), previous_selection: Vec::new(), path, @@ -698,12 +702,8 @@ impl Session { && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&doc, &st.doc) { - st.undo.push(Snapshot { label: spec.label.to_string(), doc, selection: sel }); - st.redo.clear(); + st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel }); st.revision += 1; - if st.undo.len() > 2000 { - st.undo.remove(0); - } } result } @@ -1033,8 +1033,7 @@ impl Session { if let Ok(st) = self.state_mut() && !Arc::ptr_eq(&run.before, &st.doc) { - st.undo.push(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before }); - st.redo.clear(); + st.push_undo(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before }); st.revision += 1; } } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index d2c8ce3..baf0566 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -18,6 +18,8 @@ const SESSION_VARS: &[&str] = &[ "SNAPMODE", "SNAPUNIT", "GRIDUNIT", + "UNDOCTL", + "UNDOMARKS", "GRIDMAJOR", "DYNMODE", "DYNPIFORMAT", @@ -96,6 +98,9 @@ pub fn get(s: &Session, name: &str) -> Option { }) .unwrap_or_default() ), + // UNDO state of the drawing: Control/Auto/group bits and the number of marks. + "UNDOCTL" => json!(s.state().ok()?.undo_ctl.bits()), + "UNDOMARKS" => json!(s.state().ok()?.undo_marks()), "VIEWCTR" => { let v = s.state().ok()?.view(); json!([v.center.x, v.center.y, 0.0]) @@ -138,7 +143,7 @@ fn as_pt(v: &Value) -> Option { } /// Variables `get` reports from session or drawing state that `set` can't change. -const READ_ONLY: &[&str] = &["CMDNAMES", "DWGNAME", "DBMOD", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX"]; +const READ_ONLY: &[&str] = &["CMDNAMES", "DWGNAME", "DBMOD", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"]; pub fn is_read_only(name: &str) -> bool { READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim())) diff --git a/crates/engine/src/undoctl.rs b/crates/engine/src/undoctl.rs new file mode 100644 index 0000000..398c1bd --- /dev/null +++ b/crates/engine/src/undoctl.rs @@ -0,0 +1,170 @@ +//! UNDO control: marks, BEgin/End groups and the Control/Auto settings of one drawing, kept on top +//! of its undo stack ([`DocState::undo`]). Every command records its step through +//! [`DocState::push_undo`], so marks, groups, Control and the stack limit apply to all of them +//! alike; a command never needs to know about them. + +use crate::{DocState, Snapshot}; + +/// The most undo steps a drawing keeps; the oldest are dropped first. +pub const MAX_UNDO: usize = 2000; +/// The most marks a drawing keeps; the oldest are dropped first. +const MAX_MARKS: usize = 1000; + +/// UNDO Control: how much undo information is kept. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Control { + /// Full undo (the default). + #[default] + All, + /// Undo is off: nothing is recorded. + None, + /// Only the last step can be undone. + One, +} + +/// The UNDO settings and markers of one drawing. +#[derive(Clone, Debug)] +pub struct UndoCtl { + pub control: Control, + /// UNDO Auto. Every command already records one undo step for all it does, so this is kept and + /// reported (UNDOCTL) only. + pub auto: bool, + /// UNDO Control Combine: consecutive zooms and pans as one step. View changes record no undo + /// steps, so this is kept and reported only. + pub combine_view: bool, + /// UNDO Control Layer: layer property changes as one step. Kept and reported only. + pub combine_layer: bool, + /// Undo-stack depths at each UNDO Mark, oldest first. + pub marks: Vec, + /// Undo-stack depth when the open BEgin group started. + pub group: Option, +} + +impl Default for UndoCtl { + fn default() -> Self { + UndoCtl { control: Control::All, auto: true, combine_view: true, combine_layer: true, marks: Vec::new(), group: None } + } +} + +impl UndoCtl { + /// The UNDOCTL bit code: 1 undo on, 2 only one step, 4 Auto, 8 a group is open, 16 combine + /// zooms and pans, 32 combine layer changes. + pub fn bits(&self) -> i64 { + let mut b = 0; + if self.control != Control::None { + b |= 1; + } + if self.control == Control::One { + b |= 2; + } + if self.auto { + b |= 4; + } + if self.group.is_some() { + b |= 8; + } + if self.combine_view { + b |= 16; + } + if self.combine_layer { + b |= 32; + } + b + } +} + +impl DocState { + /// Record an undo step (`snap` holds the drawing as it was before the change) and clear redo. + /// UNDO Control None records nothing and One keeps just this step; the stack holds at most + /// [`MAX_UNDO`] steps. + pub fn push_undo(&mut self, snap: Snapshot) { + self.redo.clear(); + match self.undo_ctl.control { + Control::None => { + self.undo.clear(); + return; + } + Control::One => { + self.undo.clear(); + self.undo.push(snap); + return; + } + Control::All => {} + } + // Marks above the current depth were undone; a new step makes them unreachable for good. + let depth = self.undo.len(); + self.undo_ctl.marks.retain(|m| *m <= depth); + if let Some(g) = self.undo_ctl.group + && g > depth + { + self.undo_ctl.group = Some(depth); + } + self.undo.push(snap); + if let Some(extra) = self.undo.len().checked_sub(MAX_UNDO).filter(|e| *e > 0) { + self.undo.drain(..extra); + for m in &mut self.undo_ctl.marks { + *m = m.saturating_sub(extra); + } + if let Some(g) = &mut self.undo_ctl.group { + *g = g.saturating_sub(extra); + } + } + } + + /// UNDO Mark at the current depth. Returns the number of marks. + pub fn undo_mark(&mut self) -> usize { + let depth = self.undo.len(); + let marks = &mut self.undo_ctl.marks; + marks.retain(|m| *m <= depth); + marks.push(depth); + if marks.len() > MAX_MARKS { + marks.remove(0); + } + marks.len() + } + + /// The marks still reachable from the current depth. + pub fn undo_marks(&self) -> usize { + let depth = self.undo.len(); + self.undo_ctl.marks.iter().filter(|m| **m <= depth).count() + } + + /// UNDO BEgin: the steps from here to UNDO End become one. BEgin while a group is open ends that + /// group first (returns true then). + pub fn begin_undo_group(&mut self) -> bool { + let ended = self.end_undo_group(); + self.undo_ctl.group = Some(self.undo.len()); + ended + } + + /// UNDO End (also on save): collapse the steps since BEgin into one undo step. Returns whether + /// a group was open; End with no open group does nothing. + pub fn end_undo_group(&mut self) -> bool { + let Some(start) = self.undo_ctl.group.take() else { return false }; + // The first step of the group holds the drawing as it was at BEgin. + if self.undo.len() > start { + self.undo.truncate(start + 1); + if let Some(first) = self.undo.get_mut(start) { + first.label = "Group".into(); + } + } + // States inside the group no longer exist. + self.undo_ctl.marks.retain(|m| *m <= start); + true + } + + /// UNDO Control: All, None (drops all undo information) or One (keeps only the last step). + /// Any open group and all marks are dropped when leaving All. + pub fn set_undo_control(&mut self, c: Control) { + self.undo_ctl.control = c; + if c == Control::All { + return; + } + self.undo_ctl.group = None; + self.undo_ctl.marks.clear(); + self.redo.clear(); + let keep = if c == Control::One { 1 } else { 0 }; + let drop = self.undo.len().saturating_sub(keep); + self.undo.drain(..drop); + } +} diff --git a/crates/engine/tests/undo_control.rs b/crates/engine/tests/undo_control.rs new file mode 100644 index 0000000..1a08006 --- /dev/null +++ b/crates/engine/tests/undo_control.rs @@ -0,0 +1,215 @@ +//! UNDO Mark/Back, BEgin/End groups, Auto and Control (All/None/One/Combine/Layer), UNDOCTL and +//! UNDOMARKS, REDO and MREDO counts: JSON form, command line and scripts. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use serde_json::{Value, json}; + +fn circles(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +fn circle(s: &mut Session, x: f64) { + s.execute("circle", &json!({ "center": [x, 0], "radius": 1 })).unwrap(); +} + +fn getvar(s: &mut Session, name: &str) -> Value { + s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone() +} + +#[test] +fn mark_and_back_undo_to_the_mark() { + let mut s = Session::new(); + let base = circles(&s); + circle(&mut s, 0.0); + s.execute("undo", &json!({ "mode": "mark" })).unwrap(); + assert_eq!(getvar(&mut s, "UNDOMARKS"), json!(1)); + circle(&mut s, 2.0); + circle(&mut s, 4.0); + let r = s.execute("undo", &json!({ "mode": "back" })).unwrap(); + assert_eq!(r["undone"].as_array().unwrap().len(), 2); + assert_eq!(circles(&s), base + 1, "back to the mark"); + assert_eq!(getvar(&mut s, "UNDOMARKS"), json!(0), "the mark goes with Back"); + // No mark left: Back asks first; refusing keeps the drawing. + s.execute("undo", &json!({ "mode": "back", "confirm": false })).unwrap(); + assert_eq!(circles(&s), base + 1); + s.execute("undo", &json!({ "mode": "back" })).unwrap(); + assert_eq!(circles(&s), base, "no mark: everything undone"); +} + +#[test] +fn back_at_the_command_line_and_marks_undone_away() { + let mut s = Session::new(); + let base = circles(&s); + s.cmdline("undo m").unwrap(); + circle(&mut s, 0.0); + s.cmdline("undo m").unwrap(); + circle(&mut s, 2.0); + // Undoing past the second mark (then drawing again) drops it; the first stays. + s.execute("u", &json!({})).unwrap(); + s.execute("u", &json!({})).unwrap(); + circle(&mut s, 3.0); + assert_eq!(getvar(&mut s, "UNDOMARKS"), json!(1)); + circle(&mut s, 5.0); + s.cmdline("undo b").unwrap(); + assert_eq!(circles(&s), base, "back to the first mark"); + // Without a mark the command line asks; No keeps everything. + circle(&mut s, 0.0); + s.cmdline("undo b").unwrap(); + assert!(s.running.is_some(), "asks before undoing everything"); + s.cmdline("n").unwrap(); + assert_eq!(circles(&s), base + 1); + s.cmdline("undo b y").unwrap(); + assert_eq!(circles(&s), base); +} + +#[test] +fn begin_end_group_is_one_undo_step() { + let mut s = Session::new(); + let base = circles(&s); + let depth = s.state().unwrap().undo.len(); + s.execute("undo", &json!({ "mode": "begin" })).unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL").as_i64().unwrap() & 8, 8, "group open"); + for x in 0..3 { + circle(&mut s, f64::from(x) * 2.0); + } + s.execute("undo", &json!({ "mode": "end" })).unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL").as_i64().unwrap() & 8, 0); + assert_eq!(s.state().unwrap().undo.len(), depth + 1, "one step"); + let r = s.execute("u", &json!({})).unwrap(); + assert_eq!(r["undone"], json!(["Group"])); + assert_eq!(circles(&s), base); + s.execute("redo", &json!({})).unwrap(); + assert_eq!(circles(&s), base + 3); + // End without BEgin does nothing. + s.execute("undo", &json!({ "mode": "end" })).unwrap(); + assert_eq!(circles(&s), base + 3); +} + +#[test] +fn group_from_a_script_and_nested_begin() { + let mut s = Session::new(); + let base = circles(&s); + let depth = s.state().unwrap().undo.len(); + s.script("UNDO BE\nCIRCLE 0,0 1\nUNDO BE\nCIRCLE 3,0 1\nCIRCLE 6,0 1\nUNDO E\n").unwrap(); + // BEgin inside a group ends that group and starts a new one. + assert_eq!(s.state().unwrap().undo.len(), depth + 2); + s.cmdline("undo 1").unwrap(); + assert_eq!(circles(&s), base + 1); + // UNDO inside an open group undoes the whole group so far. + s.cmdline("undo be").unwrap(); + circle(&mut s, 9.0); + circle(&mut s, 12.0); + s.cmdline("u").unwrap(); + assert_eq!(circles(&s), base + 1); + assert_eq!(getvar(&mut s, "UNDOCTL").as_i64().unwrap() & 8, 0, "the group was ended"); +} + +#[test] +fn saving_closes_an_open_group() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let mut s = Session::new(); + let depth = s.state().unwrap().undo.len(); + s.execute("undo", &json!({ "mode": "begin" })).unwrap(); + circle(&mut s, 0.0); + circle(&mut s, 2.0); + let path = std::env::temp_dir().join(format!("cadcraft-undo-group-{}.dxf", std::process::id())); + s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(getvar(&mut s, "UNDOCTL").as_i64().unwrap() & 8, 0, "saving ends the group"); + assert_eq!(s.state().unwrap().undo.len(), depth + 1); +} + +#[test] +fn control_none_and_one() { + let mut s = Session::new(); + circle(&mut s, 0.0); + s.execute("undo", &json!({ "mode": "control", "control": "none" })).unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL").as_i64().unwrap() & 1, 0); + assert!(s.state().unwrap().undo.is_empty(), "None discards undo information"); + circle(&mut s, 2.0); + assert!(s.state().unwrap().undo.is_empty(), "nothing recorded"); + assert!(s.execute("u", &json!({})).is_err(), "U says undo is off"); + assert!(s.execute("undo", &json!({ "mode": "mark" })).is_err()); + // With undo off the command line asks for the control option straight away. + s.cmdline("undo one").unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL").as_i64().unwrap() & 3, 3); + let before = circles(&s); + circle(&mut s, 4.0); + circle(&mut s, 6.0); + assert_eq!(s.state().unwrap().undo.len(), 1, "One keeps a single step"); + s.cmdline("undo 5").unwrap(); + assert_eq!(circles(&s), before + 1); + s.cmdline("undo c a").unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL").as_i64().unwrap() & 3, 1); + circle(&mut s, 8.0); + circle(&mut s, 10.0); + assert_eq!(s.state().unwrap().undo.len(), 2); +} + +#[test] +fn auto_combine_and_layer_set_undoctl() { + let mut s = Session::new(); + assert_eq!(getvar(&mut s, "UNDOCTL"), json!(53)); + s.cmdline("undo a off").unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL"), json!(49)); + s.execute("undo", &json!({ "mode": "auto", "on": true })).unwrap(); + s.cmdline("undo c c n").unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL"), json!(37)); + s.execute("undo", &json!({ "mode": "control", "control": "layer", "on": false })).unwrap(); + assert_eq!(getvar(&mut s, "UNDOCTL"), json!(5)); + // Read-only. + assert!(s.execute("setvar", &json!({ "name": "UNDOCTL", "value": 1 })).is_err()); + assert!(s.execute("setvar", &json!({ "name": "UNDOMARKS", "value": 1 })).is_err()); + assert!(s.execute("undo", &json!({ "mode": "sideways" })).is_err()); + assert!(s.execute("undo", &json!({ "mode": "auto" })).is_err(), "auto needs on"); +} + +#[test] +fn redo_and_mredo_counts() { + let mut s = Session::new(); + let base = circles(&s); + for x in 0..4 { + circle(&mut s, f64::from(x) * 2.0); + } + s.execute("undo", &json!({ "count": 4 })).unwrap(); + assert_eq!(circles(&s), base); + s.execute("redo", &json!({ "count": 2 })).unwrap(); + assert_eq!(circles(&s), base + 2); + s.execute("mredo", &json!({ "all": true })).unwrap(); + assert_eq!(circles(&s), base + 4); + s.cmdline("undo 3").unwrap(); + s.cmdline("mredo 2").unwrap(); + assert_eq!(circles(&s), base + 3); + s.cmdline("undo 3").unwrap(); + s.cmdline("mredo l").unwrap(); + assert_eq!(circles(&s), base + 1); + s.cmdline("mredo a").unwrap(); + assert_eq!(circles(&s), base + 4); + assert!(s.cmdline("mredo -1").is_ok(), "a bad count re-prompts"); + s.cancel(); +} + +#[test] +fn hostile_undo_params_never_panic() { + let mut s = Session::new(); + circle(&mut s, 0.0); + for p in [ + json!({ "count": -1 }), + json!({ "count": u64::MAX }), + json!({ "mode": 7 }), + json!({ "mode": "control", "control": 3 }), + json!({ "mode": "back", "confirm": "maybe" }), + json!({ "mode": "mark" }), + ] { + let _ = s.execute("undo", &p); + } + for p in [json!({ "count": u64::MAX }), json!({ "all": "yes" }), json!(null)] { + let _ = s.execute("mredo", &p); + } + // Many marks stay bounded. + for _ in 0..1100 { + s.execute("undo", &json!({ "mode": "mark" })).unwrap(); + } + assert!(getvar(&mut s, "UNDOMARKS").as_u64().unwrap() <= 1000); +}