UNDO Mark/Back, BEgin/End, Auto and Control; MREDO; UNDOCTL (part of #461) (#464)

Squash of PR #464 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:36:30 -04:00
1 parent 6aa7c03fad
commit f3553783a0
6 files changed
+723 -51

No files matched your search

+324 -43
View File
@@ -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<CommandSpec> {
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<UndoMode> {
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<bool> {
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<String>) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value>) -> Result<Step> {
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<Step> {
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::<u64>().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::<u64>().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<Value> {
/// Redo up to `n` undone steps.
fn redo_steps(s: &mut Session, n: u64) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Step> {
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::<u64>().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<Vec2>) -> Result<usize> {
let d = s.doc()?;
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
+2
View File
@@ -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())
}
+6 -7
View File
@@ -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<Drawing>,
pub undo: Vec<Snapshot>,
pub redo: Vec<Snapshot>,
/// UNDO marks, BEgin/End group and Control (see [`undoctl`]).
pub undo_ctl: undoctl::UndoCtl,
pub selection: Vec<Handle>,
pub previous_selection: Vec<Handle>,
pub path: Option<String>,
@@ -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;
}
}
+6 -1
View File
@@ -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<Value> {
})
.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<cadcraft_geom::Vec2> {
}
/// 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()))
+170
View File
@@ -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<usize>,
/// Undo-stack depth when the open BEgin group started.
pub group: Option<usize>,
}
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);
}
}
+215
View File
@@ -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);
}