Files
bflatastic f97d57f859 Fix #1719: Redo keeps a drag in progress as a step, as Undo does
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.
2026-10-14 08:11:02 +07:00

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);
}