Merge #456: Wrap saved story revision counters consistently

Conflict: crates/doc/src/edit.rs: next's thread_frames (append_story); every rev += 1 is rev.wrapping_add(1)
Conflict: crates/engine/src/cmd/fonts.rs: next's replace (the local-formatting pass moved out of this hunk)
Conflict: crates/engine/src/cmd/text.rs: next's format_chars (apply_char_attrs, typing format); rev += 1 is rev.wrapping_add(1)
This commit is contained in:
aiongg
2026-10-11 12:20:08 +00:00
15 changed files with 73 additions and 28 deletions
+6 -6
View File
@@ -311,7 +311,7 @@ impl Document {
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
st.frames.insert(at, to);
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
let it = self.item_mut(to).ok_or(DocError::NoItem(to))?;
it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() });
return Ok(());
@@ -332,7 +332,7 @@ impl Document {
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
@@ -369,7 +369,7 @@ impl Document {
}
let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?;
st.frames.insert(0, from);
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
let it = self.item_mut(from).ok_or(DocError::NoItem(from))?;
match it.text_frame_mut() {
Some(t) => t.story = ts,
@@ -438,7 +438,7 @@ impl Document {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
let tail = st.frames.split_off(p + 1);
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
(tail, st.vertical, st.direction)
};
if tail.is_empty() {
@@ -468,7 +468,7 @@ impl Document {
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
}
st.frames.retain(|f| *f != frame);
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
(st.vertical, st.direction)
};
let nid = StoryId(self.alloc());
@@ -572,7 +572,7 @@ impl Document {
st.index_refs.clear();
st.objects.clear();
st.fix_marks();
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
Ok(())
}
}
+2 -2
View File
@@ -83,7 +83,7 @@ impl Story {
/// Mutable endnote (copy-on-write); bumps the story revision.
pub fn endnote_mut(&mut self, id: u64) -> Option<&mut Footnote> {
let n = self.endnotes.iter_mut().find(|n| n.id == id)?;
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
Some(Arc::make_mut(n))
}
@@ -179,6 +179,6 @@ impl crate::Document {
let Some(st) = self.stories.get_mut(&sid) else { return };
let st = Arc::make_mut(st);
let (frames, rev) = (std::mem::take(&mut st.frames), st.rev);
*st = Story { frames, rev: rev + 1, ..fresh };
*st = Story { frames, rev: rev.wrapping_add(1), ..fresh };
}
}
+1 -1
View File
@@ -156,7 +156,7 @@ impl Story {
/// Mutable footnote (copy-on-write); bumps the story revision.
pub fn note_mut(&mut self, id: u64) -> Option<&mut Footnote> {
let n = self.notes.iter_mut().find(|n| n.id == id)?;
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
Some(Arc::make_mut(n))
}
+1 -1
View File
@@ -123,7 +123,7 @@ impl Story {
}
self.fix_tables();
}
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
end
}
+29 -4
View File
@@ -307,7 +307,7 @@ impl Story {
// Character runs.
self.splice_runs(pos, 0, text.len(), fmt);
self.normalize();
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
}
/// End the paragraph after every break character that isn't followed by a `\n`.
@@ -361,7 +361,7 @@ impl Story {
self.remove_run_bytes(a, b - a);
self.normalize();
self.fix_tables();
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
}
/// Replace a range with text (delete + insert using the format at the start of the range).
@@ -402,7 +402,7 @@ impl Story {
start = end;
}
self.normalize();
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
}
/// Apply `f` to every paragraph intersecting `range` (an empty range = the paragraph at it).
@@ -414,7 +414,7 @@ impl Story {
f(p);
p.table = table;
}
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
}
/// Plain text of a range.
@@ -597,6 +597,31 @@ mod tests {
s.check().unwrap();
}
#[test]
fn saved_revision_wraps_without_stopping_edits() {
let mut s = st("seed");
s.rev = u64::MAX;
// Revisions are serialized; a valid saved story can reach the boundary.
let bytes = serde_json::to_vec(&s).unwrap();
let mut s: Story = serde_json::from_slice(&bytes).unwrap();
s.insert(s.len(), " text");
assert_eq!(s.rev, 0);
assert_eq!(s.text, "seed text");
s.rev = u64::MAX;
s.delete(4..s.len());
assert_eq!(s.rev, 0);
assert_eq!(s.text, "seed");
s.rev = u64::MAX;
s.format_chars(0..s.len(), |f| f.over.size = Some(18.0));
assert_eq!(s.rev, 0);
assert_eq!(s.char_format_at(0).over.size, Some(18.0));
s.rev = u64::MAX;
s.format_paras(0..s.len(), |p| p.style = "Heading".into());
assert_eq!(s.rev, 0);
assert_eq!(s.paras[0].style, "Heading");
s.check().unwrap();
}
#[test]
fn replace_and_format_paras() {
let mut s = st("a\nb\nc");
+1 -1
View File
@@ -523,7 +523,7 @@ impl CellStyle {
for p in &mut cell.text.paras {
p.style = ps.clone();
}
cell.text.rev += 1;
cell.text.rev = cell.text.rev.wrapping_add(1);
}
cell.style = if self.name == NO_CELL_STYLE { String::new() } else { self.name.clone() };
}
+3 -3
View File
@@ -923,7 +923,7 @@ impl Table {
fn append_story(s: &mut Story, other: &Story) {
if s.is_empty() {
*s = Story { id: s.id, ..other.clone() };
s.rev += 1;
s.rev = s.rev.wrapping_add(1);
return;
}
let base = s.len();
@@ -1000,7 +1000,7 @@ impl Story {
/// Mutable table (copy-on-write); bumps the story revision.
pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> {
let t = self.tables.get_mut(&id)?;
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
Some(Arc::make_mut(t))
}
@@ -1025,7 +1025,7 @@ impl Story {
let api = self.para_at(anchor);
self.paras[api].table = Some(id);
self.fix_tables();
self.rev += 1;
self.rev = self.rev.wrapping_add(1);
anchor
}
+1 -1
View File
@@ -190,7 +190,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let o = st.objects.get_mut(k).ok_or_else(|| bad("anchored.options", "no such anchored object"))?;
Arc::make_mut(o).position = position.clone();
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
Ok(json!({"position": position}))
})
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(a) = author {
n.author = a;
}
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
ok()
})
}),
+21 -1
View File
@@ -12,7 +12,7 @@ fn copy_into(d: &mut Document, parent: StoryId, child: StoryId) -> Result<()> {
let src = d.story(parent).ok_or_else(|| bad("story.link", format!("no story {}", parent.0)))?.clone();
let st = d.story_mut(child).ok_or_else(|| bad("story.link", format!("no story {}", child.0)))?;
let (frames, rev) = (std::mem::take(&mut st.frames), st.rev);
*st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), ..src };
*st = Story { id: child, frames, rev: rev.wrapping_add(1), link: Some((parent, src.rev)), ..src };
Ok(())
}
@@ -94,6 +94,26 @@ mod tests {
use crate::Session;
#[test]
fn linked_story_update_wraps_the_child_revision() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let p = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "Original"})).unwrap();
let c = s.execute("story.placeAndLink", &json!({"story": p["story"], "rect": [100, 0, 200, 100]})).unwrap();
let child = designcraft_doc::StoryId(c["story"].as_u64().unwrap());
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).story_mut(child).unwrap().rev = u64::MAX;
s.execute("story.setText", &json!({"story": p["story"], "text": "Revised"})).unwrap();
s.execute("story.updateLink", &json!({"story": c["story"]})).unwrap();
let st = s.doc().unwrap().doc.story(child).unwrap();
assert_eq!(st.text, "Revised");
assert_eq!(st.rev, 0);
assert_eq!(s.execute("story.links", &json!({})).unwrap()[0]["outOfDate"], false);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(child).unwrap().text, "Original");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(child).unwrap().text, "Revised");
}
#[test]
fn linked_story_goes_out_of_date_and_updates() {
let mut s = Session::new();
+1 -1
View File
@@ -623,7 +623,7 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
rename_char_refs(&mut f.para, from, to);
}
}
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
});
}
}
+2 -2
View File
@@ -1363,7 +1363,7 @@ fn reapply_cell_styles(d: &mut Document, names: &[String]) {
}
}
}
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
}
}
@@ -1488,7 +1488,7 @@ fn table_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
ts.apply_to(std::sync::Arc::make_mut(t), &cells);
}
}
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
}
ok()
})
+2 -2
View File
@@ -801,7 +801,7 @@ pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[
&& st.vertical != vertical
{
st.vertical = vertical;
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
}
}
}
@@ -1329,7 +1329,7 @@ pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Tar
&& let Some(run) = st.chars.first_mut()
{
run.format.over.merge(a);
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
}
continue;
}
+1 -1
View File
@@ -438,7 +438,7 @@ fn import_xml(s: &mut Session, p: &Value) -> Result<Value> {
}
}
fresh.frames = frames;
fresh.rev = st.rev + 1;
fresh.rev = st.rev.wrapping_add(1);
*st = fresh;
}
Ok(json!({"placed": placed, "unmatched": unmatched}))
+1 -1
View File
@@ -162,7 +162,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let x = st.xrefs.get_mut(k).ok_or_else(|| bad("xref.setFormat", "no such cross-reference"))?;
std::sync::Arc::make_mut(x).format = format;
st.rev += 1;
st.rev = st.rev.wrapping_add(1);
Ok(Value::Null)
})
}