Merge PR #525 from NikitkaRa: Review: compare with a revised copy and accept its slides

# Conflicts:
#	crates/engine/src/cmd/review.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panes.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:53:58 -04:00
10 changed files with 689 additions and 2 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool {
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
}
fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
deckcraft_model::walk(shapes, &mut |s, _| {
match &s.kind {
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
+82
View File
@@ -44,6 +44,10 @@ pub fn specs() -> Vec<CommandSpec> {
has_text,
replace_word
),
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling),
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
@@ -394,6 +398,84 @@ fn replace_word(s: &mut Session, p: &Value) -> Result<Value> {
s.execute("text.insert", &json!({"text": with}))
}
fn comparing(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) }
}
fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
if let Some(data) = str_param(p, "data") {
let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?;
return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes));
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = str_param(p, "path") {
let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
return Ok((path.to_string(), bytes));
}
Err(bad("review.compare", "give `path` or `data`"))
}
fn revisions_json(st: &crate::DocState) -> Result<Value> {
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
}
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = revised_bytes(p)?;
let revised = super::file::open_presentation(&name, &bytes)?;
let st = s.doc_mut()?;
let by_position = crate::compare::unrelated(&st.doc, &revised);
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
revisions_json(s.doc()?)
}
fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
revisions_json(s.doc()?)
}
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
return Err(bad("review.accept", "give `index` or `all`"));
}
s.edit(|doc, sel| {
let mut accepted = 0usize;
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
for _ in 0..limit {
let changes = crate::compare::diff(doc, &rev, by_pos);
let c = if all {
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
} else {
changes.into_iter().nth(index.unwrap_or(0))
};
let Some(c) = c else { break };
let Some(at) = crate::compare::accept(doc, &rev, &c) else { break };
accepted += 1;
sel.slide = at;
sel.shapes.clear();
sel.text = None;
if !all {
break;
}
}
if accepted == 0 {
return Err(bad("review.accept", "no such revision"));
}
Ok(json!({"accepted": accepted}))
})
}
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
s.doc_mut()?.compare = None;
ok()
}
#[cfg(test)]
mod tests {
use super::*;
+449
View File
@@ -0,0 +1,449 @@
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
//! by slide, and accepting a revised slide into the open one.
//!
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
//! slide id at all, by position. Shapes match by id, then by name.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
use serde::Serialize;
/// The revised presentation a document is being compared with.
#[derive(Clone, Debug)]
pub struct Compare {
pub name: String,
pub revised: Arc<Presentation>,
/// Slides match by position (the decks shared no slide id when the compare started); fixed
/// for the session so accepting slides doesn't switch the matching half way.
pub by_position: bool,
}
/// Whether `cur` and `rev` share no slide id (then slides match by position).
pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
let ids: HashSet<SlideId> = rev.slides.iter().map(|s| s.id).collect();
!cur.slides.iter().any(|s| ids.contains(&s.id))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ChangeKind {
/// Only in the revised deck.
Inserted,
/// Only in the open deck.
Deleted,
/// In both, different.
Changed,
}
/// One slide-level revision.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Change {
pub kind: ChangeKind,
/// Index in the open deck (for inserted: where it would go).
pub slide: usize,
/// Index in the revised deck (none for deleted).
pub revised: Option<usize>,
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
pub details: Vec<String>,
}
const MAX_DETAILS: usize = 50;
/// Slide pairs (open index, revised index).
fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> {
if by_position {
return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect();
}
let rev_ids: HashMap<SlideId, usize> = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect();
cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect()
}
pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Change> {
let pairs = pairs(cur, rev, by_position);
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![];
for (i, j) in &pairs {
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details });
}
}
for (i, s) in cur.slides.iter().enumerate() {
if !cur_matched.contains(&i) {
out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] });
}
}
for (j, s) in rev.slides.iter().enumerate() {
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] });
}
}
out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
out
}
fn slide_title(s: &Slide) -> String {
let t = s
.shapes
.iter()
.find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle)))
.and_then(|x| x.text.as_ref())
.map(|b| b.text())
.unwrap_or_default();
let t = t.trim();
if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() }
}
fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
p.media(id).map(|m| m.data.as_slice())
}
/// A shape without its ids (shape and media), for comparing across decks.
fn plain_shape(s: &Shape) -> Shape {
let mut c = s.clone();
fn rec(shapes: &mut [Shape]) {
for s in shapes {
s.id = ShapeId(0);
if let Some(ch) = s.children_mut() {
rec(ch);
}
}
}
rec(std::slice::from_mut(&mut c));
c
}
fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
if text(a) != text(b) {
what.push("text");
} else if a.text != b.text {
what.push("text formatting");
}
match (&a.xfrm, &b.xfrm) {
(Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"),
(Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"),
(x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"),
_ => {}
}
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
let pic = |p: &Presentation, s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
_ => None,
};
if pic(cp, a) != pic(rp, b) {
what.push("picture");
}
if what.is_empty() {
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
x.text = None;
y.text = None;
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
x.kind = ShapeKind::Shape;
y.kind = ShapeKind::Shape;
}
if x != y {
what.push("properties");
}
}
if !what.is_empty() {
out.push(format!("{label}: {}", what.join(", ")));
}
}
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
for sa in &a.shapes {
let k = b
.shapes
.iter()
.position(|x| x.id == sa.id)
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(cp, sa, rp, sb, &mut out);
}
}
_ => out.push(format!("{} deleted", shape_label(sa))),
}
}
for (k, sb) in b.shapes.iter().enumerate() {
if !used.contains(&k) {
out.push(format!("{} inserted", shape_label(sb)));
}
}
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
if out.is_empty() && order(a) != order(b) {
out.push("order of objects".into());
}
let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone());
if layout_name(cp, a) != layout_name(rp, b) {
out.push("layout".into());
}
if a.notes.text() != b.notes.text() {
out.push("notes".into());
}
if a.transition != b.transition {
out.push("transition".into());
}
if a.animations.len() != b.animations.len()
|| a.animations
.iter()
.zip(&b.animations)
.any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms))
{
out.push("animations".into());
}
if a.background != b.background {
out.push("background".into());
}
if a.hidden != b.hidden {
out.push(if b.hidden { "hidden" } else { "shown" }.into());
}
out.truncate(MAX_DETAILS);
out
}
fn max_shape_id(shapes: &[Shape], m: &mut u32) {
deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0));
}
/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape
/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its
/// layout matched by id, then by name, then the first layout.
pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option<SlideId>) -> Slide {
let mut sl = slide.clone();
// Media.
let mut mids = vec![];
crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids);
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
for id in mids {
let Some(m) = rev.media(id) else { continue };
match doc.media(id) {
Some(x) if x.data == m.data => {}
None => doc.media.push(m.clone()),
Some(_) => {
let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone());
media_map.insert(id, new);
}
}
}
// Shape ids used elsewhere in the open deck.
let mut taken: HashSet<u32> = HashSet::new();
for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) {
deckcraft_model::walk(&s.shapes, &mut |x, _| {
taken.insert(x.id.0);
});
}
let mut id_map: HashMap<ShapeId, ShapeId> = HashMap::new();
fn rec(
doc: &mut Presentation,
shapes: &mut [Shape],
taken: &HashSet<u32>,
ids: &mut HashMap<ShapeId, ShapeId>,
media: &HashMap<MediaId, MediaId>,
depth: usize,
) {
if depth > 64 {
return;
}
for s in shapes {
if taken.contains(&s.id.0) {
let new = ShapeId(doc.alloc_id());
ids.insert(s.id, new);
s.id = new;
}
let remap = |m: &mut MediaId| {
if let Some(n) = media.get(m) {
*m = *n;
}
};
match &mut s.kind {
ShapeKind::Picture { fill } => remap(&mut fill.media),
ShapeKind::Media(m) => {
remap(&mut m.media);
if let Some(p) = m.poster.as_mut() {
remap(p);
}
}
_ => {}
}
if let Some(Fill::Picture(pf)) = &mut s.fill {
remap(&mut pf.media);
}
if let Some(ch) = s.children_mut() {
rec(doc, ch, taken, ids, media, depth + 1);
}
}
}
rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0);
for a in &mut sl.animations {
if let Some(n) = id_map.get(&a.shape) {
a.shape = *n;
}
if let Some(t) = a.trigger.as_mut()
&& let Some(n) = id_map.get(t)
{
*t = *n;
}
}
let mut max = 0;
max_shape_id(&sl.shapes, &mut max);
doc.next_id = doc.next_id.max(max);
// Layout.
if doc.layout(sl.layout).is_none() {
let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone());
let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id));
sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default();
}
match replacing {
Some(id) => sl.id = id,
None => {
if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 {
sl.id = SlideId(doc.alloc_id());
}
doc.next_id = doc.next_id.max(sl.id.0);
}
}
sl
}
/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show.
pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> Option<usize> {
match change.kind {
ChangeKind::Changed => {
let b = rev.slides.get(change.revised?)?.clone();
let id = doc.slides.get(change.slide)?.id;
let sl = adopt(doc, rev, &b, Some(id));
*doc.slides.get_mut(change.slide)? = Arc::new(sl);
Some(change.slide)
}
ChangeKind::Inserted => {
let b = rev.slides.get(change.revised?)?.clone();
let sl = adopt(doc, rev, &b, None);
let at = change.slide.min(doc.slides.len());
doc.slides.insert(at, Arc::new(sl));
Some(at)
}
ChangeKind::Deleted => {
if change.slide >= doc.slides.len() || doc.slides.len() <= 1 {
return None;
}
doc.slides.remove(change.slide);
Some(change.slide.min(doc.slides.len().saturating_sub(1)))
}
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
/// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title
/// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3.
fn decks() -> (Session, String, usize) {
let mut s = Session::new();
crate::sample::open_sample(&mut s).unwrap();
let n = s.doc().unwrap().doc.slides.len();
let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
// Make the revision in a second document.
s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap();
let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0;
s.execute("edit.select", &json!({"ids": [first]})).unwrap();
s.execute("text.edit", &json!({"id": first})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": "Revised title"})).unwrap();
s.execute("text.exit", &json!({})).unwrap();
s.execute("slide.delete", &json!({"index": 1})).unwrap();
s.execute("slide.go", &json!({"index": 1})).ok();
let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0;
s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok();
s.execute("slide.go", &json!({"index": n - 2})).ok();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap();
let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.close_document(1);
s.set_active(0);
(s, rev, n)
}
fn kinds(v: &Value) -> Vec<String> {
v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect()
}
#[test]
fn lists_inserted_deleted_and_changed_slides() {
let (mut s, rev, n) = decks();
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let k = kinds(&v);
assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}");
let ch = &v["changes"].as_array().unwrap()[0];
assert_eq!(ch["slide"], 0);
assert!(ch["details"].to_string().contains("text"), "{ch}");
let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap();
assert_eq!(ins["details"][0], "Brand new");
let at = ins["slide"].as_u64().unwrap() as usize;
assert_eq!(at, n, "after the open slide matching the revised one before it");
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
}
#[test]
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
let (mut s, rev, n) = decks();
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len();
s.execute("review.accept", &json!({"index": 0})).unwrap();
let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len();
assert_eq!(after, before - 1);
assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title"))));
s.execute("review.accept", &json!({"all": true})).unwrap();
let left = s.execute("review.revisions", &json!({})).unwrap();
assert!(left["changes"].as_array().unwrap().is_empty(), "{left}");
assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate());
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before);
s.execute("review.endCompare", &json!({})).unwrap();
assert!(s.execute("review.revisions", &json!({})).is_err());
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
}
#[test]
fn unrelated_decks_match_by_position_and_bad_input_fails() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap();
let mut other = Session::with_new();
other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap();
other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap();
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
assert!(kinds(&v).contains(&"inserted".into()), "{v}");
s.execute("review.accept", &json!({"all": true})).unwrap();
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
assert!(s.doc().unwrap().doc.validate().is_empty());
assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err());
assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err());
assert!(s.execute("review.compare", &json!({})).is_err());
assert!(s.execute("review.accept", &json!({"index": 999})).is_err());
}
}
+4
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod compare;
pub mod connect;
pub mod fonts;
pub mod guard;
@@ -212,6 +213,8 @@ pub struct DocState {
pub missing_fonts: Vec<String>,
/// An input method's composition in progress ([`Composition`]).
pub composition: Option<Composition>,
/// Review › Compare in progress: the revised copy.
pub compare: Option<compare::Compare>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -238,6 +241,7 @@ impl DocState {
title,
missing_fonts: Vec::new(),
composition: None,
compare: None,
}
}
pub fn is_dirty(&self) -> bool {
+61
View File
@@ -0,0 +1,61 @@
//! Review › Compare: the Revisions pane lists a revised copy's changes and accepts them.
use deckcraft_engine::Session;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::{Services, SlideApp};
/// The sample deck compared with a copy whose slide 2 is gone and that has a new last slide.
fn harness() -> (Harness<'static, SlideApp>, usize) {
let mut s = Session::new();
deckcraft_engine::sample::open_sample(&mut s).unwrap();
let n = s.doc().unwrap().doc.slides.len();
let data = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": data})).unwrap();
s.execute("slide.delete", &json!({"index": 1})).unwrap();
s.execute("slide.go", &json!({"index": n - 2})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "From the reviewer", "body": "x"})).unwrap();
let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.close_document(1);
s.set_active(0);
s.execute("review.compare", &json!({"name": "Q3 review.deckcraft", "data": rev})).unwrap();
let app = SlideApp::new(s, Services::default());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(4);
(h, n)
}
#[test]
fn pane_opens_lists_changes_and_accept_all_applies_them() {
let (mut h, n) = harness();
assert_eq!(h.state().ui.pane.as_deref(), Some("revisions"), "compare opens the Revisions pane");
assert!(h.query_by_label("Compared with Q3 review.deckcraft").is_some());
assert!(h.query_by_label("Slide 2 deleted").is_some());
assert!(h.query_by_label_contains("Slide inserted after").is_some());
h.get_by_label_contains("Accept All").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
assert_eq!(st.doc.slides.len(), n, "one slide gone, one slide added");
assert!(h.query_by_label("No differences left.").is_some());
h.get_by_label("End Review").click();
h.run_steps(3);
assert!(h.state().session.active().unwrap().compare.is_none());
}
#[test]
#[ignore = "writes a screenshot for review"]
fn screenshot() {
let (mut h, _) = harness();
// Review tab.
h.state_mut().ui.tab = "Review".into();
h.run_steps(4);
h.render().unwrap().save("/tmp/compare.png").unwrap();
}
+14
View File
@@ -213,6 +213,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.saveAsDialog", "Save As…", None, "{}"),
("app.exportDialog", "Export…", None, "{}"),
("app.browseThemeDialog", "Browse for Themes…", None, "{}"),
("app.compareDialog", "Compare…", None, "{}"),
("app.dialog", "Open Dialog", None, "{id}"),
("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"),
("app.qat", "Quick Access Toolbar", None, "{add: command id, params?, label?, icon?} | {remove: id, params?} | {reset: true} → [buttons]"),
@@ -739,6 +740,17 @@ impl SlideApp {
self.dialog = Some(d);
}
"app.find" | "app.replace" => self.palette = Some((String::new(), 0)),
"app.compareDialog" => {
if self.session.active().is_none() {
return Err("open the presentation to compare first".into());
}
if self.services.pick_open.is_some() {
self.pick_and_insert("open", "review.compare");
} else {
// The browser's asynchronous picker would open the file as a new document.
self.set_status("Compare needs the desktop app for now".to_string());
}
}
"app.dialog" => {
let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?;
self.dialog = Some(dialogs::Dialog::new(d));
@@ -1393,6 +1405,8 @@ mod eyedropper_tests;
#[cfg(test)]
mod huge_deck_tests;
#[cfg(test)]
mod compare_tests;
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod replacefonts_tests;
+57
View File
@@ -30,6 +30,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"designer" => "Design Ideas",
"smartart" => "Text Pane",
"thesaurus" => "Thesaurus",
"revisions" => "Revisions",
_ => "Pane",
};
ui.horizontal(|ui| {
@@ -55,6 +56,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"designer" => designer(app, ui),
"smartart" => smartart(app, ui),
"thesaurus" => thesaurus(app, ui),
"revisions" => revisions(app, ui),
_ => {}
});
});
@@ -973,6 +975,61 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
let _ = paint_anim_tile;
}
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it.
fn revisions(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(v) = app.session.execute("review.revisions", &json!({})) else {
ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim));
return;
};
let changes = v["changes"].as_array().cloned().unwrap_or_default();
ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim));
if v["byPosition"].as_bool() == Some(true) {
ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint));
}
ui.add_space(4.0);
if changes.is_empty() {
ui.label("No differences left.");
if ui.button("End Review").clicked() {
run(app, "review.endCompare", json!({}));
app.ui.pane = None;
}
return;
}
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
run(app, "review.accept", json!({"all": true}));
}
ui.separator();
for (i, c) in changes.iter().enumerate() {
let slide = c["slide"].as_u64().unwrap_or(0);
let (head, col) = match c["kind"].as_str().unwrap_or("") {
"inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)),
"deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)),
_ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)),
};
egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(2), col);
if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() {
let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0);
run(app, "slide.go", json!({"index": (slide as usize).min(max)}));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("Accept").clicked() {
run(app, "review.accept", json!({"index": i}));
}
});
});
for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) {
ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim));
}
});
ui.add_space(4.0);
}
}
fn comments(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
if ui.button("+ New Comment").clicked() {
+13
View File
@@ -2106,6 +2106,19 @@ fn review(app: &mut SlideApp, ui: &mut Ui) {
let _ = r.on_hover_text("Translation needs a language model (not bundled)");
big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"}));
});
group(ui, |ui| {
big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({}));
let comparing = enabled(app, "review.endCompare");
if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() {
run(app, "review.accept", json!({"all": true}));
}
if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() {
run(app, "review.endCompare", json!({}));
if app.ui.pane.as_deref() == Some("revisions") {
app.ui.pane = None;
}
}
});
group(ui, |ui| {
big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"}));
rows(ui, |ui| {
+3
View File
@@ -332,6 +332,9 @@ sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; need
matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile
encryption), ink to shape/text (6–10 h), 3-D models and stock content (6–10 h; Insert › Icons places a Lucide stroke set).
show (8–12 h), compare presentations beyond slide-level accept (reject, per-object accept,
markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376 agile
encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set).
## UI/UX
+5 -1
View File
@@ -19,6 +19,8 @@ The summary between `<!-- SUMMARY -->
| P2 | 54 | 14 | 14 | 26 | 39% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 16 | 25 | 39% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 16 | 25 | 39% |
| Area | Parity |
|---|---|
@@ -49,6 +51,8 @@ The summary between `<!-- SUMMARY -->
| Animations | 79% |
| Slide show | 68% |
| Review | 60% |
| Slide show | 66% |
| Review | 70% |
| Draw | 33% |
| Undo & clipboard | 100% |
| Automation (✱) | 100% |
@@ -241,7 +245,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Slide show | Multi-monitor presenting | P1 | M |
| Review | Comments (add, reply, resolve, delete) | P1 | D |
| Review | Accessibility checker | P2 | D |
| Review | Compare presentations | P2 | M |
| Review | Compare presentations | P2 | P |
| Review | Track changes / version history | P2 | M |
| Draw | Ink pens, highlighter, eraser | P1 | P |
| Draw | Ink to shape / text | P2 | M |