mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Redo swapped the document and the history under a live interaction, whose snapshot was still the document from before: the next preview or a cancel brought that one back while the history recorded the redo, so the redone edit was lost. Redo now settles the interaction first, the way Undo already did: a drag that changed something is kept as the newest step (which ends the steps to redo), one that changed nothing is dropped.
182 lines
7.9 KiB
Rust
182 lines
7.9 KiB
Rust
//! The undo history's memory (#1244): steps keep no lookup tables, unchanged selections are
|
|
//! shared between steps, and past a memory budget the oldest steps go (never the last few).
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_doc::{Appearance, Node};
|
|
use vectorcraft_geom::shapes;
|
|
|
|
use super::*;
|
|
|
|
/// A session with one document holding `n` rectangles, all selected.
|
|
fn session_with_rects(n: usize) -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 2000, "height": 2000})).unwrap();
|
|
s.edit("Add", |d, _| {
|
|
let layer = d.layers[0].id;
|
|
for i in 0..n {
|
|
let id = d.alloc_id();
|
|
let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0));
|
|
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art()))?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
s.execute("select.all", &json!({})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn first_x(s: &Session) -> f64 {
|
|
let d = &s.doc().unwrap().doc;
|
|
let id = d.layers[0].children().unwrap()[0].id;
|
|
d.node(id).unwrap().geometric_bounds().unwrap().x0
|
|
}
|
|
|
|
#[test]
|
|
fn past_the_budget_the_oldest_steps_go_but_the_last_few_stay() {
|
|
let n = 500;
|
|
let mut s = session_with_rects(n);
|
|
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
|
let step = s.doc().unwrap().history.undo.last().unwrap().bytes;
|
|
// Each move keeps every rectangle's old node: about 700 bytes each (measured: ~775).
|
|
assert!((300 * n..1500 * n).contains(&step), "{step} bytes for moving {n} rectangles");
|
|
// A budget of about three moves.
|
|
s.active_mut().unwrap().history.byte_budget = 3 * step;
|
|
for _ in 0..20 {
|
|
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
|
}
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.history.undo.len(), History::MIN_STEPS, "only the last few steps are kept");
|
|
assert!(st.history.undo.iter().all(|e| e.label == "Move"), "the newest ones: {:?}", st.history.undo.iter().map(|e| &e.label).collect::<Vec<_>>());
|
|
// The kept steps undo as before.
|
|
let x = first_x(&s);
|
|
for _ in 0..History::MIN_STEPS {
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
}
|
|
assert_eq!(first_x(&s), x - History::MIN_STEPS as f64);
|
|
assert!(s.execute("edit.undo", &json!({})).is_err(), "the older steps went");
|
|
// Redo too stays within the budget, keeping the next few steps.
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.history.redo.len(), History::MIN_STEPS);
|
|
for _ in 0..History::MIN_STEPS {
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
}
|
|
assert_eq!(first_x(&s), x);
|
|
}
|
|
|
|
#[test]
|
|
fn within_the_budget_the_step_limit_still_counts() {
|
|
let mut s = session_with_rects(10);
|
|
s.active_mut().unwrap().history.limit = 12;
|
|
for _ in 0..30 {
|
|
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
|
}
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.history.undo.len(), 12);
|
|
assert!(st.history.bytes() < st.history.byte_budget);
|
|
}
|
|
|
|
#[test]
|
|
fn undo_steps_keep_no_lookup_tables() {
|
|
let mut s = session_with_rects(200);
|
|
for _ in 0..5 {
|
|
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
|
// Lookups build the current document's table.
|
|
first_x(&s);
|
|
assert!(s.doc().unwrap().doc.has_lookup_cache());
|
|
}
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
assert!(!st.history.undo.is_empty() && !st.history.redo.is_empty());
|
|
for e in st.history.undo.iter().chain(&st.history.redo) {
|
|
assert!(!e.doc.has_lookup_cache(), "step {} keeps a lookup table", e.label);
|
|
}
|
|
// The document undone to builds its table again on its first lookup.
|
|
first_x(&s);
|
|
assert!(s.doc().unwrap().doc.has_lookup_cache());
|
|
// A drag's snapshot goes into the history without its table too.
|
|
s.begin_interaction("Move").unwrap();
|
|
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
|
|
first_x(&s);
|
|
s.commit_interaction().unwrap();
|
|
assert!(!s.doc().unwrap().history.undo.last().unwrap().doc.has_lookup_cache());
|
|
}
|
|
|
|
#[test]
|
|
fn steps_share_an_unchanged_selection() {
|
|
let mut s = session_with_rects(100);
|
|
for _ in 0..4 {
|
|
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
|
|
}
|
|
s.begin_interaction("Move").unwrap();
|
|
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
|
|
s.preview("object.move", &json!({"dx": 4, "dy": 0})).unwrap();
|
|
s.commit_interaction().unwrap();
|
|
let st = s.doc().unwrap();
|
|
let moves: Vec<_> = st.history.undo.iter().filter(|e| e.label == "Move").collect();
|
|
assert_eq!(moves.len(), 5);
|
|
assert_eq!(moves[0].selection.objects.len(), 100);
|
|
assert!(moves.windows(2).all(|w| Arc::ptr_eq(&w[0].selection, &w[1].selection)), "one selection for all the moves");
|
|
// Undo and redo keep it shared, and restore it.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
assert!(Arc::ptr_eq(&st.history.redo[0].selection, &st.history.redo[1].selection));
|
|
assert_eq!(st.selection.objects.len(), 100);
|
|
}
|
|
|
|
/// Undo and redo don't estimate the steps again: the step an undo (redo) makes takes the undone
|
|
/// step's figures the other way round, which are what a fresh estimate gives.
|
|
#[test]
|
|
fn undo_and_redo_reuse_the_estimates() {
|
|
let mut s = session_with_rects(200);
|
|
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
|
// A deletion is lopsided: the step before it keeps the objects, the one after keeps nothing.
|
|
s.execute("edit.clear", &json!({})).unwrap();
|
|
let fresh = |e: &HistoryEntry, now: &Document| e.doc.retained_bytes(now);
|
|
let st = s.doc().unwrap();
|
|
let delete = st.history.undo.last().unwrap();
|
|
assert!(delete.bytes > 200 * 300, "the deleted objects: {}", delete.bytes);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
let redo = st.history.redo.last().unwrap();
|
|
assert_eq!(redo.bytes, fresh(redo, &st.doc));
|
|
assert!(redo.bytes < 4096, "the document without the objects keeps little of its own: {}", redo.bytes);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
let undo = st.history.undo.last().unwrap();
|
|
assert_eq!(undo.label, "Move");
|
|
assert_eq!(undo.bytes, fresh(undo, &st.doc));
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
let delete = st.history.undo.last().unwrap();
|
|
assert_eq!(delete.bytes, fresh(delete, &st.doc));
|
|
}
|
|
|
|
/// #1719: Redo while a drag is in progress keeps the drag as a step (which ends the steps to redo,
|
|
/// as any new edit does) instead of swapping the document under it: before, the next preview or a
|
|
/// cancel went back to the document from before the redo while the history said it was redone.
|
|
#[test]
|
|
fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() {
|
|
let mut s = session_with_rects(1);
|
|
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
|
let moved = first_x(&s);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let start = first_x(&s);
|
|
// A drag that changed nothing yet is no step: Redo redoes, and a cancel has nothing to undo.
|
|
s.begin_interaction("Move").unwrap();
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
s.cancel_interaction().unwrap();
|
|
assert_eq!(first_x(&s), moved, "the redone move stays");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
// A drag that moved the art is the newest step: nothing is left to redo, and nothing is lost.
|
|
s.begin_interaction("Move").unwrap();
|
|
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
|
|
assert!(s.execute("edit.redo", &json!({})).is_err(), "the drag ended the steps to redo");
|
|
assert!(!s.in_interaction());
|
|
assert_eq!(first_x(&s), start + 3.0);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(first_x(&s), start);
|
|
}
|