mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #464 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6aa7c03fad
commit
f3553783a0
6 files changed
+723
-51
No files matched your search
+324
-43
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user