From a6000f18fdcca4e6a54dfa0b7bb62269ed3d28a6 Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:51:31 +0200 Subject: [PATCH 001/376] Morph: align an outline morph once per transition, not every frame Follow-up to #27. render_blend built a GeometryMorph (contours, alignment) for every morphing shape on every frame, though only t and the current box change while a transition plays. The Renderer now keeps the aligned morphs, keyed by both geometries and their own sizes, and drops those a frame did not use, so the cache ends with the transition. - render: morph_path::Cache replaces morph_path::geometry; blend sweeps it. - test: one alignment per transition, same geometry as a fresh one. generated with Claude Code --- crates/render/src/lib.rs | 7 +++- crates/render/src/morph_path.rs | 73 +++++++++++++++++++++++++++++---- crates/render/src/tests.rs | 23 +++++++++++ 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6dde577a..61a35e93 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -221,11 +221,13 @@ pub struct Renderer { resources: Resources, /// Outlines morphing in the frame being drawn by `blend`. paths: Vec, + /// Their aligned outlines, kept from frame to frame. + morphs: morph_path::Cache, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new() } + Renderer { resources: Resources::new(), paths: Vec::new(), morphs: morph_path::Cache::default() } } } @@ -539,6 +541,7 @@ impl Renderer { } } self.paths.clear(); + self.morphs.sweep(); self.finish(ctx, w, h) } @@ -654,7 +657,7 @@ impl Renderer { fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) { let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { - Some(pm) => morph_path::geometry(pm, s, w, h), + Some(pm) => self.morphs.geometry(pm, s, w, h), None => (shape_geometry(s, w, h), Vec::new()), }; let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); diff --git a/crates/render/src/morph_path.rs b/crates/render/src/morph_path.rs index 6579eb18..33af94ed 100644 --- a/crates/render/src/morph_path.rs +++ b/crates/render/src/morph_path.rs @@ -29,12 +29,69 @@ fn ratio(now: f64, own: f64) -> f64 { if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 } } -/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their -/// own sizes (so the alignment does not change while the box grows), then scaled into the box. -/// With a [`Fade`] per sub-path. -pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec) { - let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h); - let b = shape_geometry(s, pm.to_box.w, pm.to_box.h); - let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); - GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box)) +/// What a [`GeometryMorph`] is built from: both outlines at their own sizes. None of it changes +/// while a transition plays, only `t` and the current box do. +#[derive(Debug)] +struct Source { + from: Geom, + from_wh: (f64, f64), + to: Geom, + to_wh: (f64, f64), +} + +impl Source { + fn of(pm: &PathMorph, s: &Shape) -> Self { + Source { from: pm.from.clone(), from_wh: (pm.from_box.w, pm.from_box.h), to: s.geom.clone(), to_wh: (pm.to_box.w, pm.to_box.h) } + } + + fn is(&self, pm: &PathMorph, s: &Shape) -> bool { + self.from == pm.from && self.from_wh == (pm.from_box.w, pm.from_box.h) && self.to == s.geom && self.to_wh == (pm.to_box.w, pm.to_box.h) + } + + fn build(&self) -> GeometryMorph { + let geo = |geom: &Geom, (w, h): (f64, f64)| shape_geometry(&Shape { geom: geom.clone(), ..Shape::default() }, w, h); + GeometryMorph::new(&geo(&self.from, self.from_wh), &geo(&self.to, self.to_wh)) + } +} + +/// The aligned outlines of the morphs being played, kept across frames: aligning is the expensive +/// part, blending them at `t` is cheap. +#[derive(Debug, Default)] +pub(crate) struct Cache { + /// With whether the current frame used it. + morphs: Vec<(Source, GeometryMorph, bool)>, +} + +impl Cache { + /// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at + /// their own sizes (so the alignment does not change while the box grows), then scaled into the + /// box. With a [`Fade`] per sub-path. + pub(crate) fn geometry(&mut self, pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec) { + let k = match self.morphs.iter().position(|(src, ..)| src.is(pm, s)) { + Some(k) => k, + None => { + let src = Source::of(pm, s); + let m = src.build(); + self.morphs.push((src, m, false)); + self.morphs.len() - 1 + } + }; + let (_, m, used) = &mut self.morphs[k]; + *used = true; + let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); + m.at(pm.t, scale(pm.from_box), scale(pm.to_box)) + } + + /// Ends a frame: forgets the morphs it did not use (their transition is over). + pub(crate) fn sweep(&mut self) { + self.morphs.retain(|(.., used)| *used); + for (.., used) in &mut self.morphs { + *used = false; + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.morphs.len() + } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472f..464ad0dd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -417,6 +417,29 @@ fn morph_path_fades_a_fill_only_one_side_has() { assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}"); } +#[test] +fn morph_path_aligns_once_per_transition() { + let (from, to) = (Xfrm::new(0.0, 0.0, 200.0, 200.0), Xfrm::new(0.0, 0.0, 300.0, 150.0)); + let star = Shape { geom: Geom::preset("star5"), ..Default::default() }; + let pm = |t: f64| PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t }; + let els = |(g, f): (deckcraft_geom::preset::Geometry, Vec<_>)| (g.paths.iter().map(|p| p.path.elements().to_vec()).collect::>(), f); + let mut cache = morph_path::Cache::default(); + for t in [0.0, 0.3, 0.7, 1.0] { + let got = els(cache.geometry(&pm(t), &star, 250.0, 175.0)); + // The same as aligning afresh. + assert_eq!(got, els(morph_path::Cache::default().geometry(&pm(t), &star, 250.0, 175.0))); + cache.sweep(); + } + assert_eq!(cache.len(), 1); + // Another target outline is another morph. + let _ = cache.geometry(&pm(0.5), &Shape { geom: Geom::preset("rect"), ..Default::default() }, 250.0, 175.0); + assert_eq!(cache.len(), 2); + // A frame without morphs ends the transition: the cache empties. + cache.sweep(); + cache.sweep(); + assert_eq!(cache.len(), 0); +} + #[test] fn text_transform_unmirrors_flips() { // Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text. From 074e090f050ed98c63e7fd36dcaa889ea4f66865 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sat, 10 Oct 2026 17:50:21 +0200 Subject: [PATCH 002/376] Fonts: a lookup finds an installed family whichever thread loads it first Two threads resolving a family nobody had loaded yet raced. The one that lost the race saw add_font add nothing and fell back to another face, and a lookup made while a family was being added saw only part of it (Arial Bold Italic loads first, so "Regular" came back bold italic). The same text was then laid out in different fonts depending on timing, which made clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn flaky when the theme font isn't bundled. Loading installed fonts now happens under one lock and rechecks once it holds it, and a family's faces are added in one step. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 75 ++++++++++++++++++++++++++++++-------- crates/fonts/src/lib.rs | 28 ++++++++++++++ 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f0a06efe..426b95c9 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -245,6 +245,11 @@ pub struct FontDb { /// System fallback state: enabled, and characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, + /// Held while installed fonts are loaded, so threads that need the same family wait for one + /// load instead of racing it: what a lookup finds doesn't depend on which thread got there + /// first. + #[cfg(not(target_arch = "wasm32"))] + loading: Mutex<()>, } #[cfg(not(target_arch = "wasm32"))] @@ -535,6 +540,8 @@ impl FontDb { cataloged: std::sync::OnceLock::new(), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), + #[cfg(not(target_arch = "wasm32"))] + loading: Mutex::new(()), } } @@ -600,14 +607,33 @@ impl FontDb { /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or /// every face was already present). pub fn add_font(&self, bytes: Vec) -> usize { - let data = Arc::new(bytes); - let mut added = 0; - for (i, family, style, coords) in enumerate_faces(&data) { - if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { - continue; + self.add_fonts(vec![bytes]) + } + + /// Add font files together: lookups see all of their faces or none, never part of a family. + /// Returns the number of faces added. + fn add_fonts(&self, files: Vec>) -> usize { + let present = |faces: &[Arc], family: &str, style: &str| { + faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style)) + }; + let mut new = Vec::new(); + for bytes in files { + let data = Arc::new(bytes); + for (i, family, style, coords) in enumerate_faces(&data) { + if present(&self.read_faces(), &family, &style) { + continue; + } + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { + new.push(Arc::new(f)); + } } - if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { - self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); + } + let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let mut added = 0; + for f in new { + // Checked again under the lock: another thread may have added the same face. + if !present(&faces, &f.family, &f.style) { + faces.push(f); added += 1; } } @@ -677,9 +703,21 @@ impl FontDb { n } - /// Load the files of the installed `family`. Returns whether any face was added. + /// Load the files of the installed `family` unless it is loaded. Returns whether it is loaded + /// (by this call or any other). #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { + let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); + self.load_family(family, &loading) + } + + /// [`FontDb::load_cataloged`] for a caller already holding `loading`. + #[cfg(not(target_arch = "wasm32"))] + fn load_family(&self, family: &str, _loading: &std::sync::MutexGuard<'_, ()>) -> bool { + // Another thread may have loaded it while this one waited for the lock. + if self.is_loaded(family) { + return true; + } let paths: Vec = { let cat = self.read_catalog(); let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); @@ -687,13 +725,8 @@ impl FontDb { p.dedup(); p }; - let mut any = false; - for p in paths { - if let Ok(data) = std::fs::read(&p) { - any |= self.add_font(data) > 0; - } - } - any + self.add_fonts(paths.iter().filter_map(|p| std::fs::read(p).ok()).collect()); + self.is_loaded(family) } /// Resolve a family + style to a face, falling back to the closest style of the family, then to @@ -846,6 +879,7 @@ impl FontDb { if c.is_control() || c.is_whitespace() { return false; } + let seen = self.read_faces().len(); { let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); if !sys.enabled || sys.misses.contains(&c) { @@ -854,9 +888,18 @@ impl FontDb { } let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); + let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); + // Another thread may have loaded a face for `c` (faces are only ever added), or found + // there is none, while this one waited. + if self.read_faces().len() != seen && covered(self) { + return true; + } + if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) { + return false; + } for fam in SYSTEM_FALLBACKS { if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { - self.load_cataloged(fam); + self.load_family(fam, &loading); if covered(self) { return true; } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 94e8081f..2587e7b0 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -152,6 +152,34 @@ mod tests { assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); } + #[test] + fn concurrent_lookups_all_find_an_installed_family() { + // Threads resolving an installed family nobody had loaded yet used to fall back to + // another face when a different thread finished loading it first, so the same text was + // laid out in different fonts depending on timing. + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + for _ in 0..20 { + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + let start = std::sync::Barrier::new(8); + let got: Vec = std::thread::scope(|s| { + let threads: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + start.wait(); + db.face("Hack", "Regular").family.clone() + }) + }) + .collect(); + threads.into_iter().map(|t| t.join().unwrap()).collect() + }); + assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn office_fonts_have_substitutes() { assert!(substitutes("Calibri").contains(&"Carlito")); From 6be9817693a5f8832a8bc3c0e4ceeac28000d3c6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:59:34 +0200 Subject: [PATCH 003/376] Export: PNG images (all slides) in the browser downloads every slide (#64) --- crates/ui-egui/src/dialogs.rs | 55 +++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73ce180b..0cd0bcc0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -455,19 +455,17 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } } else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) { - let bytes = match ext { - "png" | "jpg" => { - let img = deckcraft_render::render_slide( - &s.doc, - s.selection.slide, - &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }, - ); - if ext == "png" { img.to_png() } else { img.to_jpeg(92) } + if matches!(ext, "png" | "jpg") { + for (file, bytes) in web_image_files(&s.doc, s.selection.slide, all, &name, ext) { + dl(&file, &bytes); } - "pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params), - other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(), - }; - dl(&suggested, &bytes); + } else { + let bytes = match ext { + "pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params), + other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(), + }; + dl(&suggested, &bytes); + } } } ok || cancel @@ -928,11 +926,44 @@ fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json: deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default() } +/// Images for the browser download: the current slide, or every slide (named `1.png`, `2.png`... like the native export) when `all`. +fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec)> { + let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + let single = slides.len() == 1; + slides + .into_iter() + .enumerate() + .map(|(k, i)| { + let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }); + let bytes = if ext == "png" { img.to_png() } else { img.to_jpeg(92) }; + let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; + (file, bytes) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; use std::cell::{Cell, RefCell}; + /// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). + #[test] + fn web_export_all_slides_gives_one_file_per_slide() { + let mut doc = deckcraft_model::Presentation::default(); + if let Some(first) = doc.slides.first().cloned() { + doc.slides.push(first.clone()); + doc.slides.push(first); + } + assert_eq!(doc.slides.len(), 3); + let all = web_image_files(&doc, 1, true, "Deck", "png"); + let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); + let one = web_image_files(&doc, 1, false, "Deck", "png"); + assert_eq!(one.len(), 1); + assert_eq!(one[0].0, "Deck.png"); + } + /// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17). #[test] fn inset_wrapped_indents_every_row() { From 6ff5b274ea251fb8d22c3b1f1b66e74f1c08b1e5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:00:06 +0200 Subject: [PATCH 004/376] PPTX: animation rewind survives save and reopen (#77) --- crates/pptx/src/read/timing.rs | 2 ++ crates/pptx/src/write/timing.rs | 2 +- crates/pptx/tests/roundtrip.rs | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 500bb485..fcf01953 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -137,6 +137,8 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: preset_id: Some(preset), preset_subtype: Some(sub), auto_reverse: c.bool("autoRev").unwrap_or(false), + // PowerPoint's "Rewind when done playing" is `fill="remove"` on the effect node. + rewind: c.attr("fill") == Some("remove"), smooth_start: c.f64("accel").map(|v| v / 100_000.0).unwrap_or(0.0), smooth_end: c.f64("decel").map(|v| v / 100_000.0).unwrap_or(0.0), ..Default::default() diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index aa577ca7..09b101dc 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -445,7 +445,7 @@ fn effect_par(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bo .o("accel", accel) .o("decel", decel) .t("autoRev", a.auto_reverse) - .a("fill", "hold") + .a("fill", if a.rewind { "remove" } else { "hold" }) .a("grpId", e.grp) .a("nodeType", node), ); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 420a3921..a835ca69 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -633,6 +633,23 @@ fn every_animation_effect_survives() { } } +#[test] +fn animation_rewind_survives() { + let mut p = defaults::new_presentation(None); + let mut s = p.slides[0].as_ref().clone(); + let sh = shape(&mut p, "Target", 100.0, 100.0, 100.0, 100.0); + let id = sh.id; + s.shapes.push(sh); + s.animations.push(Animation { shape: id, rewind: true, ..Default::default() }); + s.animations.push(Animation { shape: id, effect: "fly".into(), option: "l".into(), start: AnimStart::WithPrevious, ..Default::default() }); + p.slides[0] = Arc::new(s); + let q = round(&p); + let b = &q.slides[0].animations; + assert_eq!(b.len(), 2); + assert!(b[0].rewind); + assert!(!b[1].rewind); +} + /// Writes bisection variants of the rich deck to `DECKCRAFT_PPTX_BISECT` (manual checks). #[test] fn bisect_variants() { From d2acdf07dba9c6a8e05681137945515fba441368 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:01:56 +0200 Subject: [PATCH 005/376] Comments: browser-created comments and replies get a creation timestamp (#72) --- Cargo.lock | 1 + crates/engine/Cargo.toml | 4 ++++ crates/engine/src/cmd/review.rs | 38 +++++++++++++++++++++++++++------ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e50d814e..f31cb6c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -951,6 +951,7 @@ dependencies = [ "deckcraft-text", "i_overlay", "image", + "js-sys", "log", "serde", "serde_json", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1f4dbfcd..14071145 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,3 +25,7 @@ i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } + +# The browser clock for comment timestamps (wasm has no std::time::SystemTime). +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..856aa128 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -43,20 +43,29 @@ pub fn specs() -> Vec { ] } -fn now() -> String { +/// Seconds since the Unix epoch: the system clock, or the browser's on wasm (which has no `SystemTime`). +fn now_secs() -> u64 { #[cfg(not(target_arch = "wasm32"))] { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let (y, m, d) = super::design::civil((secs / 86_400) as i64); - let t = secs % 86_400; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } #[cfg(target_arch = "wasm32")] { - String::new() + (js_sys::Date::now() / 1000.0).max(0.0) as u64 } } +/// A UTC timestamp as saved on comments: `2026-10-10T13:10:35Z`. +fn iso_utc(secs: u64) -> String { + let (y, m, d) = super::design::civil((secs / 86_400) as i64); + let t = secs % 86_400; + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) +} + +fn now() -> String { + iso_utc(now_secs()) +} + fn add(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string(); let author = s.prefs.author.clone(); @@ -264,3 +273,20 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { })?; ok() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timestamps_are_iso_utc() { + assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso_utc(1_791_637_835), "2026-10-10T13:10:35Z"); + } + + #[test] + fn the_clock_is_not_empty() { + // On wasm this used to return an empty string, so browser comments were saved undated. + assert!(now().starts_with("20"), "{}", now()); + } +} From 7afa9ea1330760c0f7cc06610c7ba8dd77eb0a43 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:01:44 +0200 Subject: [PATCH 006/376] =?UTF-8?q?Command=20palette:=20one=20Export?= =?UTF-8?q?=E2=80=A6=20entry=20that=20opens=20the=20dialog,=20no=20path-on?= =?UTF-8?q?ly=20duplicate=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/menus.rs | 50 +++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9349dc36..2a413e95 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -52,6 +52,22 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { true } +/// The palette's entries for the lowercase query `q`. An engine command is hidden when a UI command has the same label: Open…, Save As…, +/// Export… and Audio/Video from File… need a `path` the palette can't give, so the one entry left opens the dialog instead of failing. +fn palette_items(session: &deckcraft_engine::Session, q: &str) -> Vec<(String, String, Option<&'static str>)> { + let mut items: Vec<(String, String, Option<&'static str>)> = session + .commands() + .into_iter() + .filter(|c| c.enabled && !crate::UI_COMMANDS.iter().any(|u| u.1 == c.label)) + .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) + .chain(crate::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2))) + .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(q) || id.to_lowercase().contains(q)) + .collect(); + items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(q)); + items.truncate(60); + items +} + /// ⇧⌘P: search every command by name and run it. pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { let Some((mut query, mut sel)) = app.palette.take() else { return }; @@ -66,17 +82,7 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search commands…").desired_width(f32::INFINITY).font(theme::font(15.0))); r.request_focus(); let q = query.to_lowercase(); - let mut items: Vec<(String, String, Option<&'static str>)> = app - .session - .commands() - .into_iter() - .filter(|c| c.enabled) - .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) - .chain(crate::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2))) - .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(&q) || id.to_lowercase().contains(&q)) - .collect(); - items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(&q)); - items.truncate(60); + let items = palette_items(&app.session, &q); let (down, up, enter, esc) = ui.input(|i| { ( i.key_pressed(egui::Key::ArrowDown), @@ -245,3 +251,25 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Help", vec![("About DeckCraft", "app.about"), ("Command Palette", "app.palette")]), ] } + +#[cfg(test)] +mod tests { + use super::palette_items; + + /// #79: the palette listed two "Export…" entries and the first (`file.export`) failed with "missing `path`". + #[test] + fn palette_has_one_export_entry_that_opens_the_dialog() { + let session = deckcraft_engine::Session::with_new(); + for query in ["exp", "export"] { + let exports: Vec<_> = palette_items(&session, query).into_iter().filter(|(_, label, _)| label == "Export…").collect(); + assert_eq!(exports.len(), 1, "query {query}"); + assert_eq!(exports[0].0, "app.exportDialog"); + } + // Open…, Save As…, Audio/Video from File… and Notes had the same duplicates: every UI command is the only entry with its label. + for (id, label, _, _) in crate::UI_COMMANDS { + let same: Vec<_> = palette_items(&session, &label.to_lowercase()).into_iter().filter(|(_, l, _)| l == label).collect(); + assert_eq!(same.len(), 1, "{label}"); + assert_eq!(same[0].0, *id, "{label}"); + } + } +} From cebf667579cf4fa4aa8e78f4b27a805864515112 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:03:35 +0200 Subject: [PATCH 007/376] Paste: Ctrl+V pastes an image-only clipboard as a picture (#53) --- crates/ui-egui/src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..b72bf753 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -209,6 +209,8 @@ pub struct SlideApp { pub(crate) notes_buf: (u64, usize, String), /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, + /// A V key press or a Paste event arrived since the last V release (see `keyboard`). + v_seen: bool, } impl SlideApp { @@ -244,6 +246,7 @@ impl SlideApp { quit_confirmed: false, notes_buf: (0, usize::MAX, String::new()), thumb_drag: None, + v_seen: false, } } @@ -607,10 +610,21 @@ impl SlideApp { /// Keyboard and clipboard for the slide (when no text field has focus). fn keyboard(&mut self, ctx: &egui::Context) { + let events = ctx.input(|i| i.events.clone()); + // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the + // key press, so the release of a V press never seen is the paste. Tracked even while a text + // field has focus, so that a V typed there is not taken for one. + let mut image_paste = false; + for e in &events { + match e { + egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, + egui::Event::Key { key: egui::Key::V, pressed: false, .. } => image_paste = !std::mem::take(&mut self.v_seen), + _ => {} + } + } if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() { return; } - let events = ctx.input(|i| i.events.clone()); if self.home_open() { // Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the // presentation: typing, pasting or deleting would edit the hidden presentation. @@ -627,6 +641,13 @@ impl SlideApp { self.drain_requests(); return; } + let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + if let Some(png) = img { + let data = deckcraft_engine::cmd::base64_encode(&png); + if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { + self.set_status(e.to_string()); + } + } for e in events { match e { egui::Event::Text(t) => { @@ -932,6 +953,42 @@ mod tests { assert_eq!(harness.state().session.documents().len(), 2); } + /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and + /// swallows the key press, so only the V release reaches the app. + #[test] + fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() { + let session = Session::with_new(); + let png = { + let d = session.active().unwrap(); + deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 + }; + assert!(!png.is_empty()); + let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() }; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + let pictures = |app: &SlideApp| { + let d = app.session.active().unwrap(); + d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count() + }; + let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; + // A text paste (Paste event on the press) does not also paste the image on release. + harness.event(egui::Event::Paste("hello".into())); + harness.run_steps(2); + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 1); + } + #[test] fn home_needs_an_open_presentation() { let mut app = SlideApp::new(Session::new(), Services::default()); From 20366a8ea9da5f04aa9187ff4d391a011ea29ea7 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen <7456949+mikl0s@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:33:10 +0200 Subject: [PATCH 008/376] Export: an unknown format is an error instead of a deckcraft file file.export with an explicit format outside png, jpeg, jpg, pdf, pptx, potx, ppsx, deckcraft, outline and txt fell through to the native deckcraft zip, so a request for "docx" reported success and wrote a deckcraft archive under that name. It now returns an error and writes nothing. A format inferred from the file extension behaves as before. --- crates/engine/src/cmd/file.rs | 5 +++-- crates/mcp/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 55aac8bd..dc7840e4 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -264,11 +264,12 @@ fn export(s: &mut Session, p: &Value) -> Result { write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } - other => { - let bytes = save_bytes(&st.doc, other)?; + "pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => { + let bytes = save_bytes(&st.doc, &format)?; write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } + other => Err(bad("file.export", format!("unknown format `{other}`"))), } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index b1521521..ec9110a2 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -117,3 +117,15 @@ fn export_tool_lists_pdf_and_its_options() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn export_rejects_unknown_format() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let path = std::env::temp_dir().join(format!("deckcraft-mcp-unknown-format-{}.docx", std::process::id())); + let _ = std::fs::remove_file(&path); + let r = call(&mut s, 1, "tools/call", json!({"name": "export", "arguments": {"path": path.to_str().unwrap(), "format": "docx"}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("docx"), "{r}"); + assert!(!path.exists(), "nothing may be written for an unknown format"); +} From d34b5df775cd4dcbdbd4ef7b381493db30928807 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen <7456949+mikl0s@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:40:53 +0200 Subject: [PATCH 009/376] Slides: going to a slide that does not exist is an error slide.go clamped an out-of-range index to the last slide and reported success, so a request for slide 99 of a short deck landed on the last slide without a word. It now returns an error and stays on the current slide. A slide id beyond u32 is reported as an unknown slide instead of being read as id 0. --- crates/engine/src/cmd/slide.rs | 9 ++++++++- crates/mcp/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..367c269e 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -283,9 +283,16 @@ fn go_to(s: &mut Session, i: usize) -> Result { fn go(s: &mut Session, p: &Value) -> Result { let i = match (usize_param(p, "index"), p.get("id").and_then(Value::as_u64)) { (Some(i), _) => i, - (None, Some(id)) => s.doc()?.doc.slide_index(SlideId(u32::try_from(id).unwrap_or(0))).ok_or_else(|| bad("slide.go", "no such slide"))?, + (None, Some(id)) => s + .doc()? + .doc + .slide_index(SlideId(u32::try_from(id).map_err(|_| bad("slide.go", "no such slide"))?)) + .ok_or_else(|| bad("slide.go", "no such slide"))?, _ => return Err(bad("slide.go", "missing `index`")), }; + if i >= s.doc()?.doc.slides.len() { + return Err(bad("slide.go", format!("no slide {i}"))); + } go_to(s, i) } fn next(s: &mut Session, _p: &Value) -> Result { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index b1521521..6c14f208 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -90,6 +90,26 @@ fn errors_are_reported_not_panics() { assert_eq!(r["result"]["isError"], true); } +fn current_slide_title(s: &mut Server) -> Value { + tool(s, "inspect_slide", json!({}))["title"].clone() +} + +#[test] +fn go_to_slide_out_of_range_is_error() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + tool(&mut s, "add_slide", json!({"layout": "titleAndContent", "title": "Second", "body": "x"})); + // Boundaries stay valid: first and last slide. + tool(&mut s, "go_to_slide", json!({"index": 0})); + tool(&mut s, "go_to_slide", json!({"index": 1})); + assert_eq!(current_slide_title(&mut s), "Second"); + for bad in [json!(2), json!(99)] { + let r = call(&mut s, 1, "tools/call", json!({"name": "go_to_slide", "arguments": {"index": bad}})); + assert_eq!(r["result"]["isError"], true, "{bad}: {r}"); + assert_eq!(current_slide_title(&mut s), "Second", "failed go_to_slide must not move"); + } +} + #[test] fn export_tool_lists_pdf_and_its_options() { let mut s = Server::new(Box::new(Headless::new())); From 736dd52b51c6525dd479242059694b963bdfd950 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen <7456949+mikl0s@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:41:04 +0200 Subject: [PATCH 010/376] Commands: an invalid shape id is an error instead of the selection A shape id that was present but not a valid id (negative, not an integer, larger than u32) was treated as absent, so commands such as text.set and text.edit silently edited the selected shape instead. id_param now tells three cases apart: absent (the selection fallback is unchanged), valid, and invalid (an error naming the command). arrange, edit (select), inspect, review, table, text and the animation painter pick this up. --- crates/engine/src/cmd/animation.rs | 2 +- crates/engine/src/cmd/arrange.rs | 2 +- crates/engine/src/cmd/edit.rs | 5 +++- crates/engine/src/cmd/inspect.rs | 3 ++- crates/engine/src/cmd/mod.rs | 15 ++++++++--- crates/engine/src/cmd/review.rs | 2 +- crates/engine/src/cmd/table.rs | 4 +-- crates/engine/src/cmd/text.rs | 6 ++--- crates/mcp/src/tests.rs | 41 ++++++++++++++++++++++++++++++ 9 files changed, 67 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 8b6fe5a7..5804fee6 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -305,7 +305,7 @@ fn get(s: &mut Session, p: &Value) -> Result { fn painter_pick(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = id_param(p, "id") + let id = id_param(p, "id", "animation.painter")? .or_else(|| st.selection.shapes.first().copied()) .ok_or_else(|| bad("animation.painter", "select an animated shape first"))?; let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?; diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index c755050e..f195442d 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -86,7 +86,7 @@ fn order(s: &mut Session, p: &Value, how: i32) -> Result { } fn reorder(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?; + let id = id_param(p, "id", "arrange.reorder")?.ok_or_else(|| bad("arrange.reorder", "missing `id`"))?; let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?; s.edit(|doc, sel| { let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..8d744acf 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -353,7 +353,10 @@ fn deselect(s: &mut Session, _p: &Value) -> Result { } fn select(s: &mut Session, p: &Value) -> Result { - let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default(); + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => id_param(p, "id", "edit.select")?.map(|i| vec![i]).unwrap_or_default(), + }; let add = bool_or(p, "add", false); let toggle = bool_or(p, "toggle", false); let valid: Vec = ids.into_iter().filter(|i| s.active().is_some_and(|d| d.shape(*i).is_some())).collect(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index db162a38..fb666abc 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -136,7 +136,8 @@ fn slide(s: &mut Session, p: &Value) -> Result { fn shape(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = id_param(p, "id").or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?; + let id = + id_param(p, "id", "shape.inspect")?.or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?; let sh = st.shape(id).ok_or_else(|| bad("shape.inspect", format!("no shape {id}")))?; let x = xfrm_of(&st.doc, &st.selection, sh); Ok(json!({"shape": serde_json::to_value(sh).unwrap_or_default(), "box": x})) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 29c7013e..ebc56c75 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -171,8 +171,17 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { pub(crate) fn usize_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()) } -pub(crate) fn id_param(p: &Value, key: &str) -> Option { - p.get(key).and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).map(ShapeId) +/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when +/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection. +pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .map(|v| Some(ShapeId(v))) + .ok_or_else(|| bad(cmd, format!("`{key}` is not a valid shape id: {v}"))), + } } pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(ShapeId).collect()) @@ -196,7 +205,7 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result> { { return Ok(ids); } - if let Some(id) = id_param(p, "id") { + if let Some(id) = id_param(p, "id", "shape")? { return Ok(vec![id]); } Ok(s.doc()?.selection.shapes.clone()) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..72616274 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -60,7 +60,7 @@ fn now() -> String { fn add(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string(); let author = s.prefs.author.clone(); - let shape = id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied())); + let shape = id_param(p, "id", "comment.add")?.or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied())); let (x, y) = (f64_or(p, "x", 10.0), f64_or(p, "y", 10.0)); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.add", "no slide"))?); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e9f6f164..757aab6d 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -78,7 +78,7 @@ fn at(s: &Session, p: &Value) -> (usize, usize) { } fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> Result<()>) -> Result { - let id = match id_param(p, "id") { + let id = match id_param(p, "id", cmd)? { Some(i) => i, None => { let st = s.doc()?; @@ -253,7 +253,7 @@ fn row_height(s: &mut Session, p: &Value) -> Result { } fn select_cells(s: &mut Session, p: &Value) -> Result { - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "table.selectCells")? { Some(i) => i, None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("table.selectCells", "select a table"))?, }; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a51..ee58986f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -158,7 +158,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { })?; return ok(); } - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "text.edit")? { Some(i) => i, None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.edit", "select a shape with text"))?, }; @@ -491,7 +491,7 @@ fn select_para(s: &mut Session, p: &Value) -> Result { fn set(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string(); - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "text.set")? { Some(i) => i, None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, }; @@ -531,7 +531,7 @@ fn set(s: &mut Session, p: &Value) -> Result { fn get(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = match id_param(p, "id") { + let id = match id_param(p, "id", "text.get")? { Some(i) => i, None => match &st.selection.text { Some(t) => t.shape, diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index b1521521..b2e92455 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -79,6 +79,47 @@ fn agent_builds_a_deck() { assert_eq!(n2, n1 + 1); } +#[test] +fn set_text_unknown_id_is_error() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let title_id = tool(&mut s, "inspect_slide", json!({}))["shapes"][0]["id"].as_u64().unwrap(); + tool(&mut s, "select", json!({"ids": [title_id]})); + // -1 and 2^32 are not shape ids; they must not fall back to the selection. + for bad in [json!(999999), json!(-1), json!(4294967296u64)] { + let r = call(&mut s, 1, "tools/call", json!({"name": "set_text", "arguments": {"id": bad, "text": "x"}})); + assert_eq!(r["result"]["isError"], true, "{bad}: {r}"); + } + // A real id, and a just-created shape, still work; omitting id still uses the selection. + tool(&mut s, "set_text", json!({"id": title_id, "text": "Real"})); + let sid = tool(&mut s, "add_shape", json!({"preset": "rect", "x": 10, "y": 10, "w": 50, "h": 50}))["id"].as_u64().unwrap(); + tool(&mut s, "set_text", json!({"id": sid, "text": "Fresh"})); + tool(&mut s, "select", json!({"ids": [title_id]})); + tool(&mut s, "set_text", json!({"text": "Selected"})); + let sl = tool(&mut s, "inspect_slide", json!({})); + let text_of = |id: u64| sl["shapes"].as_array().unwrap().iter().find(|x| x["id"].as_u64() == Some(id)).unwrap()["text"].clone(); + assert_eq!(text_of(sid), "Fresh"); + assert_eq!(text_of(title_id), "Selected"); +} + +#[test] +fn invalid_id_never_falls_back_to_selection() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let title_id = tool(&mut s, "inspect_slide", json!({}))["shapes"][0]["id"].as_u64().unwrap(); + tool(&mut s, "select", json!({"ids": [title_id]})); + for cmd in ["text.edit", "text.get", "shape.inspect", "table.selectCells", "arrange.reorder"] { + for bad in [json!(-1), json!(1.5), json!("7"), json!(4294967296u64)] { + let r = call(&mut s, 1, "tools/call", json!({"name": "run_command", "arguments": {"command": cmd, "params": {"id": bad, "index": 0}}})); + assert_eq!(r["result"]["isError"], true, "{cmd} {bad}: {r}"); + } + } + // Absent id still means the selection, and a real id still works. + tool(&mut s, "run_command", json!({"command": "text.get", "params": {}})); + tool(&mut s, "run_command", json!({"command": "text.get", "params": {"id": title_id}})); + tool(&mut s, "run_command", json!({"command": "shape.inspect", "params": {"id": title_id}})); +} + #[test] fn errors_are_reported_not_panics() { let mut s = Server::new(Box::new(Headless::new())); From 35d265891c83898431b9d5e7395e07f7e27fa678 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 10 Oct 2026 17:28:25 -0400 Subject: [PATCH 011/376] docs(readme): percent-encode middle dot in platforms badge url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ceb6a8c4..168c4725 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Status: early development License: MIT OR Apache-2.0 Written in Rust - Platforms + Platforms

From 04069e19682c3c20d19ad7492dc53fb4b8f6879d Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:03:01 +0200 Subject: [PATCH 012/376] Morph: text boxes without a partner fade in place instead of moving (#119) The last matching pass paired any two shapes of one kind and preset geometry, so a text box leaving the slide was paired with an unrelated one arriving and flew across to it. That pass now only pairs shapes without text: a text box needs its name or its text on the other slide to morph, otherwise it fades out (or in) where it is, as in PowerPoint. generated with Claude Code --- crates/anim/src/morph.rs | 7 +++++-- crates/anim/src/tests.rs | 20 ++++++++++++++++++++ docs/animation-parity.md | 3 ++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index b5808c4f..1d4ab8f3 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String { /// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by /// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same -/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole). +/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on +/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used +/// once; top-level shapes only (groups move whole). pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new(); let mut used_a = vec![false; a.shapes.len()]; @@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { x.kind_name() == y.kind_name() && x.geom.preset_name().is_some() && x.geom.preset_name() == y.geom.preset_name() - && text_of(x).trim().is_empty() == text_of(y).trim().is_empty() + && text_of(x).trim().is_empty() + && text_of(y).trim().is_empty() }, ]; for pass in passes { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..de9c40ab 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -529,6 +529,26 @@ fn morph_pairs_by_name_text_and_geometry() { assert!(morph_pairs(&Slide::default(), &b).is_empty()); } +#[test] +fn morph_pairs_leaves_unrelated_text_boxes_alone() { + // A text box that leaves and an unrelated one that arrives fade in place (#119). + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) }; + a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")]; + b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")]; + assert!(morph_pairs(&a, &b).is_empty()); + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect(); + assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]); + // Empty shapes of one kind and geometry still pair, and a named text box keeps its partner. + a.shapes.push(shape(3, "Rect A")); + b.shapes.push(shape(4, "Rect B")); + b.shapes[0].name = "TextBox 1".into(); + assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]); +} + #[test] fn morph_xfrm_interpolates() { let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) }; diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 1d50766b..74d246f1 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -24,7 +24,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX | Duration, advance on click / after time, Apply To All | yes | yes | | Transition sounds | yes | **no** (the model has the field; no picker, no playback) | | Preview in the editor | yes | yes | -| Morph: objects move, resize, recolour | yes | yes | +| Morph: objects move, resize, recolour; unmatched text boxes fade in place | yes | yes (#119) | | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | @@ -76,4 +76,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Morph leaves unrelated text boxes unpaired: they fade in place (#119) | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | From 7db770d91dfc254e93f9f4528197a806ef34ef33 Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:16:27 +0200 Subject: [PATCH 013/376] Morph: a picture cropped differently on the next slide morphs its crop (#118) Two paired pictures that differed only in crop counted as changed content, so the new one faded in over the old one on the moving box. Now they stay one picture whose crop morphs: the whole image moves and scales linearly between where it lies on either slide, and each frame's crop is what the interpolated box shows of it (morph_crop). generated with Claude Code --- crates/anim/src/lib.rs | 2 +- crates/anim/src/morph.rs | 63 ++++++++++++++++++++++++++++++++++++++-- crates/anim/src/tests.rs | 35 ++++++++++++++++++++++ docs/animation-parity.md | 3 +- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index fae7e4dd..bea76eb8 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -27,7 +27,7 @@ pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; -pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; +pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index b5808c4f..96bd761b 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -173,9 +173,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool { a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) } } +/// The content of a shape apart from what Morph interpolates, with a picture's crop left out. +fn uncropped(s: &Shape) -> Shape { + let mut c = content(s); + if let ShapeKind::Picture { fill } = &mut c.kind { + fill.crop = [0.0; 4]; + } + c +} + +fn crop_of(s: &Shape) -> [f64; 4] { + match &s.kind { + ShapeKind::Picture { fill } => fill.crop, + _ => [0.0; 4], + } +} + +/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph. +fn recropped(a: &Shape, b: &Shape) -> bool { + matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b) +} + +/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box +/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales +/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back +/// to blending the crops when a crop leaves nothing visible. +pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] { + // Along one axis: (box start, box size, crop before, crop after) → the crops at `t`. + let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| { + let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb); + if va <= 1e-6 || vb <= 1e-6 { + return (lerp(la, lb, t), lerp(ha, hb, t)); + } + // The whole image: its extent and start on either slide. + let (ea, eb) = (sa / va, sb / vb); + let e = lerp(ea, eb, t); + if !e.is_finite() || e.abs() <= 1e-9 { + return (lerp(la, lb, t), lerp(ha, hb, t)); + } + let o = lerp(pa - la * ea, pb - lb * eb, t); + let lo = (p - o) / e; + (lo, 1.0 - lo - s / e) + }; + let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w)); + let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h)); + let c = [l, tp, r, bt]; + if c.iter().all(|v| v.is_finite()) { c } else { cb } +} + /// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes /// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the -/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]), +/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only +/// in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]), /// otherwise the new shape fades in under the fading old one on the same moving /// box; with the transition option `words` or `characters` their texts are listed in /// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for @@ -223,8 +272,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: (_, xb) => xb, }; let reshape = reshaped(sa, sb); - if reshape || content(sa) == content(sb) { + let recrop = recropped(sa, sb); + if reshape || recrop || content(sa) == content(sb) { let mut m = Shape { xfrm: x, ..sb.clone() }; + if recrop && let ShapeKind::Picture { fill } = &mut m.kind { + fill.crop = match (xa, xb, x) { + (Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s), + _ => { + let (ca, cb) = (crop_of(sa), crop_of(sb)); + [lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)] + } + }; + } if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb)) && fa != fb { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..4d4a7745 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -529,6 +529,41 @@ fn morph_pairs_by_name_text_and_geometry() { assert!(morph_pairs(&Slide::default(), &b).is_empty()); } +#[test] +fn morph_crop_moves_the_image_linearly() { + let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0); + // Same box, the crop pans from the left half to the right half of a 200-wide image. + let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5); + assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}"); + // The box shrinks over an image that stays put: only the right crop grows. + let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5); + assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}"); + // Nothing visible on one side: the crops blend. + let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5); + assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}"); +} + +#[test] +fn morph_frame_morphs_a_picture_crop() { + // A picture recropped on the next slide stays one picture whose crop morphs (#118). + let p = deckcraft_model::defaults::new_presentation(None); + let pic = |id, crop| Shape { + kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } }, + ..shape(id, "Picture 1") + }; + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])]; + b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])]; + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + assert_eq!(f.shapes.len(), 1); + assert!(f.opacity.is_empty()); + let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") }; + let want = 0.5 * f.mix; + assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop); + assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind); +} + #[test] fn morph_xfrm_interpolates() { let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) }; diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 1d50766b..7f3d9878 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -24,7 +24,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX | Duration, advance on click / after time, Apply To All | yes | yes | | Transition sounds | yes | **no** (the model has the field; no picker, no playback) | | Preview in the editor | yes | yes | -| Morph: objects move, resize, recolour | yes | yes | +| Morph: objects move, resize, recolour; a picture's crop morphs | yes | yes (#118) | | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | @@ -76,4 +76,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Morph animates a changed picture crop (#118) | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | From 3bb284cfdd15b357bf252449e68f54cd67cc983f Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:26:42 +0200 Subject: [PATCH 014/376] Text: typing goes on after Tab in a text box (#78) The engine handled Tab (a tab character, or a list level at the start of a paragraph), but egui also treated it as focus navigation: with no widget focused it focused the first ribbon widget, and since the slide only takes keys while nothing is focused, every key after Tab was lost. When the slide has the keyboard, the keyboard handler now cancels that focus move. This also covers the "can't type after indenting" part of #93. generated with Claude Code --- crates/ui-egui/src/lib.rs | 44 +++++++++++++++++++++++++++++++++++++++ docs/gaps.md | 6 +----- docs/ui-parity.md | 3 ++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..6983f1c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -664,6 +664,12 @@ impl SlideApp { } } egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == egui::Key::Tab { + // The slide has the keyboard, so Tab is ours (indent, tab character, next + // shape). Left to egui it would also focus the first ribbon widget, and + // every key after it would go there instead of the slide (#78). + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { continue; @@ -932,6 +938,44 @@ mod tests { assert_eq!(harness.state().session.documents().len(), 2); } + /// #78: after Tab in a text box, typing and deleting no longer reached the text. + #[test] + fn typing_goes_on_after_tab_in_a_text_box() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("ab".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("c".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\tc"); + harness.key_press(egui::Key::Backspace); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t"); + assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing"); + // Tab at the start of a paragraph indents it, and typing goes on there too (#93). + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("d".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t\nd"); + let level = |h: &egui_kittest::Harness| { + h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level + }; + assert_eq!(level(&harness), 1); + } + #[test] fn home_needs_an_open_presentation() { let mut app = SlideApp::new(Session::new(), Services::default()); diff --git a/docs/gaps.md b/docs/gaps.md index 6ea4130a..fa303000 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -138,11 +138,6 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** copy-paste of screenshots is a core workflow. - **Estimate:** 2–4 h. **Beta blocker.** -### Tab inside a text box ends text entry (#78) -- **Evidence:** #78. -- **Impact:** tab stops and indented lists can't be typed. -- **Estimate:** 1–2 h. - ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & @@ -247,5 +242,6 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Removed "Tab inside a text box ends text entry" (#78, fixed) | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5c7c50b9..8fe5e08d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -48,7 +48,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa | Connector glue and rerouting | yes | yes | | Edit points on freeforms | yes | **no** | | Crop handles, crop to shape, aspect ratio | yes | partial | -| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | +| Text caret, selection, double/triple click, drag-select | yes | yes | | Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | @@ -87,4 +87,5 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Tab in text boxes keeps editing (#78) | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 | From 208c16364782cd83a19f08f095d0138ed1065422 Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:55:36 +0200 Subject: [PATCH 015/376] Slide show: media set to play in the click sequence starts on Next (#116) PPTX import already read PowerPoint's media effects (presetClass mediacall: Play, Pause, Stop) into the timeline, but the show never carried them out: media started only with autoplay or a click on it. The show now sets off a step's media effects as the step reaches them, and also when a click cuts the step short. A step also no longer lasts as long as the media it starts: the video plays on by itself and the next click goes on at once. An After Previous effect behind it still waits for the media to end. generated with Claude Code --- crates/anim/src/tests.rs | 17 ++++++ crates/anim/src/timeline.rs | 18 ++++++- crates/ui-egui/src/show.rs | 102 ++++++++++++++++++++++++++++++++++-- docs/slideshow-parity.md | 3 +- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..48544b7c 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -789,3 +789,20 @@ fn show_follows_links() { assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None); assert_eq!(s.slide, 3); } + +#[test] +fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() { + // #116: a video set to play in the click sequence starts with its step, and the step doesn't + // last as long as the video (the next click goes on while it plays). + let play = Animation { duration_ms: 5000, ..anim(1, AnimClass::Media, "play", AnimStart::OnClick) }; + let tl = Timeline::new(&slide_with(vec![play.clone(), anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H); + assert_eq!(tl.steps(), 2); + assert_eq!(tl.step_duration(0), 0.0); + let cues = tl.media_cues(0, f64::NEG_INFINITY, 0.0); + assert_eq!(cues.iter().map(|e| (e.shape, e.anim)).collect::>(), [(ShapeId(1), 0)]); + assert!(tl.media_cues(0, 0.0, f64::INFINITY).is_empty(), "set off once"); + assert!(tl.media_cues(1, f64::NEG_INFINITY, f64::INFINITY).is_empty(), "only media effects"); + // An After Previous effect still waits for the video. + let tl = Timeline::new(&slide_with(vec![play, anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious)]), W, H); + assert!((tl.step_duration(0) - 6.0).abs() < 1e-9, "{}", tl.step_duration(0)); +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index e73acfd9..cc477904 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -249,7 +249,9 @@ impl Timeline { max_end = max_end.max(end); let step = steps.len().saturating_sub(1); if let Some(s) = steps.last_mut() { - s.duration = s.duration.max(end); + // Media plays on by itself once started: it holds back an After Previous + // effect (`max_end`), but not the next click (#116). + s.duration = s.duration.max(if a.class == AnimClass::Media { start } else { end }); } tl.effects.push(Effect { timing: EffectTiming { @@ -308,6 +310,20 @@ impl Timeline { self.main.get(i).map(|s| s.duration).unwrap_or(0.0) } + /// Media effects (Play, Pause, Stop) of main step `step` that start after `from` and by `to` + /// seconds into it, in order: what a show running the step from `from` to `to` sets off. + pub fn media_cues(&self, step: usize, from: f64, to: f64) -> Vec { + let mut v: Vec = self + .effects + .iter() + .map(|e| &e.timing) + .filter(|e| e.class == AnimClass::Media && e.sequence == Sequence::Main && e.step == step && e.start > from && e.start <= to) + .cloned() + .collect(); + v.sort_by(|a, b| a.start.total_cmp(&b.start)); + v + } + /// Total duration of the main sequence played without pauses. pub fn total_duration(&self) -> f64 { self.main.iter().map(|s| s.duration).sum() diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1c8e6e1e..826b30c2 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -42,6 +42,10 @@ pub struct Show { last_viewed: Option, /// Click areas of hyperlinks and shape actions on slide `.0`, in slide points. links: Option<(usize, Vec)>, + /// The main step whose media effects are being set off, and how far into it they have been. + media_clock: Option<(usize, f64)>, + /// Media effects set off and not yet carried out: (shape, effect id). + media_cues: Vec<(ShapeId, String)>, } #[derive(Clone, PartialEq)] @@ -95,6 +99,8 @@ impl Show { media_slide: None, last_viewed: None, links: None, + media_clock: None, + media_cues: vec![], } } @@ -114,10 +120,17 @@ impl Show { fn apply(&mut self, a: ShowAction, doc: &Presentation, now: f64) -> bool { match a { - ShowAction::PlayStep(_) => { + ShowAction::PlayStep(k) => { self.step_start = now; + self.media_clock = Some((k, f64::NEG_INFINITY)); } - ShowAction::FinishStep(_) | ShowAction::StepBack(_) => { + ShowAction::FinishStep(n) => { + // A step cut short by a click still sets off the media it holds. + self.cue_media(doc, n, f64::INFINITY); + self.step_end = now; + } + ShowAction::StepBack(_) => { + self.media_clock = None; self.step_end = now; } ShowAction::Slide(i) => { @@ -145,6 +158,7 @@ impl Show { self.slide_start = now; self.step_start = now; self.step_end = now; + self.media_clock = None; self.strokes.clear(); if self.preview_only { return false; @@ -168,11 +182,28 @@ impl Show { let auto = self.timeline(doc).step_is_auto(0); if auto || self.autoplay { let a = self.state.next(doc); - if matches!(a, ShowAction::PlayStep(_)) { + if let ShowAction::PlayStep(k) = a { self.step_start = now; + self.media_clock = Some((k, f64::NEG_INFINITY)); } } } + + /// Queue the media effects (Play, Pause, Stop) of main step `step` that the show reached since + /// the last call, up to `t` seconds into the step (#116). + fn cue_media(&mut self, doc: &Presentation, step: usize, t: f64) { + let Some((k, from)) = self.media_clock else { return }; + if k != step { + self.media_clock = None; + return; + } + let slide = self.state.slide; + let cues = self.timeline(doc).media_cues(step, from, t); + let effects: Vec<(ShapeId, String)> = + cues.iter().filter_map(|e| doc.slides.get(slide).and_then(|s| s.animations.get(e.anim)).map(|a| (e.shape, a.effect.clone()))).collect(); + self.media_cues.extend(effects); + self.media_clock = if t.is_finite() { Some((k, t)) } else { None }; + } } fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { @@ -352,7 +383,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { keep = false; } } + if show.state.playing { + let step = show.state.step; + show.cue_media(&doc, step, now - show.step_start); + } show_media(app, &mut show, &doc); + run_media_cues(app, &mut show, &doc); // Draw. let full = ui.max_rect(); let painter = ui.painter_at(full); @@ -670,6 +706,21 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { } } +/// Carry out the media effects the show's steps set off: Play starts from the beginning unless +/// the clip is already playing, Pause toggles, Stop stops. +fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { + for (id, effect) in std::mem::take(&mut show.media_cues) { + let Some(clip) = crate::media::find_clip(doc, id) else { continue }; + match effect.as_str() { + "pause" => app.media.toggle(doc, id, &clip, crate::media::Owner::Show), + "stop" => app.media.stop(id), + // Already playing (it also starts automatically): leave it running. + _ if app.media.is_playing(id) => {} + _ => app.media.play(doc, id, &clip, Some(0.0), crate::media::Owner::Show), + } + } +} + /// The media shape under the pointer on the shown slide. fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> Option<(ShapeId, deckcraft_model::MediaClip)> { let p = ctx.input(|i| i.pointer.interact_pos())?; @@ -843,4 +894,49 @@ mod tests { assert_eq!(tr.from, Some(0)); assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one"); } + + #[test] + fn media_in_the_click_sequence_plays_with_its_step() { + // #116: media set to play in the click sequence never started; only a click on it did. + let s = deckcraft_engine::Session::with_new(); + let mut doc = s.active().unwrap().doc.clone(); + let id = doc.slides[0].shapes[0].id; + let play = |delay_ms| deckcraft_model::Animation { + shape: id, + class: deckcraft_model::AnimClass::Media, + effect: "play".into(), + duration_ms: 5000, + delay_ms, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut doc).slides[0]).animations = vec![play(0), play(2000)]; + let mut show = Show::new(&doc, 0, false, false); + assert!(show.apply(ShowAction::Slide(0), &doc, 0.0)); + // Next: the first step plays and sets off its Play once. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(0)); + assert!(show.apply(a, &doc, 1.0)); + show.cue_media(&doc, 0, 0.0); + show.cue_media(&doc, 0, 0.5); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // The step doesn't wait for the video: the step clock ends it at once. + assert_eq!(show.timeline(&doc).step_duration(0), 0.0); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 1.0)); + assert!(show.media_cues.is_empty(), "set off once"); + // Next again, before the second Play's delay: cutting the step short still sets it off. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(1)); + assert!(show.apply(a, &doc, 2.0)); + show.cue_media(&doc, 1, 0.5); + assert!(show.media_cues.is_empty()); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 2.6)); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // Going back doesn't set anything off. + let a = show.state.prev(&doc); + assert!(show.apply(a, &doc, 3.0)); + show.cue_media(&doc, 1, 9.0); + assert!(show.media_cues.is_empty()); + } } diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fd981595..3eae40ea 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -20,7 +20,7 @@ ready** (estimated), **10–16 h** to parity. | Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Hidden slides skipped | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) | -| Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | +| Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable; click sequence since #116 | | Animated GIFs play; pause from their corner button | yes | yes (#39) | | | See All Slides grid during the show | yes | no | | | Zoom into part of a slide during the show | yes | no | | @@ -73,4 +73,5 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Media effects (Play, Pause, Stop) in the click sequence play with their step (#116) | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu | From 5ac52239465ee4395cada775c13eb71a16d0794d Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:40:58 +0200 Subject: [PATCH 016/376] PPTX import: a transition without spd is 0.5 s, not the effect's default (#117) PowerPoint 2021/2024 write a 0.5 s transition with neither spd nor p14:dur, since "fast" is the schema default for spd. The reader fell back to the effect's own default instead (Morph 2 s, Fade 0.7 s, most 1 s). generated with Claude Code --- crates/pptx/src/read/timing.rs | 4 +- crates/pptx/tests/transition_xml.rs | 72 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 crates/pptx/tests/transition_xml.rs diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 500bb485..def0b0eb 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -33,11 +33,11 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option { }, } let default_dur = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == tr.kind).map(|x| x.3).unwrap_or(1000); + // `spd` defaults to "fast" in the schema; PowerPoint omits both attributes for 0.5 s. tr.duration_ms = ms_attr(t, "dur").unwrap_or(match t.attr("spd") { - Some("fast") => 500, Some("med") => 750, Some("slow") => default_dur.max(1000), - _ => default_dur, + _ => 500, }); if let Some(snd) = t.path(&["sndAc", "stSnd", "snd"]) { tr.sound = dml::media_ref(imp, part, snd); diff --git a/crates/pptx/tests/transition_xml.rs b/crates/pptx/tests/transition_xml.rs new file mode 100644 index 00000000..ffa5bcf4 --- /dev/null +++ b/crates/pptx/tests/transition_xml.rs @@ -0,0 +1,72 @@ +//! Transition XML as other applications write it, spliced into an exported deck and read back. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::anim::Transition; +use deckcraft_model::defaults; + +const P159: &str = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"; +const P14: &str = "http://schemas.microsoft.com/office/powerpoint/2010/main"; + +/// Exports a one-slide deck, replaces the slide's `mc:AlternateContent` transition block with +/// `xml` and returns the imported transition of that slide. +fn import_with(xml: &str) -> Transition { + let mut p = defaults::new_presentation(None); + std::sync::Arc::make_mut(&mut p.slides[0]).transition = Some(Transition { kind: "fade".into(), duration_ms: 3000, ..Default::default() }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if f.name() == "ppt/slides/slide1.xml" { + let s = String::from_utf8(d).expect("utf8"); + let t = s.find(" String { + format!( + r#"{choice}{fallback}"# + ) +} + +/// PowerPoint 2021/2024 write a 0.5 s Morph with neither `spd` nor `p14:dur` (issue #117). +#[test] +fn powerpoint_half_second_morph_without_spd() { + let xml = mc(P159, "p159", r#""#, ""); + let t = import_with(&xml); + assert_eq!((t.kind.as_str(), t.duration_ms), ("morph", 500)); +} + +#[test] +fn missing_spd_is_fast_for_a_plain_transition() { + let t = import_with(""); + assert_eq!((t.kind.as_str(), t.duration_ms), ("fade", 500)); +} + +#[test] +fn spd_and_dur_still_apply() { + let t = import_with(r#""#); + assert_eq!(t.duration_ms, 750); + let xml = mc( + P159, + "p159", + &format!(r#""#), + r#""#, + ); + let t = import_with(&xml); + assert_eq!((t.kind.as_str(), t.duration_ms), ("morph", 1250)); +} From 44ddb579dad100c348119c4729e3c337182b633d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:51:58 +0200 Subject: [PATCH 017/376] Charts: a single-series chart without an explicit title shows the series name as its automatic title (#142) --- crates/pptx/src/read/chart.rs | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index a9a960a3..b93378bb 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -202,6 +202,10 @@ pub(crate) fn chart_from(root: &El) -> Option { c.series.push(ser); } } + // `autoTitleDeleted val="0"` without `c:title`: PowerPoint and LibreOffice draw the series name as the title of a single-series chart. + if ch.child("title").is_none() && ch.child("autoTitleDeleted").and_then(|a| a.bool("val")) == Some(false) && c.series.len() == 1 { + c.title = c.series.first().map(|s| s.name.clone()).filter(|n| !n.is_empty()); + } let first = *kinds.first()?; c.kind = if kinds.iter().any(|k| *k != first) { ChartType::Combo } else { first }; if c.kind != ChartType::Combo { @@ -225,3 +229,37 @@ pub(crate) fn chart_from(root: &El) -> Option { } Some(c) } + +#[cfg(test)] +mod tests { + use super::*; + + const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + fn chart(auto_deleted: &str, title: &str, series: usize) -> Chart { + let val = r#"1"#; + let ser: String = (0..series).map(|i| format!(r#"S{i}{val}"#)).collect(); + let plot = format!(r#"{ser}"#); + let xml = format!(r#"{title}{auto_deleted}{plot}"#); + read_chart_xml(xml.as_bytes()).unwrap() + } + + #[test] + fn single_series_gets_automatic_title() { + assert_eq!(chart(r#""#, "", 1).title.as_deref(), Some("S0")); + } + + #[test] + fn no_automatic_title_when_deleted_or_multi_series() { + assert_eq!(chart(r#""#, "", 1).title, None); + assert_eq!(chart("", "", 1).title, None); + assert_eq!(chart(r#""#, "", 2).title, None); + } + + #[test] + fn explicit_title_wins() { + let t = "Mine"; + assert_eq!(chart(r#""#, t, 1).title.as_deref(), Some("Mine")); + } +} From 1c8992fdbe2680e64edc90571771e22346a73d0c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:52:32 +0200 Subject: [PATCH 018/376] Charts: a missing varyColors varies single-series bar colours by point (#143) --- crates/pptx/src/read/chart.rs | 29 ++++++++++++++++++++++++++--- crates/render/src/chart.rs | 14 +++++++++++++- crates/render/src/tests.rs | 17 +++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index a9a960a3..e8239ce2 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -150,9 +150,8 @@ pub(crate) fn chart_from(root: &El) -> Option { "surfaceChart" | "surface3DChart" => ChartType::Surface, _ => continue, }; - if let Some(v) = g.child_val("varyColors") { - c.vary_colors = v == "1" || v == "true"; - } + // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. + c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { c.gap_width = (v / 100.0).clamp(0.0, 5.0); } @@ -225,3 +224,27 @@ pub(crate) fn chart_from(root: &El) -> Option { } Some(c) } + +#[cfg(test)] +mod tests { + use super::read_chart_xml; + + const NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + fn xml(vary: &str) -> String { + let ser = r#""#; + let bar = format!(r#"{vary}{ser}"#); + format!(r#"{bar}"#) + } + + #[test] + fn missing_vary_colors_means_vary_by_point() { + assert!(read_chart_xml(xml("").as_bytes()).is_some_and(|c| c.vary_colors)); + } + + #[test] + fn explicit_vary_colors_is_respected() { + assert!(read_chart_xml(xml(r#""#).as_bytes()).is_some_and(|c| !c.vary_colors)); + assert!(read_chart_xml(xml(r#""#).as_bytes()).is_some_and(|c| c.vary_colors)); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index a29b2023..ced1f77a 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -36,6 +36,18 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart } } +/// Fill of one bar: a `` fill wins, and a single-series chart that varies colours by point uses one accent per point +/// (not waterfalls, which are never read from a ``). +fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba { + if let Some((_, Fill::Solid { color: cc })) = s.point_fills.iter().find(|(k, _)| *k == pi) { + return ctx.color(cc, None); + } + if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall { + return series_color(ctx, c, pi); + } + series_fill(ctx, c, si, Some(s)) +} + fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); @@ -291,7 +303,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h Rect::new(plot.x0 + start, to_v(b), plot.x0 + start + bar, to_v(a)) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0)); + ctx.set_paint(color(bar_fill(rctx, c, si, i, s), 1.0)); ctx.fill_rect(&r.abs()); if c.data_labels { let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0)); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472f..7e60e75d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -523,3 +523,20 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() })); assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0); } + +#[test] +fn single_series_column_chart_varies_colours_by_point() { + let render = |vary: bool| { + let mut c = Chart::sample(ChartType::Column); + c.series.truncate(1); + c.vary_colors = vary; + let sh = Shape { + id: ShapeId(800), + xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 300.0)), + kind: ShapeKind::Chart(Box::new(c)), + ..Default::default() + }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }).pixels + }; + assert_ne!(render(true), render(false)); +} From e999424c797214a0d5abdfcc08c3ee417bf8f110 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 16:57:53 +0200 Subject: [PATCH 019/376] Issue #106: zooming keeps the point under the pointer Ctrl/Cmd + wheel and pinch changed the zoom but left the scroll offset alone, so the slide grew from the canvas corner (from its top-left once it was larger than the window) and the spot under the pointer slid away. The pointer position was read but never used. Zoom In/Out, the status-bar slider and the Zoom dialog had the same effect. The canvas now remembers last frame's slide rect and scale. When the scale changes it sets the offset so the slide point under the anchor stays put: the pointer for wheel and pinch zoom, the middle of the canvas for the commands. Once the slide fits the window it is centred as before. Wheel and pinch zoom take effect in the same frame instead of the next. Fixes #106 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 128 ++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d267f76c..f77738ca 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -62,19 +62,31 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } return; } - let (content_rect, scale) = layout(app, avail); // Scroll when zoomed beyond the window. let scroll_id = ui.id().with("canvas_scroll"); + let last_id = scroll_id.with("last"); let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default()); - let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); + let last: Option<(Rect, f32)> = ui.data_mut(|d| d.get_temp(last_id)); let resp = ui.allocate_rect(avail, Sense::click_and_drag()); - let (wheel, zoom_delta, pinch_pos) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); - if resp.hovered() { - if (zoom_delta - 1.0).abs() > 1e-3 { - let cur = app.ui.zoom.unwrap_or(scale); - app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0)); - let _ = pinch_pos; - } else if overflow != egui::Vec2::ZERO { + let (wheel, zoom_delta, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); + let zooming = resp.hovered() && (zoom_delta - 1.0).abs() > 1e-3; + if zooming { + let cur = app.ui.zoom.unwrap_or_else(|| layout(app, avail).1); + app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0)); + } + let (content_rect, scale) = layout(app, avail); + // A new zoom keeps the slide point under the pointer (wheel, pinch) or under the middle of the + // canvas (Zoom In/Out, the slider) where it was, instead of growing from the corner (#106). + if let Some((last_rect, last_scale)) = last + && (scale - last_scale).abs() > 1e-6 + { + let anchor = if zooming { hover.unwrap_or(avail.center()) } else { avail.center() }; + let at = (anchor - last_rect.min) / last_scale; + offset = content_rect.min + at * scale - anchor; + } + let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); + if resp.hovered() && !zooming { + if overflow != egui::Vec2::ZERO { offset -= wheel; } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { // Wheel over a fitted slide pages through slides (like PowerPoint). @@ -90,8 +102,11 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); - ui.data_mut(|d| d.insert_temp(scroll_id, offset)); let slide_rect = content_rect.translate(-offset); + ui.data_mut(|d| { + d.insert_temp(scroll_id, offset); + d.insert_temp(last_id, (slide_rect, scale)); + }); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; let xf = Xf { origin: slide_rect.min, scale }; @@ -608,3 +623,96 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_engine::Session; + use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; + use egui_kittest::Harness; + + use crate::{Services, SlideApp}; + + fn harness() -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + h + } + + /// The slide point (in slide points) under screen position `p`. + fn slide_point(h: &Harness<'_, SlideApp>, p: Pos2) -> Pos2 { + let (r, s) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s) + } + + fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) { + let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }; + h.event(wheel); + // egui spreads a wheel notch over several frames. + h.run_steps(30); + } + + fn assert_near(a: Pos2, b: Pos2, what: &str) { + assert!((a - b).length() < 0.5, "{what}: {a:?} moved to {b:?}"); + } + + /// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. + #[test] + fn wheel_and_pinch_zoom_keep_the_point_under_the_pointer() { + let mut h = harness(); + let fit = h.state().canvas_scale; + let r = h.state().slide_rect.unwrap(); + let p = r.min + vec2(r.width() * 0.8, r.height() * 0.7); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let under = slide_point(&h, p); + + h.event(Event::Zoom(2.0)); + h.run_steps(2); + assert!((h.state().canvas_scale - fit * 2.0).abs() < 1e-3); + assert_near(under, slide_point(&h, p), "pinch in"); + + ctrl_wheel(&mut h, 1.0); + assert!(h.state().canvas_scale > fit * 2.1, "Ctrl/Cmd + wheel up zooms in"); + assert_near(under, slide_point(&h, p), "wheel in"); + + ctrl_wheel(&mut h, -1.0); + ctrl_wheel(&mut h, -1.0); + assert!(h.state().canvas_scale < fit * 2.0, "Ctrl/Cmd + wheel down zooms out"); + assert_near(under, slide_point(&h, p), "wheel out"); + + h.event(Event::Zoom(0.8)); + h.run_steps(2); + assert_near(under, slide_point(&h, p), "pinch out"); + + // Zoomed out below the window size, the slide is centred again. + h.event(Event::Zoom(0.25)); + h.run_steps(2); + assert!(h.state().canvas_scale < fit); + let (canvas, slide) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap()); + assert!((canvas.center() - slide.center()).length() < 1.0, "{canvas:?} {slide:?}"); + } + + /// Zoom In/Out (keys, buttons, slider) keeps the middle of the view where it is. + #[test] + fn zoom_commands_keep_the_middle_of_the_view() { + let mut h = harness(); + let c = h.state().canvas_rect.unwrap().center(); + let mid = slide_point(&h, c); + for _ in 0..4 { + h.state_mut().run("view.zoomIn", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom in"); + } + h.state_mut().run("view.zoomOut", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom out"); + } +} From 6a91164a5b01e5d92669df4d37e36f61adcf53c1 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 17:05:17 +0200 Subject: [PATCH 020/376] Issue #144: automatic value axes scale like Office The value axis used a generic "nice" scale: about five intervals and no headroom, so data with a maximum of 42.1 got 0-50 in steps of 10, while PowerPoint and LibreOffice draw 0-45 in steps of 5. `auto_scale` now follows the rule Office documents for automatic limits: the maximum is the first major unit at or above max + (max - min)/20, the minimum is zero when all values are positive and the smallest is below 5/6 of the largest (otherwise the first major unit at or below min - the same headroom), and all-negative data mirrors that. The major unit is the smallest 1, 2 or 5 x 10^n that gives at most 10 intervals. Fixed ``/`` limits are kept as they are and no longer get rounded or padded; values beyond them stop at the plot edge. 100% charts get 0-1 in steps of 0.1. Fixes #144 Co-Authored-By: Claude Opus 5.5 --- crates/model/src/chart.rs | 184 ++++++++++++++++++++++++++++--------- crates/render/src/chart.rs | 11 ++- 2 files changed, 147 insertions(+), 48 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 5ba72fc4..0ce14b41 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -210,60 +210,99 @@ impl Chart { c.vary_colors = n_series == 1; c } - /// Value range across series (stacked sums for stacked types). + /// The value axis scale `(min, max, major unit)` the chart is drawn with: the chart's fixed + /// limits, otherwise Office's automatic scale ([`auto_scale`]). + pub fn axis_scale(&self) -> (f64, f64, f64) { + if self.kind.percent() { + return auto_scale(0.0, 1.0, Some(0.0), Some(1.0)); + } + let (lo, hi) = self.value_range(); + auto_scale(lo, hi, self.y_min, self.y_max) + } + + /// Range of the plotted values (stacked sums for stacked types, which start at zero); + /// `(0, 0)` without values. pub fn value_range(&self) -> (f64, f64) { - let mut lo: f64 = 0.0; - let mut hi: f64 = 0.0; let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); if self.kind.stacked() { + let (mut lo, mut hi) = (0.0f64, 0.0f64); for i in 0..n { let (mut p, mut m) = (0.0, 0.0); for s in &self.series { - let v = s.values.get(i).copied().flatten().unwrap_or(0.0); + let v = s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0); if v >= 0.0 { p += v } else { m += v } } hi = hi.max(p); lo = lo.min(m); } - } else { - for s in &self.series { - for v in s.values.iter().flatten() { - if v.is_finite() { - hi = hi.max(*v); - lo = lo.min(*v); - } - } - } + return (lo, hi); } - if self.kind.percent() { - return (0.0, 1.0); - } - (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) + let mut values = self.series.iter().flat_map(|s| s.values.iter().flatten()).filter(|v| v.is_finite()); + let Some(first) = values.next() else { return (0.0, 0.0) }; + values.fold((*first, *first), |(lo, hi), v| (lo.min(*v), hi.max(*v))) } } -/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. -pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { - let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; - let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; - let raw = span / 5.0; - let mag = 10f64.powf(raw.log10().floor()); - let norm = raw / mag; - let step = if norm <= 1.0 { - 1.0 - } else if norm <= 2.0 { - 2.0 - } else if norm <= 2.5 { - 2.5 - } else if norm <= 5.0 { - 5.0 - } else { - 10.0 - } * mag; - let min = (lo / step).floor() * step; - let max = (hi / step).ceil() * step; - let max = if (max - min).abs() < 1e-12 { min + step } else { max }; - (min, max, step) +/// Office's automatic value-axis scale `(min, max, major unit)` for values in `[lo, hi]`, keeping +/// the limits a chart fixes (``, ``). The rule Office documents for automatic limits: +/// +/// - **Minimum:** zero when every value is ≥ 0 and the smallest is below 5/6 of the largest; +/// otherwise the first major unit at or below `lo − (hi − lo)/20`. +/// - **Maximum:** the first major unit at or above `hi + (hi − lo)/20`. +/// - All-negative values mirror this: the axis ends at zero when the largest is above 5/6 of the +/// smallest. +/// +/// The major unit is the smallest 1, 2 or 5 × 10ⁿ that gives at most 10 major intervals (#144). +pub fn auto_scale(lo: f64, hi: f64, fixed_min: Option, fixed_max: Option) -> (f64, f64, f64) { + let fixed_min = fixed_min.filter(|v| v.is_finite()); + let fixed_max = fixed_max.filter(|v| v.is_finite()); + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 0.0) }; + let (lo, hi) = (fixed_min.unwrap_or(lo), fixed_max.unwrap_or(hi)); + let pad = (hi - lo).abs() / 20.0; + let flat = (hi - lo).abs() < 1e-12; + // Where the axis has to reach, before rounding out to the major unit. + let low = match fixed_min { + Some(m) => m, + None if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) => 0.0, + None => lo - pad, + }; + let mut high = match fixed_max { + Some(m) => m, + None if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) => 0.0, + None => hi + pad, + }; + if high - low < 1e-12 { + high = low + low.abs().max(1.0); + } + // Smallest 1, 2 or 5 × 10ⁿ at or above a tenth of the span. + let raw = (high - low) / 10.0; + let mut exp = raw.log10().floor().clamp(-400.0, 400.0) as i32; + let mut mantissa = [1.0, 2.0, 5.0, 10.0].into_iter().find(|m| m * 10f64.powi(exp) >= raw * (1.0 - 1e-9)).unwrap_or(10.0); + let mut out = (low, high, raw); + // A rounded-out axis can need one more interval than the span; then the next unit fits. + for _ in 0..4 { + if mantissa >= 10.0 { + mantissa = 1.0; + exp = exp.saturating_add(1); + } + let step = if exp >= 0 { mantissa * 10f64.powi(exp) } else { mantissa / 10f64.powi(-exp) }; + let min = fixed_min.unwrap_or_else(|| (low / step + 1e-9).floor() * step); + let max = fixed_max.unwrap_or_else(|| (high / step - 1e-9).ceil() * step); + let max = if max - min < 1e-12 { min + step } else { max }; + out = (min, max, step); + if (max - min) / step <= 10.0 + 1e-9 { + break; + } + mantissa = if mantissa < 2.0 { + 2.0 + } else if mantissa < 5.0 { + 5.0 + } else { + 10.0 + }; + } + // Spans too large or too small for f64 steps. + if [out.0, out.1, out.2].iter().all(|v| v.is_finite()) && out.2 > 0.0 && out.1 > out.0 { out } else { (0.0, 1.0, 0.1) } } #[cfg(test)] @@ -271,13 +310,70 @@ mod tests { use super::*; #[test] - fn nice_scale_covers() { - let (a, b, s) = nice_scale(0.0, 5.1); + fn auto_scale_covers() { + let (a, b, s) = auto_scale(0.0, 5.1, None, None); assert!(a <= 0.0 && b >= 5.1 && s > 0.0); - let (a, b, _) = nice_scale(f64::NAN, 3.0); - assert!(a.is_finite() && b.is_finite()); - let (a, b, _) = nice_scale(7.0, 7.0); + let (a, b, _) = auto_scale(f64::NAN, 3.0, None, None); + assert!(a.is_finite() && b.is_finite() && b > a); + let (a, b, _) = auto_scale(7.0, 7.0, None, None); assert!(b > a); + for (lo, hi, min, max) in + [(0.0, 1.0, Some(5.0), Some(5.0)), (-f64::MAX, f64::MAX, None, None), (0.0, 1e-320, None, None), (0.0, 1.0, Some(5.0), Some(1.0))] + { + let (a, b, s) = auto_scale(lo, hi, min, max); + assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{lo} {hi}: {a} {b} {s}"); + } + } + + fn column(values: &[f64]) -> Chart { + let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; + Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } + } + + /// #144: Office scales 42.1 / 18.7 / 6.3 as 0–45 in steps of 5, not 0–50 in steps of 10. + #[test] + fn automatic_axis_matches_office() { + assert_eq!(column(&[42.1, 18.7, 6.3]).axis_scale(), (0.0, 45.0, 5.0)); + // PowerPoint's default chart data (max 5): 0–6 in steps of 1. + let mut c = column(&[4.3, 2.5, 3.5, 4.5]); + c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); + c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); + assert_eq!(c.axis_scale(), (0.0, 6.0, 1.0)); + // A maximum of 100 (or 10) gets 5% headroom, which rounds up to the next major unit. + assert_eq!(column(&[100.0, 40.0]).axis_scale(), (0.0, 120.0, 20.0)); + assert_eq!(column(&[10.0, 3.0]).axis_scale(), (0.0, 12.0, 2.0)); + // Headroom 2.95 + 2.95/20 = 3.1 rounds up to 3.5. + assert_eq!(column(&[2.95, 0.0]).axis_scale(), (0.0, 3.5, 0.5)); + } + + #[test] + fn automatic_axis_minimum() { + // Below 5/6 of the maximum, the axis starts at zero; at or above it, near the data. + assert_eq!(column(&[59.0, 49.0]).axis_scale().0, 0.0); + let (min, max, step) = column(&[59.0, 50.0]).axis_scale(); + assert!(min > 0.0 && min <= 50.0 - 0.45 && max >= 59.45 && (max - min) / step <= 10.0, "{min} {max} {step}"); + // Negative data mirrors it: the axis ends at zero, and has headroom below. + assert_eq!(column(&[-42.1, -18.7, -6.3]).axis_scale(), (-45.0, 0.0, 5.0)); + // Mixed signs get headroom at both ends. + assert_eq!(column(&[42.1, -18.7]).axis_scale(), (-30.0, 50.0, 10.0)); + } + + #[test] + fn fixed_axis_limits_are_kept() { + let mut c = column(&[42.1, 18.7, 6.3]); + c.y_max = Some(50.0); + assert_eq!(c.axis_scale(), (0.0, 50.0, 5.0)); + c.y_min = Some(10.0); + c.y_max = None; + assert_eq!(c.axis_scale(), (10.0, 45.0, 5.0)); + let mut p = column(&[42.1, 18.7, 6.3]); + p.kind = ChartType::PercentColumn; + assert_eq!(p.axis_scale(), (0.0, 1.0, 0.1)); + // Degenerate data still gives an axis. + for (a, b) in [(0.0, 0.0), (7.0, 7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0)] { + let (min, max, step) = column(&[a, b]).axis_scale(); + assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); + } } #[test] diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index a29b2023..0921771b 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -2,7 +2,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; -use deckcraft_model::chart::{Chart, ChartType, nice_scale}; +use deckcraft_model::chart::{Chart, ChartType}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; @@ -162,8 +162,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } return; } - let (lo, hi) = c.value_range(); - let (min, max, step) = nice_scale(lo.min(0.0), hi); + let (min, max, step) = c.axis_scale(); let horiz = c.kind.horizontal(); // Axis label gutter. let gutter = base * 3.0; @@ -176,7 +175,11 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); } let span = (max - min).max(1e-9); - let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }; + // Values outside fixed axis limits stop at the plot's edge. + let to_v = |v: f64| { + let v = v.clamp(min, max); + if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() } + }; // Gridlines and value labels. let n_steps = ((span / step).round() as i64).clamp(1, 50); for k in 0..=n_steps { From a3fae2c03132c9ecfa662584db4eb6f5fc475a8f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:10:57 +0200 Subject: [PATCH 021/376] Ribbon: transition and animation tiles follow the dark theme (#159) --- crates/ui-egui/src/ribbon.rs | 38 ++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c7c251ef..cf91a1f3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1431,7 +1431,7 @@ fn option_label(o: &str) -> String { } pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { - let t = Tokens::light(); + let t = Tokens::get(p.ctx()); let pic = Rect::from_min_size(r.min + vec2(4.0, 2.0), vec2(r.width() - 8.0, r.height() - 18.0)); let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); let light = Color32::from_rgb(0xBF, 0xD4, 0xF5); @@ -1676,7 +1676,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { } pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass, enabled: bool) { - let t = Tokens::light(); + let t = Tokens::get(p.ctx()); let icon = match class { AnimClass::Entrance => Icon::AnimEntrance, AnimClass::Emphasis => Icon::AnimEmphasis, @@ -2405,3 +2405,37 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Paints one transition tile and one animation tile and returns the colour of every label. + fn tile_label_colours(dark: bool) -> Vec { + let ctx = egui::Context::default(); + ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() }); + let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(80.0, 60.0)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint_transition_tile(ui.painter(), r, ("fade", "Fade")); + paint_anim_tile(ui.painter(), r, "Appear", AnimClass::Entrance, true); + }); + out.textures_delta.clear(); + out.shapes + .iter() + .flat_map(|c| match &c.shape { + egui::Shape::Text(s) => s.galley.job.sections.iter().map(|x| x.format.color).collect(), + _ => Vec::new(), + }) + .collect() + } + + #[test] + fn ribbon_tile_labels_follow_the_theme() { + // #159: the transition and animation tiles were always painted with the light palette, + // so their labels were dark grey on the dark ribbon. + let dark = tile_label_colours(true); + assert_eq!(dark, vec![Tokens::dark().text; 2]); + let light = tile_label_colours(false); + assert_eq!(light, vec![Tokens::light().text; 2]); + } +} From 1ce5f686e80cb496751d6d047adef4fd70e806c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=BA=C5=A1=20Laco?= Date: Sun, 11 Oct 2026 16:51:21 +0200 Subject: [PATCH 022/376] Notes: speaker notes show their formatting and keep it when edited Notes from a .pptx were read and written with their formatting, but the notes pane and Notes Page drew them as plain text (Slide::notes_text in a plain TextEdit), and any edit there saved the text back through slide.notes as TextBody::from_text, dropping every bold run, level, indent and bullet of the slide's notes. - ui-egui: a notes editor (notes.rs) shared by the notes pane and Notes Page. It draws bold, italic, underline, strikethrough and size from the runs, and each paragraph's margin, hanging indent and bullet or number (Wingdings/Symbol bullets mapped as on slides); paragraphs are laid out one by one so wrapped lines align with the text, not the bullet. Each edit is applied to a formatted draft, so typed text takes the formatting where it lands (Enter in a bulleted point starts another), and the draft is saved once the field loses focus or the slide changes. - engine: slide.notes takes `body` (formatted notes, as slide.inspect's new `notesBody`), and `text` now changes only what was retyped (model edit::replace_text); text sharing nothing with the old notes is still plain. - text: map_symbol_bullet is public for the notes editor. PDF notes pages still print the notes as plain text. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/inspect.rs | 3 +- crates/engine/src/cmd/slide.rs | 22 ++- crates/engine/src/tests.rs | 43 ++++ crates/model/src/edit.rs | 87 ++++++++ crates/pptx/tests/notes.rs | 82 ++++++++ crates/text/src/lib.rs | 3 +- crates/ui-egui/src/lib.rs | 91 ++++++++- crates/ui-egui/src/notes.rs | 327 +++++++++++++++++++++++++++++++ crates/ui-egui/src/sorter.rs | 33 ++-- crates/ui-egui/src/status.rs | 48 ++--- docs/file-format-parity.md | 5 +- 11 files changed, 698 insertions(+), 46 deletions(-) create mode 100644 crates/pptx/tests/notes.rs create mode 100644 crates/ui-egui/src/notes.rs diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index db162a38..62f9abd1 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -10,7 +10,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document), - cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide), + cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide), cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape), cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands), ] @@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result { "transition": serde_json::to_value(&sl.transition).unwrap_or_default(), "animations": serde_json::to_value(&sl.animations).unwrap_or_default(), "notes": sl.notes_text(), + "notesBody": sl.notes, "comments": sl.comments.len(), "hidden": sl.hidden, })) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..24dcda2e 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -32,7 +32,15 @@ pub fn specs() -> Vec { cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide), cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout), cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset), - cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes), + cmd!( + "slide.notes", + "Notes", + ["View"], + None, + "{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)", + has_slide, + notes + ), cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename), cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add), cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename), @@ -463,10 +471,20 @@ fn reset(s: &mut Session, p: &Value) -> Result { fn notes(s: &mut Session, p: &Value) -> Result { let i = index_of(s, p)?; + // Formatted notes as a whole (the notes editors), or text: then only what was retyped + // changes, so bold, levels and bullets around it survive. + let body = match p.get("body") { + Some(b) => Some(serde_json::from_value::(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?), + None => None, + }; let text = str_param(p, "text").unwrap_or("").to_string(); s.edit(|doc, _| { if let Some(sl) = doc.slides.get_mut(i) { - Arc::make_mut(sl).notes = TextBody::from_text(&text); + let notes = &mut Arc::make_mut(sl).notes; + match body { + Some(b) => *notes = b, + None => deckcraft_model::edit::replace_text(notes, &text), + } } Ok(()) })?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b7..dc879641 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -543,3 +543,46 @@ fn inserted_hyperlinks_look_like_links() { s.execute("format.underline", &json!({})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); } + +#[test] +fn editing_notes_keeps_their_formatting_through_pptx() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + // A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint. + let mut p = deckcraft_model::defaults::new_presentation(None); + let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() }; + let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() }, + point("First point"), + point("Second point"), + ], + ..Default::default() + }; + let bytes = deckcraft_pptx::export(&p).unwrap(); + let mut s = session(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap(); + // Type a word into the second bullet, as the notes pane does. + s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap(); + let check = |notes: &TextBody| { + assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point"); + assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true)); + for para in ¬es.paragraphs[1..] { + assert_eq!(para.level, 1); + assert_eq!(para.props.margin_left, Some(22.5)); + assert_eq!(para.props.indent, Some(-22.5)); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() })); + } + }; + check(&s.doc().unwrap().doc.slides[0].notes); + let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap(); + check(&reopened.slides[0].notes); + // slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are. + let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone(); + s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain"); + s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap(); + check(&s.doc().unwrap().doc.slides[0].notes); + assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err()); +} diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index dcdc5089..644fdc4b 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -355,6 +355,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { } } +/// The position of the `n`th character of the body's plain text (`\n` between paragraphs). +fn pos_of_char(body: &TextBody, mut n: usize) -> Pos { + for (pi, p) in body.paragraphs.iter().enumerate() { + let len = p.char_len(); + if n <= len { + return (pi, n); + } + n -= len + 1; + } + end(body) +} + +/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in +/// place: only the span between the longest common prefix and suffix changes, so untouched text +/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text +/// that shares nothing with the old text replaces it as plain text. +pub fn replace_text(body: &mut TextBody, text: &str) { + let old: Vec = body.text().chars().collect(); + let new: Vec = text.chars().collect(); + if old == new { + return; + } + let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let room = old.len().min(new.len()) - pre; + let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count(); + if pre == 0 && suf == 0 { + *body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) }; + return; + } + let a = pos_of_char(body, pre); + let b = pos_of_char(body, old.len() - suf); + let at = delete(body, a, b); + let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect(); + if !mid.is_empty() { + insert(body, at, &mid, None); + } +} + /// End of the body. pub fn end(body: &TextBody) -> Pos { let pi = body.paragraphs.len().saturating_sub(1); @@ -472,4 +510,53 @@ mod tests { assert_eq!(b.paragraphs[0].char_len(), 3); assert_eq!(b.paragraphs.len(), 1); } + + /// Speaker notes: a bold heading over indented bullet points. + fn notes() -> TextBody { + use crate::text::{Bullet, ParaProps}; + let mut b = body("Overview\nFirst point\nSecond point"); + format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true)); + for p in b.paragraphs.iter_mut().skip(1) { + p.level = 1; + p.props = + ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() }; + } + b + } + + #[test] + fn replace_text_keeps_formatting() { + let before = notes(); + // Same text: nothing changes. + let mut b = before.clone(); + replace_text(&mut b, "Overview\nFirst point\nSecond point"); + assert_eq!(b, before); + // A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet. + replace_text(&mut b, "Overview\nFirst main point\nSecond point"); + assert_eq!(b.text(), "Overview\nFirst main point\nSecond point"); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props); + assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1)); + // Typing at the end of the heading continues it in bold. + replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point"); + assert_eq!(b.paragraphs[0].runs.len(), 1); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + // Enter after a bullet starts another bullet; deleting one joins nothing else. + replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point"); + assert_eq!(b.paragraphs.len(), 4); + assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props)); + replace_text(&mut b, "Overview (5 min)\nSecond point"); + assert_eq!(b.text(), "Overview (5 min)\nSecond point"); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(b.paragraphs[1].level, 1); + // Text with nothing in common is new plain text. + replace_text(&mut b, "Plain"); + assert_eq!(b, body("Plain")); + replace_text(&mut b, ""); + assert!(b.is_empty()); + // Into an empty body. + let mut e = TextBody::default(); + replace_text(&mut e, "a\nb"); + assert_eq!(e.text(), "a\nb"); + } } diff --git a/crates/pptx/tests/notes.rs b/crates/pptx/tests/notes.rs new file mode 100644 index 00000000..b5863d51 --- /dev/null +++ b/crates/pptx/tests/notes.rs @@ -0,0 +1,82 @@ +//! Speaker notes keep their run and paragraph formatting: a bold heading, indented points with +//! custom bullets, plain lines. + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::defaults; +use deckcraft_model::text::{Bullet, ParaProps, TextBody}; + +const NOTES: &str = "ppt/notesSlides/notesSlide1.xml"; + +/// A notes slide written the way PowerPoint writes one (Slovak text): a bold heading, a plain +/// line, two level-2 points with a hanging indent and a Wingdings square bullet (one mixing a bold +/// label and plain text), a level-2 paragraph with buNone, and a closing plain line. +const NOTES_XML: &str = r#" +Metodický prehľadŽiaci pracujú vo dvojiciach.Cieľ: vysvetliť cyklusReflexiaBez odrážkyPomôcky: počítač"#; + +/// A deck with one notes slide, its notes replaced by [`NOTES_XML`]. +fn deck() -> Vec { + let mut p = defaults::new_presentation(None); + Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder"); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut found = false; + for i in 0..src.len() { + let mut f = src.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + if name == NOTES { + data = NOTES_XML.as_bytes().to_vec(); + found = true; + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + assert!(found, "the export has no {NOTES}"); + out.finish().expect("finish").into_inner() +} + +fn assert_formatted(notes: &TextBody) { + assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač"); + let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") }; + assert_eq!(head.runs[0].props.bold, Some(true), "bold heading"); + for para in [head, plain, closing] { + assert_eq!((para.level, ¶.props), (0, &ParaProps::default()), "{:?}", para.text()); + } + assert_eq!(plain.runs[0].props.bold, None); + for para in [goal, reflect] { + assert_eq!(para.level, 1, "level 2"); + assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU"); + assert_eq!(para.props.indent, Some(-22.5), "hanging indent"); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() })); + assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings")); + } + assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label"); + assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain"); + assert_eq!(reflect.runs[0].props.italic, Some(true)); + assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5))); +} + +#[test] +fn notes_import_keeps_bold_levels_and_indents() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + assert_formatted(&p.slides[0].notes); +} + +#[test] +fn notes_formatting_survives_a_round_trip() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let again = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport"); + assert_formatted(&again.slides[0].notes); + // And the written XML says so, for PowerPoint. + let bytes = deckcraft_pptx::export(&again).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + z.by_name(NOTES).expect("notes part").read_to_string(&mut xml).expect("utf-8"); + assert!(xml.contains(r#"b="1""#), "{xml}"); + assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}"); + assert!(xml.contains(r#""), "{xml}"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 225f4afb..07f4f3ec 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,8 @@ fn shape_item( } } -fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { +/// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character. +pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase(); let first = c.chars().next().unwrap_or('•'); if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..f4292eb0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod fillui; pub mod icons; pub mod media; pub mod menus; +pub mod notes; pub mod panes; pub mod ribbon; pub mod show; @@ -206,7 +207,7 @@ pub struct SlideApp { pub last_recovery: f64, /// Quit was confirmed (changes saved or discarded): let the window close. pub quit_confirmed: bool, - pub(crate) notes_buf: (u64, usize, String), + pub(crate) notes_buf: (u64, usize, notes::Draft), /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, } @@ -242,7 +243,7 @@ impl SlideApp { last_time: 0.0, last_recovery: 0.0, quit_confirmed: false, - notes_buf: (0, usize::MAX, String::new()), + notes_buf: (0, usize::MAX, notes::Draft::default()), thumb_drag: None, } } @@ -938,4 +939,90 @@ mod tests { app.run("app.home", json!({})).unwrap(); assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway"); } + + /// Speaker notes keep their formatting when edited in the notes pane: typing into a bold + /// heading, Enter in a bulleted point, then saving as .pptx and reopening. + #[test] + fn editing_notes_in_the_pane_keeps_their_formatting() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + let point = |runs: Vec| Paragraph { + level: 1, + props: ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(Bullet::Char { char: "§".into() }), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }, + runs, + ..Default::default() + }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + let notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]), + point(vec![Run::new("Reflexia")]), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + }; + let mut session = Session::with_new(); + session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + harness.run_steps(2); + let id = egui::Id::new("notes_edit"); + let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { + let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); + egui::TextEdit::store_state(&h.ctx, id, st); + }; + let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap(); + let text = notes.text(); + harness.ctx.memory_mut(|m| m.request_focus(id)); + harness.run_steps(1); + // Type into the bold heading. + caret(&mut harness, chars(&text, "prehľad")); + harness.run_steps(1); + harness.event(egui::Event::Text(" hodiny".into())); + harness.run_steps(1); + // Enter after a bulleted point, and type the next one. + let text = text.replacen("prehľad", "prehľad hodiny", 1); + caret(&mut harness, chars(&text, "cyklus")); + harness.run_steps(1); + harness.key_press(egui::Key::Enter); + harness.event(egui::Event::Text("Nový bod".into())); + harness.run_steps(1); + // Leave the field: the notes are saved. + harness.ctx.memory_mut(|m| m.surrender_focus(id)); + harness.run_steps(2); + let check = |n: &TextBody| { + assert_eq!( + n.text(), + "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" + ); + assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); + assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(n.paragraphs[1].props, ParaProps::default()); + assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label"); + assert_eq!(n.paragraphs[2].runs[1].props.bold, None); + for i in [2, 3, 4] { + assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}"); + } + assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default())); + }; + check(&harness.state().session.active().unwrap().doc.slides[0].notes); + // Saved as .pptx and reopened. + let s = &mut harness.state_mut().session; + let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap(); + check(&s.active().unwrap().doc.slides[0].notes); + } } diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs new file mode 100644 index 00000000..20533735 --- /dev/null +++ b/crates/ui-egui/src/notes.rs @@ -0,0 +1,327 @@ +//! Speaker notes editing (the notes pane and Notes Page view). The notes are edited as text and +//! drawn with their formatting: bold, italic, underline, strikethrough and size from the runs, +//! indents (wrapped lines included) and bullets or numbers from the paragraphs. Each edit is +//! applied to a formatted draft, so typed text takes the formatting where it lands and the rest +//! keeps its own; the draft is saved with `slide.notes {body}` once the editor loses focus. + +use std::sync::Arc; + +use deckcraft_model::edit; +use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody}; +use egui::text::{CCursor, Galley, LayoutJob, TextFormat}; +use egui::{Align2, Color32, Stroke, Ui, vec2}; + +use crate::theme; + +/// Size and colour of a notes editor's text. +pub struct Look { + /// Pixels for 12 pt text, the notes' default size; indents scale with it. + pub size: f32, + /// `None` = the widget text colour. + pub color: Option, +} + +/// Notes being edited: the text in the editor and the formatted notes it stands for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Draft { + pub text: String, + pub body: TextBody, + /// Edited since it was saved. + pub edited: bool, +} + +impl Draft { + pub fn new(body: &TextBody) -> Self { + Draft { text: body.text(), body: body.clone(), edited: false } + } +} + +/// The draft as it reads with `text` typed in (one edit since the draft): `None` when that can't +/// be mapped onto it. +fn typed(body: &TextBody, text: &str) -> Option { + let mut b = body.clone(); + edit::replace_text(&mut b, text); + (b.text() == text).then_some(b) +} + +/// A length in points from the file as pixels, bounded. +fn px(pt: f64, k: f32) -> f32 { + if pt.is_finite() { pt.clamp(0.0, 400.0) as f32 * k } else { 0.0 } +} + +/// Where a paragraph's first line starts and its other lines start (points): a level without its +/// own margin is indented 36 pt per level, as in the default notes master. +fn indents(p: &Paragraph) -> (f64, f64) { + let margin = p.props.margin_left.unwrap_or(36.0 * f64::from(p.level.min(8))); + let first = margin + p.props.indent.unwrap_or(0.0); + (first, margin) +} + +/// Each paragraph's bullet or number, numbered as on a slide (per level, restarting after an +/// unnumbered paragraph at the same or a lower level). +fn bullets(body: &TextBody) -> Vec> { + let mut numbering = [0u32; 9]; + body.paragraphs + .iter() + .map(|p| { + let lvl = usize::from(p.level.min(8)); + if p.is_empty() { + return None; + } + let auto = matches!(p.props.bullet, Some(Bullet::AutoNum { .. })); + for (i, n) in numbering.iter_mut().enumerate().skip(lvl) { + if i > lvl || !auto { + *n = 0; + } + } + match p.props.bullet.as_ref()? { + Bullet::None => None, + Bullet::Char { char } => Some(deckcraft_text::map_symbol_bullet(char, p.props.bullet_font.as_deref())), + Bullet::AutoNum { scheme, start_at } => { + let n = numbering.get_mut(lvl)?; + *n = n.saturating_add(1); + Some(deckcraft_text::autonum(scheme, start_at.saturating_sub(1).saturating_add(*n))) + } + Bullet::Picture { .. } => Some("•".into()), + } + }) + .collect() +} + +fn format(props: &RunProps, size: f32, color: Color32) -> TextFormat { + let size = match props.size { + Some(pt) if pt.is_finite() && pt > 0.0 => (size * pt as f32 / 12.0).clamp(size * 0.75, size * 2.0), + _ => size, + }; + let font = if props.bold == Some(true) { theme::bold(size) } else { theme::font(size) }; + let mut f = TextFormat::simple(font, color); + f.italics = props.italic == Some(true); + if props.underline.as_deref().is_some_and(|u| u != "none") { + f.underline = Stroke::new(1.0, color); + } + if matches!(props.strike, Some(Strike::Single | Strike::Double)) { + f.strikethrough = Stroke::new(1.0, color); + } + f +} + +/// One paragraph to lay out: wrapped lines start at the margin (pixels), the first line's text at +/// `text`, clear of a bullet. +struct ParaLayout { + job: LayoutJob, + margin: f32, + /// x of the first line's text. + text: f32, +} + +fn para_layout(p: &Paragraph, bullet: Option<&str>, size: f32, color: Color32, wrap: f32) -> ParaLayout { + let k = size / 12.0; + let (first, margin) = indents(p); + let (first, margin) = (px(first, k), px(margin, k)); + let text = match bullet { + // The text starts at the margin, after the bullet if it's wider than the hanging indent. + Some(b) => margin.max(first + size * (0.4 + 0.6 * b.chars().count() as f32)), + None => first, + }; + let mut job = LayoutJob::default(); + job.wrap.max_width = (wrap - margin).max(size * 4.0); + let mut lead = (text - margin).max(0.0); + for r in &p.runs { + let t = r.content(); + if !t.is_empty() { + job.append(t, lead, format(&r.props, size, color)); + lead = 0.0; + } + } + if job.text.is_empty() { + job.append("", 0.0, format(&p.end_props, size, color)); + } + ParaLayout { job, margin, text } +} + +/// The whole notes as one job (the galley's text and formats). +fn job(body: &TextBody, size: f32, color: Color32, wrap: f32) -> LayoutJob { + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap; + for (i, (p, bullet)) in body.paragraphs.iter().zip(bullets(body)).enumerate() { + if i > 0 { + job.append("\n", 0.0, TextFormat::simple(theme::font(size), color)); + } + let pl = para_layout(p, bullet.as_deref(), size, color, wrap); + for s in pl.job.sections { + if let Some(t) = pl.job.text.get(s.byte_range.start.0..s.byte_range.end.0) { + job.append(t, s.leading_space, s.format); + } + } + } + job +} + +/// Lay the notes out paragraph by paragraph, each moved to its indent, so wrapped lines line up +/// under the text and not under the bullet. +fn galley(ui: &Ui, body: &TextBody, size: f32, color: Color32, wrap: f32) -> Arc { + let whole = job(body, size, color, wrap); + if body.paragraphs.is_empty() { + return ui.fonts_mut(|f| f.layout_job(whole)); + } + let parts: Vec> = ui.fonts_mut(|f| { + body.paragraphs + .iter() + .zip(bullets(body)) + .map(|(p, bullet)| { + let pl = para_layout(p, bullet.as_deref(), size, color, wrap); + let mut g = (*f.layout_job(pl.job)).clone(); + for (i, row) in g.rows.iter_mut().enumerate() { + // The first line already holds the gap after a bullet; a first line hanging + // left of the margin without one starts there. + row.pos.x += if i == 0 { pl.margin.min(pl.text) } else { pl.margin }; + } + Arc::new(g) + }) + .collect() + }); + Arc::new(Galley::concat(Arc::new(whole), &parts, ui.ctx().pixels_per_point())) +} + +/// A multiline editor for the draft that draws its formatting; `configure` sets the id, hint, +/// size and frame. Edits update the draft's text and formatted body. +pub fn editor( + ui: &mut Ui, + draft: &mut Draft, + look: &Look, + configure: impl for<'a> FnOnce(egui::TextEdit<'a>) -> egui::TextEdit<'a>, +) -> egui::Response { + let color = look.color.unwrap_or_else(|| ui.visuals().override_text_color.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color())); + let size = look.size; + let before = draft.body.clone(); + let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| match typed(&before, text.as_str()) { + Some(b) => galley(ui, &b, size, color, wrap), + None => ui.fonts_mut(|f| f.layout_job(LayoutJob::simple(text.as_str().to_string(), theme::font(size), color, wrap))), + }; + let out = configure(egui::TextEdit::multiline(&mut draft.text)).layouter(&mut layouter).show(ui); + if draft.body.text() != draft.text { + edit::replace_text(&mut draft.body, &draft.text); + draft.edited = true; + } + // Bullets and numbers sit at the start of each paragraph's first line. + if draft.body.text() == draft.text { + let painter = ui.painter().with_clip_rect(out.text_clip_rect); + let k = size / 12.0; + let mut at = 0usize; + for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) { + if let Some(b) = bullet { + let row = out.galley.pos_from_cursor(CCursor::new(at)); + let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y); + let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8); + painter.text(pos, Align2::LEFT_CENTER, b, font, color); + } + at += p.char_len() + 1; + } + } + out.response.response +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_model::text::{ParaProps, Run}; + + use super::*; + + /// Notes as PowerPoint users write them: a bold heading, a plain line, points with a + /// Wingdings square bullet and a hanging indent (one long enough to wrap), numbered points, + /// a point with an explicit buNone, closing plain lines; Slovak text throughout. + fn notes() -> TextBody { + let props = |bullet: Bullet| ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(bullet), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }; + let point = |runs: Vec, bullet: Bullet| Paragraph { level: 1, props: props(bullet), runs, ..Default::default() }; + let square = || Bullet::Char { char: "§".into() }; + let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold), Run::new("vysvetliť cyklus")], square()), + point(vec![Run::new("Dlhý bod, ktorý sa zalomí na viac riadkov, lebo je naozaj veľmi dlhý a pokračuje ďalej.")], square()), + point(vec![Run::new("Jeden")], num()), + point(vec![Run::new("Dva")], num()), + point(vec![Run::new("Bez odrážky")], Bullet::None), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + } + } + + #[test] + fn bullets_follow_the_paragraphs() { + let b = bullets(¬es()); + assert_eq!(b, vec![None, None, Some("▪".into()), Some("▪".into()), Some("1.".into()), Some("2.".into()), None, None]); + } + + #[test] + fn notes_show_bold_runs_indents_and_wrap_under_the_text() { + let body = notes(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut laid = None; + let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0))); + out.textures_delta.clear(); + let g = laid.unwrap(); + assert_eq!(g.job.text, body.text(), "the galley reads the notes"); + assert_eq!(g.rows.iter().map(|r| r.char_count_including_newline().0).sum::(), body.text().chars().count()); + // Bold where the runs are bold, regular elsewhere. + let font_of = |s: &str| { + let b = g.job.text.find(s).unwrap(); + g.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone() + }; + assert_eq!(font_of("Metodický"), theme::bold(12.0)); + assert_eq!(font_of("Cieľ"), theme::bold(12.0)); + assert_eq!(font_of("vysvetliť"), theme::font(12.0)); + assert_eq!(font_of("Pomôcky"), theme::font(12.0)); + // Rows: x where each paragraph's text starts. + let row_x = |s: &str| { + let at = body.text().find(s).map(|b| body.text()[..b].chars().count()).unwrap(); + g.pos_from_cursor(CCursor::new(at)).min.x + }; + assert!(row_x("Metodický") < 1.0 && row_x("Žiaci") < 1.0 && row_x("Pomôcky") < 1.0, "plain paragraphs aren't indented"); + assert!((row_x("Cieľ") - 22.5).abs() < 1.0, "bulleted text at the 22.5 pt margin: {}", row_x("Cieľ")); + assert!((row_x("Bez odrážky") - 0.0).abs() < 1.0, "buNone: no bullet, the first line hangs to 0"); + // The long point wraps, and its wrapped lines start at the margin, not under the bullet. + let long = body.text().find("Dlhý").map(|b| body.text()[..b].chars().count()).unwrap(); + let first_row = g.pos_from_cursor(CCursor::new(long)); + let wrapped: Vec<_> = + g.rows.iter().filter(|r| r.pos.y > first_row.min.y + 1.0 && r.pos.y < g.pos_from_cursor(CCursor::new(long + 80)).max.y).collect(); + assert!(!wrapped.is_empty(), "the long point wraps"); + assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text"); + } + + #[test] + fn typing_keeps_the_formatting_around_it() { + let mut d = Draft::new(¬es()); + // Typing into the bold heading continues it in bold. + let t = d.text.replacen("prehľad", "prehľad hodiny", 1); + d.body = typed(&d.body, &t).unwrap(); + assert_eq!(d.body.paragraphs[0].runs.len(), 1); + assert_eq!(d.body.paragraphs[0].runs[0].props.bold, Some(true)); + // Enter at the end of a bulleted point starts another one with the same bullet and indent. + let t = d.body.text().replacen("cyklus", "cyklus\nnový bod", 1); + d.body = typed(&d.body, &t).unwrap(); + let new = &d.body.paragraphs[3]; + assert_eq!(new.text(), "nový bod"); + assert_eq!((new.level, &new.props), (1, ¬es().paragraphs[2].props)); + assert_eq!(d.body.paragraphs[2].runs[0].props.bold, Some(true), "the bold label stays bold"); + // Hostile sizes and indents stay bounded. + let mut wild = notes(); + wild.paragraphs[2].props.margin_left = Some(f64::NAN); + wild.paragraphs[0].runs[0].props.size = Some(f64::INFINITY); + let j = job(&wild, 12.0, Color32::BLACK, 300.0); + assert!(j.sections.iter().all(|s| s.leading_space.is_finite() && s.format.font_id.size <= 24.0)); + } +} diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd85..c67b9e6f 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -132,22 +132,27 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside); let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); - let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); let id = egui::Id::new(("notespage", idx)); - let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone())); - let r = child.add( - egui::TextEdit::multiline(&mut buf) - .hint_text("Click to add text") - .desired_width(notes_rect.width()) - .desired_rows(12) - .frame(egui::Frame::NONE) - .text_color(Color32::from_gray(30)) - .font(theme::font(14.0)), - ); - if r.lost_focus() && buf != current { - let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + let edit_id = egui::Id::new(("notespage-edit", idx)); + let focused = child.ctx().memory(|m| m.has_focus(edit_id)); + let mut draft: Option = child.data_mut(|d| d.get_temp(id)); + // Save an edited draft once the field has lost focus, before anything reloads it. + if !focused && let Some(d) = draft.as_mut().filter(|d| d.edited) { + d.edited = false; + let _ = app.run("slide.notes", json!({"body": d.body, "index": idx})); } - child.data_mut(|d| d.insert_temp(id, buf)); + let notes = app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default(); + let mut draft = draft.filter(|d| focused || d.body == notes).unwrap_or_else(|| crate::notes::Draft::new(¬es)); + let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) }; + let width = notes_rect.width(); + let r = crate::notes::editor(&mut child, &mut draft, &look, |e| { + e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE) + }); + if r.lost_focus() && draft.edited { + draft.edited = false; + let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx})); + } + child.data_mut(|d| d.insert_temp(id, draft)); let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp))); if nav.0 { let _ = app.run("slide.next", json!({})); diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index a6f4eb11..fa272642 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -115,9 +115,18 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { if st.selection.target != deckcraft_engine::Target::Slides { return; } - let idx = st.selection.slide; - let uid = st.uid; - let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); + let (idx, uid) = (st.selection.slide, st.uid); + // Save an edited draft once the field has lost focus or another slide is shown (before + // anything reloads it), to the slide it was written on. + let id = egui::Id::new("notes_edit"); + let focused = ui.ctx().memory(|m| m.has_focus(id)); + if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) { + app.notes_buf.2.edited = false; + if app.notes_buf.0 == uid { + let _ = app.run("slide.notes", json!({"body": app.notes_buf.2.body, "index": app.notes_buf.1})); + } + } + let notes = app.session.active().and_then(|st| st.current_slide()).map(|s| s.notes.clone()).unwrap_or_default(); egui::Panel::bottom("notes") .resizable(true) .default_size(app.ui.notes_height) @@ -128,28 +137,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { let rect = ui.max_rect(); ui.painter() .line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator)); - // Keep a local buffer per slide; commit when it changes and the field loses focus. - if app.notes_buf.0 != uid || app.notes_buf.1 != idx { - app.notes_buf = (uid, idx, current.clone()); + // Keep a local draft per slide, following the notes while the field isn't focused. + if app.notes_buf.0 != uid || app.notes_buf.1 != idx || (!focused && app.notes_buf.2.body != notes) { + app.notes_buf = (uid, idx, crate::notes::Draft::new(¬es)); } - let id = egui::Id::new("notes_edit"); - let focused = ui.ctx().memory(|m| m.has_focus(id)); - if !focused && app.notes_buf.2 != current { - app.notes_buf.2 = current.clone(); - } - let mut buf = app.notes_buf.2.clone(); - let r = ui.add( - egui::TextEdit::multiline(&mut buf) - .id(id) - .hint_text("Click to add notes") - .frame(egui::Frame::NONE) - .desired_width(f32::INFINITY) - .desired_rows(2) - .font(theme::font(13.0)), - ); - app.notes_buf.2 = buf.clone(); - if r.lost_focus() && buf != current { - let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + let mut draft = app.notes_buf.2.clone(); + let look = crate::notes::Look { size: 13.0, color: None }; + let r = crate::notes::editor(ui, &mut draft, &look, |e| { + e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2) + }); + if r.lost_focus() && draft.edited { + draft.edited = false; + let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx})); } + app.notes_buf.2 = draft; }); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1d87c9ac..cffee35c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (formatted speaker notes) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -61,7 +61,7 @@ the round trip even though DeckCraft doesn't model it. | Presentation, slide size, sections (p14), custom shows | yes | yes | | | Slide masters, layouts, placeholders and inheritance | yes | yes | | | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | -| Notes slides and notes master | yes | yes | No notes-master editing view | +| Notes slides and notes master | yes | yes | Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown; no notes-master editing view | | Handout master | no | no | | | Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | @@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | From f033c76913a271944b9afb895d161b420b8dcc66 Mon Sep 17 00:00:00 2001 From: Hevar Hemin Date: Sun, 11 Oct 2026 22:28:45 +0300 Subject: [PATCH 023/376] Bidi: auto-detect RTL paragraph direction and keep typing caret advancing smoothly When typing in Kurdish, Arabic or Hebrew in a new document or text shape, default paragraph direction (props.rtl: None) was resolving to LTR. In an LTR paragraph, visual reordering pinned newly typed characters and the caret to the left margin, resetting to the start of the line with every keystroke and jumping on space characters due to UAX #9 rule L1. - Add first_strong_is_rtl() in deckcraft-model following UAX #9 rules P2/P3. - Auto-resolve unassigned paragraph direction to RTL in shape_para layout when the paragraph starts with an RTL script, defaulting alignment to Align::Right. - Auto-promote paragraph to RTL (props.rtl = Some(true), props.align = Some(Align::Right)) when typing or inserting text starting with an RTL character. - Add regression tests in deckcraft-model, deckcraft-text, and deckcraft-engine. --- crates/engine/src/cmd/text.rs | 22 +++++++++- crates/engine/src/tests.rs | 47 +++++++++++++++++++- crates/model/src/bidi.rs | 81 +++++++++++++++++++++++++++++++++++ crates/model/src/lib.rs | 2 + crates/text/src/lib.rs | 7 ++- crates/text/src/tests.rs | 13 ++++++ 6 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 crates/model/src/bidi.rs diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a51..e09bb8d9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; +use deckcraft_model::text::{Align, AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; use deckcraft_model::{Presentation, ShapeId, ShapeKind}; use serde_json::{Value, json}; @@ -232,12 +232,26 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) let c = ed::insert(body, at, &text, props); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para); + } + } c })?; autocorrect(s)?; Ok(json!({"caret": [pos.0, pos.1]})) } +fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph) { + if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { + para.props.rtl = Some(true); + if para.props.align.is_none() { + para.props.align = Some(Align::Right); + } + } +} + /// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked /// run, the props to type with are the run's without the link. `None` elsewhere. fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { @@ -314,6 +328,11 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { let c = ed::insert_body(body, at, src); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para); + } + } c })?; Ok(json!({"caret": [pos.0, pos.1]})) @@ -518,6 +537,7 @@ fn set(s: &mut Session, p: &Value) -> Result { para.level = *lv; para.props = pp.clone(); } + promote_rtl_if_needed(para); for r in &mut para.runs { r.props = keep.clone(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b7..31365ca0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -406,7 +406,7 @@ fn right_to_left_paragraphs() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap(); + s.execute("text.insert", &json!({"text": "Year 2024 (Retrait) only"})).unwrap(); s.execute("format.rtl", &json!({})).unwrap(); let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); assert_eq!(para(&s).rtl, Some(true)); @@ -426,6 +426,51 @@ fn right_to_left_paragraphs() { assert_eq!(para(&s).rtl, Some(true)); } +#[test] +fn typing_kurdish_or_arabic_auto_detects_rtl_and_advances_caret_smoothly() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + + let mut first_x = None; + let mut last_x = 0.0; + + // Type Kurdish sentence character-by-character: "من ناوم هێڤاره" + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + s.execute("text.insert", &json!({"text": ch})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().unwrap(); + let l = super::cmd::text::layout_for(st, t).unwrap(); + let (cx, ..) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }).unwrap(); + + if first_x.is_none() { + first_x = Some(cx); + } + last_x = cx; + + // Caret must remain near the right margin (e.g. > 400.0 pt in title placeholder) + // and must NOT jump back to the left margin (e.g. ~7.2 pt). + assert!(cx > 400.0, "caret should not jump to left margin: {cx}"); + } + + // Caret advances leftwards overall as text is appended + assert!(last_x < first_x.unwrap(), "caret should advance leftwards: last={last_x} first={:?}", first_x); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(true), "auto-detected as RTL"); + assert_eq!(para.align, Some(deckcraft_model::text::Align::Right), "aligned right"); +} + +#[test] +fn explicit_ltr_override_is_preserved_when_typing_rtl() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("format.rtl", &json!({"on": false})).unwrap(); + s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap(); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(false), "user explicit LTR is preserved"); +} + #[test] fn header_footer_fixed_date_is_the_slides_own_text() { use deckcraft_model::text::RunKind; diff --git a/crates/model/src/bidi.rs b/crates/model/src/bidi.rs new file mode 100644 index 00000000..0b113522 --- /dev/null +++ b/crates/model/src/bidi.rs @@ -0,0 +1,81 @@ +//! Bidirectional text helpers (UAX #9). +//! +//! Follows Unicode Bidirectional Algorithm (UAX #9 rules P2 and P3) for paragraph +//! base direction auto-detection. + +/// Does the first strong directional character in `text` belong to a right-to-left script? +/// +/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R +/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek, +/// Cyrillic, CJK…) give `false`. Neutral, weak, and whitespace characters are skipped. +pub fn first_strong_is_rtl(text: &str) -> bool { + for c in text.chars() { + if c == '\u{200F}' { + // Right-to-Left Mark (RLM) + return true; + } + if c == '\u{200E}' { + // Left-to-Right Mark (LRM) + return false; + } + if c.is_alphabetic() { + return is_rtl_script(c); + } + } + false +} + +/// Helper checking if a char belongs to standard RTL Unicode blocks. +pub fn is_rtl_script(c: char) -> bool { + matches!( + c, + '\u{0590}'..='\u{05FF}' // Hebrew + | '\u{0600}'..='\u{06FF}' // Arabic + | '\u{0700}'..='\u{074F}' // Syriac + | '\u{0750}'..='\u{077F}' // Arabic Supplement + | '\u{0780}'..='\u{07BF}' // Thaana + | '\u{0870}'..='\u{089F}' // Arabic Extended-B + | '\u{08A0}'..='\u{08FF}' // Arabic Extended-A + | '\u{FB1D}'..='\u{FB4F}' // Hebrew Presentation Forms + | '\u{FB50}'..='\u{FDFF}' // Arabic Presentation Forms-A + | '\u{FE70}'..='\u{FEFE}' // Arabic Presentation Forms-B + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_arabic_and_kurdish() { + assert!(first_strong_is_rtl("مرحبا")); + assert!(first_strong_is_rtl("من ناوم هێڤاره")); + assert!(first_strong_is_rtl("سلام")); + } + + #[test] + fn detects_hebrew() { + assert!(first_strong_is_rtl("שלום")); + } + + #[test] + fn detects_latin_as_not_rtl() { + assert!(!first_strong_is_rtl("Hello")); + assert!(!first_strong_is_rtl("DeckCraft")); + } + + #[test] + fn skips_neutrals_numbers_and_whitespace() { + assert!(first_strong_is_rtl(" 123 مرحبا")); + assert!(first_strong_is_rtl("... (2024) سنة")); + assert!(!first_strong_is_rtl(" 123 Hello")); + assert!(!first_strong_is_rtl("")); + assert!(!first_strong_is_rtl(" 12345 ")); + } + + #[test] + fn honours_directional_marks() { + assert!(first_strong_is_rtl("\u{200F}Hello")); + assert!(!first_strong_is_rtl("\u{200E}مرحبا")); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 6940fa4b..f8129f69 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod anim; +pub mod bidi; pub mod chart; pub mod defaults; pub mod edit; @@ -24,6 +25,7 @@ pub use deckcraft_geom::{Rect, Size, Xfrm}; use serde::{Deserialize, Serialize}; pub use anim::{AnimClass, AnimStart, Animation, Transition}; +pub use bidi::{first_strong_is_rtl, is_rtl_script}; pub use chart::Chart; pub use style::{ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle}; pub use table::{Cell, Table}; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 225f4afb..a14f5ad7 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -415,8 +415,11 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, rtl: false, }; }; - let props = resolve::para(ctx, shape, para); - let rtl = props.rtl.unwrap_or(false); + let mut props = resolve::para(ctx, shape, para); + let rtl = props.rtl.unwrap_or_else(|| deckcraft_model::bidi::first_strong_is_rtl(¶.text())); + if rtl && para.props.align.is_none() { + props.align = Some(Align::Right); + } let mut styles: Vec

A & B

C

"; + let body = html_to_body(html).unwrap(); + assert_eq!(body.text(), "A & B\nC"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true)); + assert!(same_words(&body.text(), "A & B\nC")); + assert!(!same_words(&body.text(), "other")); + } + + #[test] + fn a_huge_fragment_is_refused() { + let html = "x".repeat(MAX_HTML + 1); + assert!(html_to_body(&html).is_none()); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..57b66c09 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -93,6 +93,62 @@ fn clipboard_copy_paste_shapes_and_slides() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +#[test] +fn bold_copy_is_html_and_pages_html_pastes_bold() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + let v = s.execute("edit.copy", &json!({})).unwrap(); + let html = v["html"].as_str().unwrap(); + assert!(html.contains(""), "{html}"); + assert_eq!(v["text"], "Hello"); + // The same plain words are our own clipboard, so a plainer HTML fragment is not reparsed. + s.execute("text.select", &json!({"anchor": [0, 5], "caret": [0, 5]})).unwrap(); + s.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.clone().unwrap(); + assert_eq!(body.text(), "HelloHello"); + assert!(body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| r.props.bold == Some(true)), "{body:?}"); + s.execute("text.select", &json!({"anchor": [0, 10], "caret": [0, 10]})).unwrap(); + let caret = s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(caret["text"], ""); + assert_eq!(caret["html"], ""); + + let mut ext = session(); + ext.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let pasted = ext + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello")) + .unwrap() + .text + .clone() + .unwrap(); + assert_eq!(pasted.paragraphs[0].runs[0].props.bold, Some(true), "{pasted:?}"); + + let mut mismatched = session(); + mismatched.execute("edit.paste", &json!({"text": "Hi", "html": "Nope"})).unwrap(); + let plain = mismatched + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .unwrap() + .text + .clone() + .unwrap(); + assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true)); +} + #[test] fn group_ungroup_roundtrip_positions() { let mut s = session(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..e9ac442e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2107,6 +2107,49 @@ fn draw_glyph_with( } } +/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). +pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { + if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { + return None; + } + let rctx = Ctx::for_slide(pres, slide)?; + let mut items = Vec::new(); + let mut bounds: Option = None; + for id in ids.iter().take(256) { + let Some(s) = slide.shape(*id) else { continue }; + let b = resolve::xfrm(&rctx, s).bounds(); + if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { + continue; + } + bounds = Some(match bounds { + Some(u) => u.union(b), + None => b, + }); + items.push(s); + } + let b = bounds?.inflate(8.0, 8.0); + if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { + return None; + } + let fit = (2048.0 / b.width().max(b.height())).min(scale); + if !(fit.is_finite() && fit > 0.0) { + return None; + } + let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; + let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale: fit, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; + let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + for s in items { + r.shape(&mut ctx, &f, &rctx, s, view, 0); + } + Some(r.finish(ctx, w, h)) + }) +} + /// Render one shape alone onto a transparent canvas (drag previews, copy as picture). pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { let rctx = Ctx::for_slide(pres, slide)?; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..2c469218 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -35,6 +35,22 @@ fn shape_fill_uses_theme_accent() { assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); } +#[test] +fn render_shapes_draws_the_selection_on_a_transparent_canvas() { + let sh = Shape { + id: ShapeId(880), + xfrm: Some(Xfrm::new(100.0, 80.0, 60.0, 40.0)), + fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))), + line: Some(deckcraft_model::Line::none()), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_shapes(&p, &p.slides[0], &[ShapeId(880)], 1.0).expect("picture"); + let mid = img.pixel(img.width / 2, img.height / 2); + assert!(mid[0] > 200 && mid[1] < 40 && mid[3] > 200, "{mid:?}"); + assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0)); +} + #[test] fn text_draws_ink() { let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..7cf2fde3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,6 +60,10 @@ pub struct Services { pub inbox: Option, /// Image on the system clipboard (PNG bytes), when the host can read one. pub clipboard_image: Option Option>>>, + /// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`). + pub clipboard_html: Option Option>>, + /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed. + pub clipboard_set_html: Option bool>>, /// Audio output for media playback (cpal on desktop); without one media plays silently. pub audio_out: Option>, } @@ -881,28 +885,24 @@ impl SlideApp { } } egui::Event::Ime(ime) => self.ime_event(&ime), - egui::Event::Copy => { - if let Ok(v) = self.session.execute("edit.copy", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } - } - egui::Event::Cut => { - if let Ok(v) = self.session.execute("edit.cut", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } - } + egui::Event::Copy => self.publish_clipboard(ctx, false), + egui::Event::Cut => self.publish_clipboard(ctx, true), egui::Event::Paste(text) => { - // An image on the clipboard (screenshots, copied pictures) becomes a picture. + // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. + // Text keeps its HTML (bold, italic, underline) when the host can read it. + let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let r = match img { Some(png) => self .session .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), - None => self.session.execute("edit.paste", &json!({"text": text})), + None => { + let mut params = json!({"text": text}); + if let Some(h) = html { + params["html"] = Value::String(h); + } + self.session.execute("edit.paste", ¶ms) + } }; if let Err(e) = r { self.set_status(e.to_string()); @@ -925,6 +925,61 @@ impl SlideApp { self.drain_requests(); } + /// Offer the copy or cut on the system clipboard. + /// + /// Slides go out as plain text. A textless shape goes out as a picture (Keynote and Pages + /// paste that). Text goes out as HTML plus the same plain words, so a paste back into + /// DeckCraft still uses the internal clipboard. + fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { + // Cut deletes the shapes, so the picture is taken first. + let picture = self.textless_selection_image(); + let cmd = if cut { "edit.cut" } else { "edit.copy" }; + let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; + let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); + if !self.session.clipboard.slides.is_empty() { + ctx.copy_text(text); + return; + } + if let Some(img) = picture { + ctx.copy_image(img); + return; + } + if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) { + return; + } + ctx.copy_text(text); + } + + /// A picture of the selected shapes when none of them has text, on the slide (not a master). + fn textless_selection_image(&self) -> Option { + let st = self.session.active()?; + if st.selection.target != deckcraft_engine::Target::Slides || st.selection.text.is_some() || st.selection.shapes.is_empty() { + return None; + } + let mut plain = String::new(); + let mut any = false; + for id in &st.selection.shapes { + let Some(sh) = st.shape(*id) else { continue }; + any = true; + if let Some(t) = &sh.text { + if !plain.is_empty() { + plain.push('\n'); + } + plain.push_str(&t.text()); + } + } + if !any || !plain.is_empty() { + return None; + } + let slide = st.current_slide()?; + let img = deckcraft_render::render_shapes(&st.doc, slide, &st.selection.shapes, 2.0)?; + if img.width == 0 || img.height == 0 { + return None; + } + Some(textures::to_color_image(&img, false)) + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1408,4 +1463,71 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), services), + ); + h.run_steps(2); + h + } + + /// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. + #[test] + fn copy_offers_html_and_paste_keeps_bold() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(None)); + let record = seen.clone(); + let services = Services { + clipboard_set_html: Some(Box::new(move |html, plain| { + *record.borrow_mut() = Some((html.to_string(), plain.to_string())); + true + })), + ..Default::default() + }; + let mut h = step_app(services); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + h.event(egui::Event::Text("Hi".into())); + h.run_steps(1); + h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let (html, plain) = seen.borrow().clone().expect("html offered"); + assert!(html.contains(""), "{html}"); + assert_eq!(plain, "Hi"); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); + + let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; + let mut h = step_app(services); + h.event(egui::Event::Paste("Hi".into())); + h.run_steps(1); + let st = h.state().session.active().unwrap(); + let body = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .and_then(|sh| sh.text.clone()) + .expect("pasted text"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); + } + + /// A shape with no text copies out as a picture. + #[test] + fn a_textless_shape_copies_as_a_picture() { + let mut h = step_app(Services::default()); + h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let cmds = &h.output().platform_output.commands; + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyImage(img) if img.size[0] > 0 && img.size[1] > 0)), "{cmds:?}"); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..dec16e32 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,8 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -85,7 +85,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | -| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | +| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), a textless shape copies out as a picture, and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | ## Keyboard shortcuts @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) and a picture of a textless shape with other apps | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | From fa4b80ed654b6190fcbfa4c7706ebe1a8373f934 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:40:11 +0300 Subject: [PATCH 299/376] UI: Slide Master Preserve and Title or Footers placeholders Preserve the open master or layout, and turn its title and footer placeholders on or off. Each change is one undo step, and a value that is already set is not. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/engine/src/cmd/design.rs | 302 +++++++++++++++++++++- crates/ui-egui/src/master_layout_tests.rs | 61 +++++ crates/ui-egui/src/ribbon.rs | 34 +++ docs/architecture.md | 5 +- docs/gaps.md | 3 +- docs/target-app-parity.md | 11 +- docs/ui-parity.md | 6 +- 8 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/master_layout_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..c4297df4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 3cad3f00..d36cce81 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -60,6 +60,16 @@ pub fn specs() -> Vec { has_doc, insert_placeholder ), + cmd!("master.preserve", "Preserve", ["Slide Master", "Edit Master"], None, "{preserve?: bool}", has_doc, preserve), + cmd!( + "master.placeholders", + "Layout Placeholders", + ["Slide Master", "Edit Master"], + None, + "{title?: bool, footers?: bool}", + has_doc, + placeholders + ), ] } @@ -432,8 +442,14 @@ pub fn civil(z: i64) -> (i64, u32, u32) { fn master_view(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); - let layout = usize_param(p, "layout") - .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))); + // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects + // that layout. With neither key, follow the current slide's layout. + let layout = if p.get("master").is_some() && p.get("layout").is_none() { + None + } else { + usize_param(p, "layout") + .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) + }; let target = match layout { Some(l) => Target::Layout { master: mi, layout: l }, None => Target::Master { master: mi }, @@ -556,11 +572,293 @@ fn insert_placeholder(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id})) } +fn preserve(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let (on_master, mi, li, current) = match st.selection.target { + Target::Master { master } => { + let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?; + (true, master, 0, on) + } + Target::Layout { master, layout } => { + let on = st + .doc + .masters + .get(master) + .and_then(|m| m.layouts.get(layout)) + .map(|l| l.preserve) + .ok_or_else(|| bad("master.preserve", "no such layout"))?; + (false, master, layout, on) + } + Target::Slides => return Err(bad("master.preserve", "open Slide Master view")), + }; + let next = bool_param(p, "preserve").unwrap_or(!current); + if next == current { + return Ok(json!({"preserve": next})); + } + s.edit(|doc, _| { + if on_master { + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?); + m.preserve = next; + } else { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?; + l.preserve = next; + } + Ok(()) + })?; + Ok(json!({"preserve": next})) +} + +fn placeholders(s: &mut Session, p: &Value) -> Result { + let title = bool_param(p, "title"); + let footers = bool_param(p, "footers"); + if title.is_none() && footers.is_none() { + return Err(bad("master.placeholders", "give title or footers")); + } + let st = s.doc()?; + if matches!(st.selection.target, Target::Slides) { + return Err(bad("master.placeholders", "open Slide Master view")); + } + let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + let title_changes = title.is_some_and(|want| want != has_title); + let footer_changes = footers.is_some_and(|want| want != has_footers); + if !title_changes && !footer_changes { + return Ok(json!({"title": has_title, "footers": has_footers})); + } + let mut add = Vec::new(); + if title == Some(true) && !has_title { + add.push(PhType::Title); + } + if footers == Some(true) && !has_footers { + add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]); + } + let size = st.doc.slide_size; + s.edit(|doc, sel| { + let mut ids = Vec::with_capacity(add.len()); + for _ in 0..add.len() { + ids.push(ShapeId(doc.alloc_id())); + } + let prototypes = match sel.target { + Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(), + Target::Slides => Vec::new(), + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?; + if title == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); + } + if footers == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind)); + } + for (kind, id) in add.into_iter().zip(ids) { + let idx = free_ph_idx(shapes, preferred_ph_idx(kind)); + let proto = prototypes.iter().find(|sh| match kind { + PhType::Title => sh.ph_type().is_some_and(PhType::is_title), + other => sh.ph_type() == Some(other), + }); + let shape = match proto { + Some(src) => retarget_placeholder(src.clone(), id, idx), + None => synthesized_placeholder(kind, id, idx, size), + }; + shapes.push(shape); + } + Ok(()) + })?; + let st = s.doc()?; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + Ok(json!({"title": title, "footers": footers})) +} + +fn preferred_ph_idx(kind: PhType) -> u32 { + match kind { + PhType::Title | PhType::CtrTitle => 0, + PhType::Date => 10, + PhType::Footer => 11, + PhType::SlideNum => 12, + _ => 1, + } +} + +fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 { + let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx)); + if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) } +} + +/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide +/// number keeps its `‹#›` field. +fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape { + sh.id = id; + if let Some(ph) = sh.ph.as_mut() { + ph.idx = idx; + } + let slide_num = sh.ph_type() == Some(PhType::SlideNum); + if slide_num { + let paras = vec![slide_number_paragraph()]; + if let Some(text) = sh.text.as_mut() { + text.paragraphs = paras; + } else { + sh.text = Some(TextBody { paragraphs: paras, ..Default::default() }); + } + } else if let Some(text) = sh.text.as_mut() { + text.paragraphs.clear(); + } + sh +} + +fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape { + let (name, text) = match kind { + PhType::Date => ("Date Placeholder", TextBody::default()), + PhType::Footer => ("Footer Placeholder", TextBody::default()), + PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }), + _ => ("Title", TextBody::default()), + }; + Shape { + id, + name: name.into(), + xfrm: Some(placeholder_rect(kind, size)), + ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(text), + ..Default::default() + } +} + +/// Standard placeholder boxes at 960×540, scaled to the slide size. +fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm { + let sx = size.width / 960.0; + let sy = size.height / 540.0; + let (x, y, w, h) = match kind { + PhType::Date => (60.0, 500.0, 216.0, 28.0), + PhType::Footer => (318.0, 500.0, 324.0, 28.0), + PhType::SlideNum => (684.0, 500.0, 216.0, 28.0), + _ => (60.0, 29.0, 840.0, 104.0), + }; + deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy) +} + +fn slide_number_paragraph() -> deckcraft_model::Paragraph { + deckcraft_model::Paragraph { + runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }], + ..Default::default() + } +} + #[cfg(test)] mod tests { + use deckcraft_model::PhType; + use deckcraft_model::text::RunKind; + use serde_json::json; + + use crate::{Session, Target}; + #[test] fn civil_dates() { assert_eq!(super::civil(0), (1970, 1, 1)); assert_eq!(super::civil(20_734), (2026, 10, 8)); } + + fn shapes(s: &Session) -> Vec { + s.doc().unwrap().shapes().to_vec() + } + + fn has_title(s: &Session) -> bool { + shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)) + } + + fn has_footers(s: &Session) -> bool { + shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)) + } + + #[test] + fn preserve_and_placeholders_edit_the_open_master_or_layout() { + assert_eq!(crate::command_specs().len(), 236); + + let mut s = Session::with_new(); + let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); + + s.execute("view.slideMaster", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 }); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 }); + assert!(!s.doc().unwrap().doc.masters[0].preserve); + + let n = s.doc().unwrap().history.undo.len(); + s.execute("master.preserve", &json!({"preserve": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step"); + s.execute("master.preserve", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.masters[0].preserve); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().doc.masters[0].preserve); + + s.execute("master.placeholders", &json!({"footers": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); + assert!(has_title(&s)); + assert!(!has_footers(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(has_footers(&s)); + + s.execute("view.closeMaster", &json!({})).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("master.preserve", &json!({})).unwrap_err(); + assert!(err.to_string().contains("open Slide Master view"), "{err}"); + let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err(); + assert!(err.to_string().contains("open Slide Master view"), "{err}"); + let err = s.execute("master.placeholders", &json!({})).unwrap_err(); + assert!(err.to_string().contains("give title or footers"), "{err}"); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + + s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank }); + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + assert!(!has_title(&s)); + assert!(has_footers(&s)); + + let n = s.doc().unwrap().history.undo.len(); + s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state"); + + s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); + assert!(has_title(&s)); + assert!(!has_footers(&s)); + let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + assert_eq!(title.name, "Title 1"); + assert_eq!(title.ph.unwrap().idx, 0); + assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!has_title(&s)); + assert!(has_footers(&s)); + + s.execute("master.placeholders", &json!({"footers": false})).unwrap(); + s.execute("master.placeholders", &json!({"footers": true})).unwrap(); + let restored = shapes(&s); + assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date))); + assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer))); + let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap(); + let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0]; + assert_eq!(run.text, "‹#›"); + assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum")); + + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + s.execute("master.preserve", &json!({"preserve": false})).unwrap(); + assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + + s.edit(|doc, _| { + let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap()); + m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); + Ok(()) + }) + .unwrap(); + s.execute("master.placeholders", &json!({"title": true})).unwrap(); + let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); + assert_eq!(title.name, "Title"); + let box_ = title.xfrm.unwrap(); + assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01); + assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01); + } } diff --git a/crates/ui-egui/src/master_layout_tests.rs b/crates/ui-egui/src/master_layout_tests.rs new file mode 100644 index 00000000..d4f5efa0 --- /dev/null +++ b/crates/ui-egui/src/master_layout_tests.rs @@ -0,0 +1,61 @@ +//! Slide Master ribbon: Preserve, Title and Footers on the open layout. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use deckcraft_model::PhType; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness(app: SlideApp) -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn app_on_blank() -> (SlideApp, usize) { + let mut session = Session::with_new(); + let blank = session.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); + session.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); + let mut app = SlideApp::new(session, Services::default()); + app.ui.tab = "Slide Master".into(); + (app, blank) +} + +fn click_check(h: &mut Harness<'_, SlideApp>, label: &str) { + h.query_all_by_role_and_label(Role::CheckBox, label).next().unwrap_or_else(|| panic!("no {label} checkbox")).click(); + h.run_steps(3); +} + +#[test] +fn slide_master_preserve_title_and_footers() { + let (app, blank) = app_on_blank(); + let mut h = harness(app); + assert!(h.query_all_by_role_and_label(Role::CheckBox, "Preserve").next().is_some()); + assert!(h.query_all_by_role_and_label(Role::CheckBox, "Title").next().is_some()); + assert!(h.query_all_by_role_and_label(Role::CheckBox, "Footers").next().is_some()); + { + let st = h.state().session.doc().unwrap(); + assert!(st.doc.masters[0].layouts[blank].preserve); + assert!(!st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))); + assert!(st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))); + } + + click_check(&mut h, "Title"); + assert!(h.state().session.doc().unwrap().shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))); + + click_check(&mut h, "Footers"); + assert!(!h.state().session.doc().unwrap().shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))); + + click_check(&mut h, "Preserve"); + assert!(!h.state().session.doc().unwrap().doc.masters[0].layouts[blank].preserve); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..af1a3b63 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2372,12 +2372,42 @@ fn playback(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Preserve, and whether the open master or layout has a title or any footer placeholder. +fn master_checks(app: &SlideApp) -> (bool, bool, bool) { + let Some(st) = app.session.active() else { return (false, false, false) }; + let preserve = match st.selection.target { + Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve), + Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve), + Target::Slides => false, + }; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title())); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind())); + (preserve, title, footers) +} + fn slide_master(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({})); big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({})); big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"})); }); + group(ui, |ui| { + rows(ui, |ui| { + let (preserve, title, footers) = master_checks(app); + let mut on = preserve; + if ui.checkbox(&mut on, "Preserve").changed() { + run(app, "master.preserve", json!({"preserve": on})); + } + let mut on = title; + if ui.checkbox(&mut on, "Title").changed() { + run(app, "master.placeholders", json!({"title": on})); + } + let mut on = footers; + if ui.checkbox(&mut on, "Footers").changed() { + run(app, "master.placeholders", json!({"footers": on})); + } + }); + }); group(ui, |ui| { let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true); egui::Popup::menu(&r).show(|ui| { @@ -2420,3 +2450,7 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +#[cfg(test)] +#[path = "master_layout_tests.rs"] +mod master_layout_tests; diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..a11c7ed1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (command registry counted at 236; dialogs stay 27; the test-count line stays 504; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -20,7 +20,7 @@ nothing below L6 uses egui, eframe, winit or rfd. | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | | L4 | `pdf` | PDF export through krilla | -| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (236 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | @@ -78,5 +78,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Command registry counted at 236, including `master.preserve` and `master.placeholders`. Dialogs stay 27. The test-count line stays 504. The menu measurement and the d556ff9 snapshot still count 226 | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..0b3af950 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..cae2ec26 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -230,8 +230,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Slide show:** full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter window, rehearse timings, custom shows, Set Up Show, Reading View; links and shape actions followed on click. -- **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master; zoom; - grayscale. +- **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master (Preserve on + the open master or layout, and Title and Footers placeholders); zoom; grayscale. - **Review:** comments with replies and resolve, accessibility checker, a basic spelling list. - **Media:** MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264, HEVC, VP9 and AV1 video in MP4/MOV/WebM/MKV with pure-Rust decoders; poster frames, trim, fades, @@ -239,8 +239,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline text, AutoRecover. -- **Automation:** 226 commands, every one reachable from `deckcraft-cli`, the JSON control channel - and MCP. +- **Automation:** 236 commands, every one reachable from `deckcraft-cli`, the JSON control channel + and MCP. The menu-bar measurement and the d556ff9 snapshot still count 226 commands. - **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09). @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Automation in "what works today" is 236 commands; the menu-bar measurement and the d556ff9 snapshot still count 226. Percentages unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..380f573b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,8 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -60,6 +60,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes | +| Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | @@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | From 084e076c1da8fe97e51225f8d1c40db0cb405a87 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:41:16 +0300 Subject: [PATCH 300/376] Undo: cap the history by memory as well as by steps Undo kept up to 500 steps whatever their size. Steps share what an edit didn't touch, so most are small, but a deck-wide edit (slide size, theme, find-and-replace style changes) keeps a copy of every slide: 500 of those on a big deck could hold hundreds of copies. Each step's cost is now estimated when it is pushed (the slides, masters and media it doesn't share with the document after it), and the oldest steps are dropped while the total passes History::byte_limit (UNDO_BYTE_BUDGET, 256 MiB, by default). The newest step always stays. Co-authored-by: Cursor --- crates/engine/src/lib.rs | 70 +++++++++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 30 ++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..0ea81356 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -129,6 +129,12 @@ pub struct History { pub undo: Vec, pub redo: Vec, pub limit: usize, + /// Most memory (estimated bytes) the undo steps may keep beyond the current document; + /// 0 means [`UNDO_BYTE_BUDGET`]. + pub byte_limit: usize, + /// Estimated bytes each undo step keeps that the document after it doesn't share, by the + /// step's document address. + pub costs: std::collections::HashMap, } /// A live pointer interaction: the document before it began (one undo step at the end). @@ -543,11 +549,67 @@ fn clamp_selection(st: &mut DocState) { } } +/// Default for [`History::byte_limit`]. +pub const UNDO_BYTE_BUDGET: usize = 256 << 20; + +/// A rough size of a shape in memory: the struct, its text and kept XML, its children. +fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize { + let text: usize = + sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::()).sum()); + let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 }; + 768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids +} + +fn shapes_cost(shapes: &[deckcraft_model::Shape]) -> usize { + shapes.iter().map(|s| shape_cost(s, 0)).sum() +} + +/// Estimated bytes `doc` keeps alive that `newer` doesn't share: its slides, masters and media +/// that aren't the same allocations (undo steps share everything an edit didn't touch). +pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize { + use std::collections::HashSet; + let slides: HashSet<*const deckcraft_model::Slide> = newer.slides.iter().map(Arc::as_ptr).collect(); + let masters: HashSet<*const deckcraft_model::Master> = newer.masters.iter().map(Arc::as_ptr).collect(); + let media: HashSet<*const Vec> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect(); + let mut n = 64 * doc.slides.len(); + for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) { + n += 512 + shapes_cost(&s.shapes); + } + for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) { + n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::(); + } + for m in doc.media.iter().filter(|m| !media.contains(&Arc::as_ptr(&m.data))) { + n += m.data.len(); + } + n +} + pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) { - st.history.undo.push(e); - st.history.redo.clear(); - if st.history.undo.len() > st.history.limit.max(1) { - st.history.undo.remove(0); + let h = &mut st.history; + h.costs.insert(Arc::as_ptr(&e.doc) as usize, undo_cost(&e.doc, &st.doc)); + h.undo.push(e); + h.redo.clear(); + if h.undo.len() > h.limit.max(1) { + h.undo.remove(0); + } + // Memory too: drop the oldest steps while the kept ones pass the budget (the newest always + // stays, however big). A deck-wide edit keeps a copy of every slide, so 500 of them on a big + // deck could hold hundreds of copies. + let budget = if h.byte_limit == 0 { UNDO_BYTE_BUDGET } else { h.byte_limit }; + let cost = |h: &History, e: &HistoryEntry| h.costs.get(&(Arc::as_ptr(&e.doc) as usize)).copied().unwrap_or(0); + let mut total: usize = h.undo.iter().map(|e| cost(h, e)).sum(); + let mut drop = 0; + while total > budget && drop + 1 < h.undo.len() { + total -= cost(h, &h.undo[drop]); + drop += 1; + } + if drop > 0 { + h.undo.drain(..drop); + } + // Costs of steps that are gone (dropped, or redo steps cleared above). + if h.costs.len() > h.undo.len() + 16 { + let live: std::collections::HashSet = h.undo.iter().chain(h.redo.iter()).map(|e| Arc::as_ptr(&e.doc) as usize).collect(); + h.costs.retain(|k, _| live.contains(k)); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..c424ce11 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,33 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// Undo keeps a bounded amount of memory: deck-wide edits (each step holds a copy of every +/// slide) are dropped oldest first once the steps pass the byte budget, while ordinary small +/// edits still get the full step count. +#[test] +fn undo_history_is_capped_by_memory_as_well_as_steps() { + let mut s = Session::with_new(); + for _ in 0..40 { + s.execute("slide.new", &json!({})).unwrap(); + } + let one_copy = crate::undo_cost(&s.doc().unwrap().doc, &Presentation::default()); + assert!(one_copy > 0); + s.doc_mut().unwrap().history = History { limit: 500, byte_limit: one_copy * 10, ..Default::default() }; + for i in 0..30 { + // Resizing the slides rescales every shape on every slide: a new copy of each. + let w = if i % 2 == 0 { 720 } else { 960 }; + s.execute("design.slideSize", &json!({"w": w, "h": 540})).unwrap(); + } + let n = s.doc().unwrap().history.undo.len(); + assert!((5..=11).contains(&n), "{n} deck-wide steps kept"); + // Still undoable, newest first. + s.execute("edit.undo", &json!({})).unwrap(); + // Small edits: each keeps one slide's copy, so many fit. + let mut s = Session::with_new(); + s.doc_mut().unwrap().history.byte_limit = one_copy * 10; + for _ in 0..120 { + s.execute("slide.new", &json!({})).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), 120); +} From f036a8e805a2273fee34f78ad2d8ae2364a6aa24 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:44:59 +0300 Subject: [PATCH 301/376] PPTX: Color Pulse emphasis is written as a colour change, not a grow --- crates/pptx/src/write/timing.rs | 19 +++++++++++++++++++ crates/pptx/tests/roundtrip.rs | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index aa577ca7..f60b937c 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -354,6 +354,25 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) { w.close("p:to"); w.close("p:set"); } + "colorPulse" => { + // As PowerPoint writes it: text and fill go to the colour and back (autoRev). The behaviour + // duration is the one import reads as the effect duration. + let c = a.color.clone().unwrap_or(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0xC0, 0x00, 0x00))); + for (attr, child) in [("style.color", true), ("fillcolor", false)] { + w.open("p:animClr", A::new().a("clrSpc", "rgb").a("dir", "cw")); + w.open("p:cBhvr", A::new().o("override", child.then_some("childStyle"))); + w.empty("p:cTn", A::new().a("id", ids.next()).a("dur", dur.max(1)).a("autoRev", "1").a("fill", "remove")); + tgt(w, e.spid, e.paragraph); + w.open0("p:attrNameLst"); + w.elt("p:attrName", attr); + w.close("p:attrNameLst"); + w.close("p:cBhvr"); + w.open0("p:to"); + super::dml::color(w, &c); + w.close("p:to"); + w.close("p:animClr"); + } + } "fillColor" | "objectColor" | "fontColor" | "lineColor" | "complementaryColor" | "darken" | "lighten" => { let attr = match a.effect.as_str() { "fontColor" => "style.color", diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..79884d1f 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,28 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +#[test] +fn color_pulse_keeps_its_effect_colour_and_duration() { + let mut p = defaults::new_presentation(None); + let s = shape(&mut p, "Pulsed", 10.0, 10.0, 200.0, 100.0); + let id = s.id; + let first = p.slides[0].id; + let sl = p.slide_mut(first).expect("slide"); + sl.shapes.push(s); + let white = ColorRef::rgb(Rgba::rgb(255, 255, 255)); + sl.animations.push(Animation { + shape: id, + class: AnimClass::Emphasis, + effect: "colorPulse".into(), + duration_ms: 1000, + color: Some(white.clone()), + ..Default::default() + }); + let q = round(&p); + let a = &q.slides[0].animations[0]; + assert_eq!((a.class, a.effect.as_str()), (AnimClass::Emphasis, "colorPulse")); + assert_eq!(a.color, Some(white)); + assert_eq!(a.amount, None, "no grow/shrink scale is invented"); + assert_eq!(a.duration_ms, 1000); +} From ba290f171ea9faceddd988461a7fa9d594dea409 Mon Sep 17 00:00:00 2001 From: rulindanalambert Date: Wed, 14 Oct 2026 07:04:45 +0200 Subject: [PATCH 302/376] Fix presenter-view keyboard navigation and end-screen controls --- ROADMAP.md | 4 +- crates/ui-egui/src/show.rs | 208 ++++++++++++++++++++++++++++--------- docs/slideshow-parity.md | 4 +- 3 files changed, 165 insertions(+), 51 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..c519199a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (presenter keyboard navigation; hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Presenter View handles the same keyboard navigation as the audience window, consumes handled keys before focused buttons, and remains available at the end screen. Regression tests cover presenter-only input, single-step navigation and Escape | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Presenter keyboard navigation and end-screen controls | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..d90f2d72 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -125,6 +125,7 @@ impl Show { self.step_end = now; } ShowAction::Slide(i) => { + self.ended = false; if self.rehearse { self.timings.push((self.timeline.as_ref().map(|t| t.0).unwrap_or(i), now - self.slide_start)); } @@ -243,6 +244,67 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t ] } +/// Read the current viewport's show keys before its widgets can consume them. Both the +/// audience window and Presenter View use this path, including animation steps and end-of-show. +fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) -> bool { + let keys: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) + .collect() + }); + let mut keep = true; + let mut actions = vec![]; + for (k, m) in keys { + use egui::Key::*; + match k { + Escape | Minus => keep = false, + ArrowRight | ArrowDown | Space | PageDown | N => { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(doc)); + } + } + Enter => { + if let Ok(n) = show.goto.parse::() { + actions.push(show.state.goto(doc, n.saturating_sub(1))); + show.goto.clear(); + } else if show.ended { + keep = false; + } else { + actions.push(show.state.next(doc)); + } + } + ArrowLeft | ArrowUp | Backspace | PageUp | P if !(k == P && m.command) => { + show.ended = false; + actions.push(show.state.prev(doc)); + } + P if m.command => show.pen = !show.pen, + A if m.command => show.pen = false, + Home => actions.push(show.state.goto(doc, 0)), + End => actions.push(show.state.goto(doc, doc.slides.len().saturating_sub(1))), + B | Period => show.blank = if show.blank == Some(Color32::BLACK) { None } else { Some(Color32::BLACK) }, + W | Comma => show.blank = if show.blank == Some(Color32::WHITE) { None } else { Some(Color32::WHITE) }, + E => show.strokes.clear(), + Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => { + show.goto.push_str(k.name()); + } + _ => continue, + } + // The same key must not also activate a focused presenter button or be handled again + // by an embedded presenter viewport. + ctx.input_mut(|i| i.consume_key(m, k)); + ctx.request_repaint_of(egui::ViewportId::ROOT); + } + for action in actions { + if !show.apply(action, doc, now) { + keep = false; + } + } + keep +} + pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { @@ -262,58 +324,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } show.after_enter(&doc, now); } - let mut keep = true; - // Input. - let (keys, clicked, secondary, moved) = ctx.input(|i| { - let keys: Vec<(egui::Key, egui::Modifiers)> = i - .events - .iter() - .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) - .collect(); - (keys, i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0) - }); + let mut keep = keyboard(&ctx, &mut show, &doc, now); + let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); if moved { show.last_move = now; } let mut actions: Vec = vec![]; - for (k, m) in keys { - use egui::Key::*; - match k { - Escape | Minus => keep = false, - ArrowRight | ArrowDown | Space | PageDown | N => { - if show.ended { - keep = false; - } else { - actions.push(show.state.next(&doc)); - } - } - Enter => { - if let Ok(n) = show.goto.parse::() { - actions.push(show.state.goto(&doc, n.saturating_sub(1))); - show.goto.clear(); - } else if show.ended { - keep = false; - } else { - actions.push(show.state.next(&doc)); - } - } - ArrowLeft | ArrowUp | Backspace | PageUp | P if !(k == P && m.command) => { - show.ended = false; - actions.push(show.state.prev(&doc)); - } - P if m.command => show.pen = !show.pen, - A if m.command => show.pen = false, - Home => actions.push(show.state.goto(&doc, 0)), - End => actions.push(show.state.goto(&doc, doc.slides.len().saturating_sub(1))), - B | Period => show.blank = if show.blank == Some(Color32::BLACK) { None } else { Some(Color32::BLACK) }, - W | Comma => show.blank = if show.blank == Some(Color32::WHITE) { None } else { Some(Color32::WHITE) }, - E => show.strokes.clear(), - Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => { - show.goto.push_str(k.name()); - } - _ => {} - } - } let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx); let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None }; let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; @@ -661,7 +677,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // Presenter view in a second window. - if show.presenter && !show.reading && !show.ended { + if keep && show.presenter && !show.reading { presenter_window(&ctx, &mut show, &doc, now, &mut keep); } if keep { @@ -794,6 +810,9 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no egui::ViewportId::from_hash_of("deckcraft_presenter"), egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]), |ui, _| { + if !keyboard(ui.ctx(), show, doc, now) { + *keep = false; + } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| { ui.horizontal(|ui| { ui.label( @@ -867,6 +886,97 @@ mod tests { use super::*; + /// Send real egui key events to the presenter UI, without the audience window's handler. + #[test] + fn presenter_navigation_uses_its_own_keyboard_events() { + use egui_kittest::kittest::Queryable; + + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let show = Show::new(&doc, 0, false, true); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( + |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { + let now = ui.input(|i| i.time); + presenter_window(ui.ctx(), show, doc, now, keep); + }, + (doc, show, true), + ); + harness.run_steps(2); + for (key, slide) in [ + (egui::Key::ArrowRight, 1), + (egui::Key::ArrowLeft, 0), + (egui::Key::Space, 1), + (egui::Key::PageDown, 2), + (egui::Key::PageUp, 1), + (egui::Key::Home, 0), + (egui::Key::End, 2), + ] { + harness.key_press(key); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, slide, "presenter key {key:?}"); + } + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().1.ended); + harness.key_press(egui::Key::ArrowLeft); + harness.run_steps(2); + assert!(!harness.state().1.ended); + assert_eq!(harness.state().1.state.slide, 2, "back from the end screen restores the last slide"); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().1.ended); + harness.get_by_label("◀ Previous").click(); + harness.run_steps(2); + assert!(!harness.state().1.ended, "the presenter Previous button also leaves the end screen"); + harness.key_press(egui::Key::Home); + harness.run_steps(2); + harness.get_by_label("Next ▶").click(); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, 1); + harness.key_press(egui::Key::Space); + harness.run_steps(2); + assert_eq!(harness.state().1.state.slide, 2); + assert!(!harness.state().1.ended, "Space must not also activate the focused Next button"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(!harness.state().2, "Escape from Presenter View ends the show"); + } + + #[test] + fn slideshow_navigation_advances_once_with_presenter_view() { + use egui_kittest::kittest::Queryable; + + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + session.execute("slide.new", &json!({})).unwrap(); + let mut app = SlideApp::new(session, crate::Services::default()); + app.ui.presenter_view = true; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert_eq!(harness.state().show.as_ref().unwrap().state.slide, 1, "one key advances one slide"); + harness.key_press(egui::Key::End); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + assert!(harness.state().show.as_ref().unwrap().ended); + assert!(harness.query_by_label("End Show").is_some(), "the presenter remains available at the end screen"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(harness.state().show.is_none()); + } + #[test] fn a_transition_keeps_the_old_slide_in_its_own_texture() { // #33: the old slide's handle was cloned, so the new slide was rendered into the same diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fd981595..8b8cdcc2 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (keyboard navigation from Presenter View) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -39,6 +39,7 @@ ready** (estimated), **10–16 h** to parity. | Elapsed timer | yes | yes | | | Pause / reset timer, clock | yes | no | | | Slide counter, previous/next/end buttons | yes | yes | | +| Keyboard navigation while Presenter View has focus | yes | yes | Shares the audience window's key handling; handled keys are consumed before buttons, and the presenter stays available at the end screen | | Notes font size, scrolling | yes | scrolling only | | | Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Beta blocker, 3–5 h | | Swap displays | yes | no | | @@ -73,4 +74,5 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Presenter View handles slideshow keys in its own viewport, including returning from the end screen and Escape; regression tests cover navigation and avoiding duplicate button activation | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu | From 887ea1e7068eaadc85429bc267957975aff85acb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:34:17 +0900 Subject: [PATCH 303/376] Slide show: click-triggered animations, per-slide state and hit testing Make the slide show behave like PowerPoint for interactive decks. - Hit testing resolves the topmost visible shape under the pointer. Non-interactive shapes block shapes beneath them; the hand cursor shows only over interactive shapes. - Click-triggered animations (interactive sequences) play on shape click, repeat after their last step, and apply in click order so cycling "score ring" shapes work. - Each slide keeps its own progress: returning to a slide through a link or Back restores hidden shapes and animation state. - A shape with a link and an animation follows the link immediately; its animation clock only runs while the slide is shown and resumes with the remaining time on return. - With advance-on-click off, background clicks still play animation steps but never change slide. - A click on a playing step completes it at once. Docs (gaps, animation and slide show parity, roadmap) are updated. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/anim/src/interact.rs | 266 +++++++++++++++++++++ crates/anim/src/lib.rs | 9 +- crates/anim/src/show.rs | 56 ++++- crates/anim/src/tests_interact.rs | 364 +++++++++++++++++++++++++++++ crates/anim/src/timeline.rs | 36 ++- crates/engine/Cargo.toml | 3 + crates/engine/tests/show_clicks.rs | 236 +++++++++++++++++++ crates/render/src/lib.rs | 2 +- crates/render/src/placed.rs | 53 ++++- crates/render/src/tests.rs | 51 ++++ crates/ui-egui/src/show.rs | 153 ++++++++++-- docs/animation-parity.md | 5 +- docs/gaps.md | 17 +- docs/slideshow-parity.md | 6 +- 16 files changed, 1210 insertions(+), 50 deletions(-) create mode 100644 crates/anim/src/interact.rs create mode 100644 crates/anim/src/tests_interact.rs create mode 100644 crates/engine/tests/show_clicks.rs diff --git a/Cargo.lock b/Cargo.lock index 4044fce6..84372937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,7 @@ dependencies = [ name = "deckcraft-engine" version = "0.6.1" dependencies = [ + "deckcraft-anim", "deckcraft-color", "deckcraft-fonts", "deckcraft-format", diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..719a0dbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | +| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | @@ -151,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | Progress log: wild text cases | +| 2026-10-13 | trivial | Progress log: slide show for interactive decks | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | diff --git a/crates/anim/src/interact.rs b/crates/anim/src/interact.rs new file mode 100644 index 00000000..e32d980e --- /dev/null +++ b/crates/anim/src/interact.rs @@ -0,0 +1,266 @@ +//! Clicks in a slide show: which shape a click lands on, what it starts (a triggered animation, a +//! link, the next step) and what the show remembers about each slide it has visited. +//! +//! The rules follow PowerPoint, as observed on its slide show: +//! - the topmost visible shape under the pointer decides; a shape without an action on top of one +//! with an action blocks it (the click then counts as a click on the background); +//! - a shape with a triggered animation plays the next step of it per click, and starts over once all +//! its steps have played; clicking it while a step plays completes the step at once; +//! - a shape with a link and a triggered animation follows the link at once. The animation's clock +//! runs only while its slide is shown, so it goes on where it stopped when the show returns; +//! - with "advance on click" off a click never leaves the slide, but it still plays animation steps; +//! - a slide the show returns to (link or arrow keys) looks as it was left. + +use deckcraft_model::text::Action; +use deckcraft_model::{Presentation, ShapeId}; + +use crate::{AnimState, LinkJump, ShowAction, ShowState, StepStamp, Timeline, TriggerPlay}; + +/// What was played on one slide. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct SlideRun { + /// The show has been on this slide. + pub visited: bool, + /// Main-sequence steps played when the show left the slide. + pub step: usize, + /// Triggered sequences started by clicks. + pub triggers: Vec, + /// Steps of triggered sequences started so far (the stamps of [`StepStamp`]). + pub clock: u64, +} + +/// Progress of one triggered sequence. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TriggerRun { + pub shape: ShapeId, + /// Clicks whose step has played to its end. + pub done: usize, + /// Seconds the current step has played; `None` when no step is playing. + pub elapsed: Option, + /// When each step of this run was started, by step. + pub stamps: Vec, +} + +/// One shape of the slide for hit testing, in drawing order (back to front; a group before its +/// children), with whether the pointer is inside its box. +#[derive(Clone, Debug, PartialEq)] +pub struct HitShape { + pub id: ShapeId, + /// Index of the enclosing group in the same list. + pub parent: Option, + pub is_group: bool, + /// The shape's own action. + pub link: Option, + pub under_pointer: bool, +} + +/// What the pointer is over: a shape that does something when clicked. +#[derive(Clone, Debug, PartialEq)] +pub struct Target { + pub shape: ShapeId, + pub link: Option, +} + +/// What a click did. +#[derive(Clone, Debug, PartialEq)] +pub enum Click { + /// Nothing happened. + None, + /// A triggered animation started or was completed: redraw. + Animated, + /// Navigation inside the show (already applied to the state). + Show(ShowAction), + /// Open this URL outside the show. + Open(String), + /// End the show. + Exit, +} + +impl From for Click { + fn from(j: LinkJump) -> Click { + match j { + LinkJump::Show(a) => Click::Show(a), + LinkJump::Open(u) => Click::Open(u), + LinkJump::Exit => Click::Exit, + LinkJump::None => Click::None, + } + } +} + +fn finite_or_zero(v: f64) -> f64 { + if v.is_finite() { v } else { 0.0 } +} + +impl ShowState { + fn animated(&self, pres: &Presentation) -> bool { + !pres.show.without_animation + } + + /// Does clicking `shape` start a triggered sequence? A sequence can be started again once it has + /// played to its end. + fn trigger_live(&self, pres: &Presentation, tl: &Timeline, shape: ShapeId) -> bool { + self.animated(pres) && tl.trigger_steps(shape) > 0 + } + + /// The playheads of the current slide's triggered sequences at the last tick, for + /// [`Timeline::state_with`]. + pub fn trigger_plays(&self) -> Vec { + match self.runs.get(&self.slide) { + Some(r) => r.triggers.iter().map(|t| TriggerPlay { trigger: t.shape, step: t.done, t: t.elapsed.unwrap_or(0.0) }).collect(), + None => vec![], + } + } + + /// When the steps of the current slide's triggered sequences were started. + pub fn trigger_stamps(&self) -> Vec { + let Some(r) = self.runs.get(&self.slide) else { return vec![] }; + r.triggers.iter().flat_map(|t| t.stamps.iter().enumerate().map(|(step, &at)| StepStamp { trigger: t.shape, step, at })).collect() + } + + /// Is a triggered animation of the current slide playing (so the picture changes every frame)? + pub fn trigger_playing(&self) -> bool { + self.runs.get(&self.slide).is_some_and(|r| r.triggers.iter().any(|t| t.elapsed.is_some())) + } + + /// Animation state of `shape` on the current slide: main step `step` with `t_main` seconds + /// played, plus the triggered sequences. + pub fn anim_state(&self, tl: &Timeline, shape: ShapeId, t_main: f64) -> AnimState { + tl.state_ordered(shape, None, self.step, t_main, &self.trigger_plays(), &self.trigger_stamps()) + } + + /// Advance the clock of the playing triggered animations to `now` (seconds, any steady clock) + /// and complete the steps that reached their end. [`Click::Animated`] when the picture changed. + pub fn tick(&mut self, tl: &Timeline, now: f64) -> Click { + let dt = match self.last_now { + Some(last) if now.is_finite() => (now - last).clamp(0.0, 3600.0), + _ => 0.0, + }; + if now.is_finite() { + self.last_now = Some(now); + } + let Some(run) = self.runs.get_mut(&self.slide) else { return Click::None }; + let mut changed = false; + for t in run.triggers.iter_mut() { + let Some(e) = t.elapsed else { continue }; + let e = finite_or_zero(e) + dt; + if e >= tl.trigger_step_duration(t.shape, t.done) { + t.done += 1; + t.elapsed = None; + changed = true; + } else { + t.elapsed = Some(e); + } + } + if changed { Click::Animated } else { Click::None } + } + + /// Index of the topmost shape a click lands on: the front-most one under the pointer that is + /// shown. A group without an action does not count; its children and the space inside its box + /// are not it. + pub fn top(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option { + let shown = |mut i: usize| -> bool { + // A shape is shown when it and the groups around it are. + for _ in 0..=shapes.len() { + let Some(h) = shapes.get(i) else { return false }; + if !visible(h.id) { + return false; + } + match h.parent { + Some(p) => i = p, + None => return true, + } + } + false + }; + (0..shapes.len()) + .rev() + .find(|&i| shapes.get(i).is_some_and(|h| h.under_pointer && !(h.is_group && !self.interactive(pres, tl, h)) && shown(i))) + } + + fn interactive(&self, pres: &Presentation, tl: &Timeline, h: &HitShape) -> bool { + h.link.is_some() || self.trigger_live(pres, tl, h.id) + } + + /// The shape under the pointer that a click would act on, `None` when a click would be a click + /// on the background. `visible` tells whether a shape is currently shown (not yet entered or + /// already exited shapes cannot be clicked). + pub fn target(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option { + // The topmost shape, or the group it is in, that has an action. A shape without one blocks + // what lies beneath it. + let mut at = self.top(pres, tl, shapes, visible); + for _ in 0..=shapes.len() { + let h = shapes.get(at?)?; + if self.interactive(pres, tl, h) { + return Some(Target { shape: h.id, link: h.link.clone() }); + } + at = h.parent; + } + None + } + + /// A mouse click. `shapes` and `visible` describe what is under the pointer (see + /// [`ShowState::target`]); `last_viewed` is the slide shown before this one. + pub fn click( + &mut self, + pres: &Presentation, + tl: &Timeline, + shapes: &[HitShape], + visible: &dyn Fn(ShapeId) -> bool, + now: f64, + last_viewed: Option, + ) -> Click { + self.tick(tl, now); + if self.ended { + return Click::None; + } + let Some(t) = self.target(pres, tl, shapes, visible) else { return self.background_click(pres) }; + let mut animated = false; + if self.trigger_live(pres, tl, t.shape) { + let slide = self.slide; + let run = self.runs.entry(slide).or_default(); + let at = match run.triggers.iter().position(|r| r.shape == t.shape) { + Some(p) => p, + None => { + run.triggers.push(TriggerRun { shape: t.shape, done: 0, elapsed: None, stamps: vec![] }); + run.triggers.len() - 1 + } + }; + let stamp = run.clock.saturating_add(1); + if let Some(r) = run.triggers.get_mut(at) { + if r.elapsed.is_some() { + // A click on a shape whose step is playing completes the step. + r.done += 1; + r.elapsed = None; + } else { + if r.done >= tl.trigger_steps(t.shape) { + // All steps have played: the sequence starts over. + r.done = 0; + r.stamps.clear(); + } + r.stamps.push(stamp); + run.clock = stamp; + if tl.trigger_step_duration(t.shape, r.done) > 0.0 { + r.elapsed = Some(0.0); + } else { + r.done += 1; + } + } + animated = true; + } + } + match t.link { + // The link is followed at once, whatever the animation still has to play. + Some(a) => self.follow(pres, &a, last_viewed).into(), + None if animated => Click::Animated, + None => Click::None, + } + } + + /// A click that is on no shape with an action: next animation step, then next slide, unless the + /// slide has "advance on click" off, which leaves only the animation steps. + fn background_click(&mut self, pres: &Presentation) -> Click { + let advance = pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).is_none_or(|t| t.advance_on_click); + let steps_left = self.playing || self.step < ShowState::steps(pres, self.slide); + if advance || steps_left { Click::Show(self.next(pres)) } else { Click::None } + } +} diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index d94167ca..8522a774 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -5,7 +5,8 @@ //! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images. //! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by //! [`morph_xfrm`]). -//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings). +//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings), what was +//! played on each visited slide, and what a click does ([`ShowState::click`]). //! //! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or //! negative times) and never panics. @@ -14,6 +15,7 @@ mod easing; mod effects; +mod interact; mod mesh; mod morph; mod path; @@ -26,17 +28,20 @@ mod transition_geom; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_interact; +#[cfg(test)] mod transition_tests; pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; +pub use interact::{Click, HitShape, Target}; pub use mesh::{MeshVertex, layer_mesh}; pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; -pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; +pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay}; pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts}; /// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show. diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index dbc0a8ae..4fc0d5e1 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -1,10 +1,13 @@ //! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop //! and automatic advance. +use std::collections::BTreeMap; + use deckcraft_model::Presentation; use deckcraft_model::text::Action; use crate::Timeline; +use crate::interact::SlideRun; /// What the UI should do after a navigation call. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -52,8 +55,10 @@ pub enum LinkJump { None, } -/// Where a slide show is. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +/// Where a slide show is. Besides the current position it remembers what was played on every slide +/// the show has visited (main steps, triggered sequences), so a slide reached again by a link or +/// the arrow keys looks the way it was left, as in PowerPoint. +#[derive(Clone, Debug, PartialEq, Default)] pub struct ShowState { /// Index into `presentation.slides`. pub slide: usize, @@ -63,6 +68,12 @@ pub struct ShowState { pub playing: bool, /// On the end screen. pub ended: bool, + /// The show has entered a slide (so `slide` and `step` are meaningful and can be saved). + active: bool, + /// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks. + pub(crate) last_now: Option, + /// What was played on each visited slide (index into `presentation.slides`). + pub(crate) runs: BTreeMap, } /// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus @@ -120,11 +131,37 @@ impl ShowState { if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() } } + /// Save what was played on the current slide before the show moves away from it. A main step + /// that is playing is taken to its end. Triggered animations keep their place: their clock only + /// runs while the slide is shown, and they go on from there when the show comes back. + fn leave(&mut self, pres: &Presentation) { + if !self.active { + return; + } + let done = if self.playing { self.step + 1 } else { self.step }; + let step = done.min(ShowState::steps(pres, self.slide)); + self.runs.entry(self.slide).or_default().step = step; + self.playing = false; + } + + /// Show slide `i`: as it was left when the show has been there before, otherwise from the start. fn enter(&mut self, pres: &Presentation, i: usize) { + self.leave(pres); + self.active = true; self.slide = i; - self.step = 0; self.ended = false; - self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0); + // Time spent elsewhere never counts for this slide's triggered animations. + self.last_now = None; + let steps = ShowState::steps(pres, i); + let run = self.runs.entry(i).or_default(); + if run.visited { + self.step = run.step.min(steps); + self.playing = false; + } else { + run.visited = true; + self.step = 0; + self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0); + } } fn next_index(&self, pres: &Presentation) -> Option { @@ -220,9 +257,14 @@ impl ShowState { }; match target { Some(i) => { - self.slide = i; - self.step = ShowState::steps(pres, i); - self.playing = false; + // A slide the show has been to looks as it was left; one it has not been to is + // shown fully built. + let seen = self.runs.get(&i).is_some_and(|r| r.visited); + self.enter(pres, i); + if !seen { + self.step = ShowState::steps(pres, i); + self.playing = false; + } ShowAction::Slide(i) } None => ShowAction::None, diff --git a/crates/anim/src/tests_interact.rs b/crates/anim/src/tests_interact.rs new file mode 100644 index 00000000..5f12dda5 --- /dev/null +++ b/crates/anim/src/tests_interact.rs @@ -0,0 +1,364 @@ +//! Clicks, triggered animations and what the show remembers about visited slides. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use deckcraft_model::text::Action; +use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm}; + +use crate::*; + +fn shape(id: u32) -> Shape { + Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() } +} + +/// A triggered exit (`Disappear` when `ms` is 0, else a fade out) of `target`, started by clicking `trigger`. +fn exit_on_click(trigger: u32, target: u32, ms: u32) -> Animation { + let effect = if ms == 0 { "disappear" } else { "fadeOut" }; + Animation { + shape: ShapeId(target), + class: AnimClass::Exit, + effect: effect.into(), + start: AnimStart::OnClick, + duration_ms: ms, + trigger: Some(ShapeId(trigger)), + ..Default::default() + } +} + +fn slide(id: u32, shapes: Vec, animations: Vec, advance_on_click: bool) -> Arc { + let transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() }); + Arc::new(Slide { id: SlideId(id), shapes, animations, transition, ..Default::default() }) +} + +fn to_slide(n: u32) -> Option { + Some(Action::Slide { slide: SlideId(n) }) +} + +fn hit(id: u32, link: Option) -> HitShape { + HitShape { id: ShapeId(id), parent: None, is_group: false, link, under_pointer: true } +} + +/// Test driver: a show plus the click helpers the slide show window uses. +struct Run { + pres: Presentation, + show: ShowState, + /// Hit shapes of each slide, back to front. + hits: Vec>, + now: f64, +} + +impl Run { + fn new(pres: Presentation, hits: Vec>) -> Run { + let show = ShowState::new(&pres); + Run { pres, show, hits, now: 0.0 } + } + + fn tl(&self) -> Timeline { + ShowState::timeline(&self.pres, self.show.slide) + } + + fn visible(&self, id: ShapeId) -> bool { + let st = self.show.anim_state(&self.tl(), id, 0.0); + st.visible && st.opacity > 0.0 + } + + fn opacity(&self, id: u32) -> f64 { + self.show.anim_state(&self.tl(), ShapeId(id), 0.0).opacity + } + + fn click(&mut self) -> Click { + let (tl, hits) = (self.tl(), self.hits[self.show.slide].clone()); + let vis: Vec<(ShapeId, bool)> = hits.iter().map(|h| (h.id, self.visible(h.id))).collect(); + let visible = move |id: ShapeId| vis.iter().find(|v| v.0 == id).is_none_or(|v| v.1); + self.show.click(&self.pres, &tl, &hits, &visible, self.now, None) + } + + fn target(&self) -> Option { + let (tl, hits) = (self.tl(), &self.hits[self.show.slide]); + self.show.target(&self.pres, &tl, hits, &|id| self.visible(id)) + } + + /// Let `secs` pass on the shown slide, in small frames. + fn wait(&mut self, secs: f64) { + let end = self.now + secs; + while self.now < end - 1e-9 { + self.now = (self.now + 0.05).min(end); + let tl = self.tl(); + self.show.tick(&tl, self.now); + } + } +} + +/// The test deck: slide 1 has a text box (3), a linked button that also disappears (2) and a +/// picture on top of both that disappears (4); slide 2 has a button back to slide 1. +fn two_slides(advance_on_click: bool) -> Run { + let s1 = slide(1000, vec![shape(3), shape(2), shape(4)], vec![exit_on_click(4, 4, 0), exit_on_click(2, 2, 0)], advance_on_click); + let s2 = slide(1001, vec![shape(1)], vec![], advance_on_click); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + Run::new(pres, vec![vec![hit(3, None), hit(2, to_slide(1001)), hit(4, None)], vec![hit(1, to_slide(1000))]]) +} + +#[test] +fn hidden_shapes_stay_hidden_when_the_show_comes_back() { + let mut r = two_slides(false); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(4)), "the picture is on top"); + // Click the picture: it disappears; the button beneath it is now the target. + assert_eq!(r.click(), Click::Animated); + assert!(!r.visible(ShapeId(4))); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(2))); + // Click the button: its animation starts and the link is followed. + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); + assert_eq!(r.show.slide, 1); + // Slide 2: the button goes back. + assert_eq!(r.click(), Click::Show(ShowAction::Slide(0))); + assert_eq!(r.show.slide, 0); + // Both are still gone, and nothing on the slide can be clicked any more. + assert!(!r.visible(ShapeId(4)) && !r.visible(ShapeId(2)) && r.visible(ShapeId(3))); + assert_eq!(r.target(), None); + // "Advance on click" is off: a click anywhere does nothing, however often. + for _ in 0..3 { + assert_eq!(r.click(), Click::None); + } + assert_eq!(r.show.slide, 0); +} + +#[test] +fn background_click_advances_when_the_slide_allows_it() { + let mut r = two_slides(true); + r.click(); // the picture disappears + r.click(); // the button disappears and links to slide 2 + r.click(); // the button there links back + assert_eq!(r.show.slide, 0); + assert_eq!(r.target(), None); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); +} + +#[test] +fn advance_on_click_off_still_plays_animation_steps() { + let main = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() }; + let s1 = slide(1000, vec![shape(1)], vec![main], false); + let s2 = slide(1001, vec![], vec![], false); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(1, None)], vec![]]); + // The first click plays the step, the next finishes it; then the slide stays. + assert!(matches!(r.click(), Click::Show(ShowAction::PlayStep(0)))); + assert!(matches!(r.click(), Click::Show(ShowAction::FinishStep(0)))); + assert_eq!(r.click(), Click::None); + assert_eq!(r.show.slide, 0); + // The arrow keys still move on. + assert_eq!(r.show.next(&r.pres), ShowAction::Slide(1)); +} + +#[test] +fn a_shape_without_an_action_blocks_the_one_beneath_it() { + let s = slide(1000, vec![shape(1), shape(2)], vec![], true); + let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(1, to_slide(1001)), hit(2, None)], vec![]]); + assert_eq!(r.target(), None, "the plain shape on top blocks the button"); + // So the click is a background click, not the button's link. + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); + // Reversed, the button is on top and is the target. + let s = slide(1000, vec![shape(2), shape(1)], vec![], true); + let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() }; + let r = Run::new(pres, vec![vec![hit(2, None), hit(1, to_slide(1001))], vec![]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1))); +} + +#[test] +fn groups_answer_for_their_children() { + let s = slide(1000, vec![shape(1)], vec![], true); + let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() }; + let group = |link| HitShape { id: ShapeId(10), parent: None, is_group: true, link, under_pointer: true }; + let child = HitShape { id: ShapeId(11), parent: Some(1), is_group: false, link: None, under_pointer: true }; + // A child of a group with an action acts as the group. + let r = Run::new(pres.clone(), vec![vec![hit(5, None), group(to_slide(1001)), child.clone()], vec![]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(10))); + // Empty space inside the box of a group without an action blocks nothing. + let behind = hit(5, to_slide(1001)); + let r = Run::new(pres, vec![vec![behind, group(None)], vec![]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(5))); +} + +#[test] +fn pointer_outside_a_shape_is_not_on_it() { + let s = slide(1000, vec![shape(1)], vec![], true); + let pres = Presentation { slides: vec![s], ..Default::default() }; + let mut h = hit(1, to_slide(1000)); + h.under_pointer = false; + let r = Run::new(pres, vec![vec![h]]); + assert_eq!(r.target(), None); +} + +#[test] +fn each_click_plays_one_step_of_a_trigger_and_a_click_completes_a_playing_step() { + // Clicking shape 1 fades shape 2 out over 5 s, then shape 3 over 5 s. + let s = slide(1000, vec![shape(1), shape(2), shape(3)], vec![exit_on_click(1, 2, 5000), exit_on_click(1, 3, 5000)], true); + let pres = Presentation { slides: vec![s], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(2, None), hit(3, None), hit(1, None)]]); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1))); + assert_eq!(r.click(), Click::Animated); + r.wait(2.0); + assert!(r.show.trigger_playing()); + assert!(r.opacity(2) < 1.0 && r.opacity(2) > 0.0, "part way through the fade"); + assert!(r.visible(ShapeId(3))); + // A click on the shape while its step plays completes the step at once... + assert_eq!(r.click(), Click::Animated); + assert!(!r.visible(ShapeId(2)) && !r.show.trigger_playing()); + // ...and the next click plays the next step. + assert_eq!(r.click(), Click::Animated); + r.wait(5.2); + assert!(!r.visible(ShapeId(3))); + // All steps have played, and the shape still answers: its sequence starts over. + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1))); + assert_eq!(r.click(), Click::Animated); + assert!(r.visible(ShapeId(2)) && r.visible(ShapeId(3)), "the first lap's effects are undone"); + r.wait(5.2); + assert!(!r.visible(ShapeId(2)) && r.visible(ShapeId(3))); +} + +#[test] +fn a_ring_of_triggers_goes_round_and_round() { + // Soccer's score: clicking shape k hides it and shows shape k+1; the last shows the first again. + let ring = 4u32; + let anims: Vec = (1..=ring) + .flat_map(|k| { + let next = k % ring + 1; + let show = Animation { + shape: ShapeId(next), + class: AnimClass::Entrance, + effect: "appear".into(), + start: AnimStart::WithPrevious, + duration_ms: 0, + trigger: Some(ShapeId(k)), + ..Default::default() + }; + [exit_on_click(k, k, 0), show] + }) + .collect(); + let shapes: Vec = (1..=ring).map(shape).collect(); + let s = slide(1000, shapes, anims, true); + let pres = Presentation { slides: vec![s], ..Default::default() }; + let hits: Vec = (1..=ring).map(|k| hit(k, None)).collect(); + let mut r = Run::new(pres, vec![hits]); + let showing = |r: &Run| -> Vec { (1..=ring).filter(|k| r.visible(ShapeId(*k))).collect() }; + assert_eq!(showing(&r), vec![1]); + for lap in 0..3 { + for k in 1..=ring { + assert_eq!(showing(&r), vec![k], "lap {lap}, before click {k}"); + assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(k))); + assert_eq!(r.click(), Click::Animated); + } + } + assert_eq!(showing(&r), vec![1]); +} + +#[test] +fn a_link_is_followed_at_once_and_the_animation_goes_on_where_it_stopped() { + // Shape 1 fades shape 4 over 5 s; shape 2 links to slide 2. + let s1 = slide(1000, vec![shape(1), shape(2), shape(4)], vec![exit_on_click(1, 4, 5000)], false); + let s2 = slide(1001, vec![shape(5)], vec![], false); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(4, None), hit(2, to_slide(1001)), hit(1, None)], vec![hit(5, to_slide(1000))]]); + // Click the animated shape, wait 2 s, click the link. + r.click(); + r.wait(2.0); + assert_eq!(r.show.trigger_plays()[0].t.round(), 2.0); + // The second shape is not over the first one, so click the link directly. + let tl = r.tl(); + let hits = vec![hit(2, to_slide(1001))]; + assert_eq!(r.show.click(&r.pres, &tl, &hits, &|_| true, r.now, None), Click::Show(ShowAction::Slide(1))); + // However long the show stays away, the animation does not run. + r.wait(60.0); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(0))); + // Back on the slide it has 3 s left. + assert!((r.show.trigger_plays()[0].t - 2.0).abs() < 0.1); + assert!(r.show.trigger_playing()); + r.wait(2.5); + assert!(r.show.trigger_playing() && r.opacity(4) > 0.0); + r.wait(1.0); + assert!(!r.show.trigger_playing() && !r.visible(ShapeId(4))); +} + +#[test] +fn a_shape_with_a_link_and_an_animation_follows_the_link_without_waiting() { + let s1 = slide(1000, vec![shape(2)], vec![exit_on_click(2, 2, 5000)], false); + let s2 = slide(1001, vec![shape(5)], vec![], false); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut r = Run::new(pres, vec![vec![hit(2, to_slide(1001))], vec![hit(5, to_slide(1000))]]); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(1))); + r.wait(30.0); + assert_eq!(r.click(), Click::Show(ShowAction::Slide(0))); + // The whole animation is still ahead. + assert!(r.visible(ShapeId(2)) && r.opacity(2) > 0.95); + assert!(r.show.trigger_playing()); + r.wait(5.5); + assert!(!r.visible(ShapeId(2))); +} + +#[test] +fn a_slide_comes_back_the_way_it_was_left() { + let steps: Vec = (1..=3) + .map(|i| Animation { shape: ShapeId(i), class: AnimClass::Entrance, effect: "appear".into(), duration_ms: 0, ..Default::default() }) + .collect(); + let s1 = slide(1000, vec![shape(1), shape(2), shape(3)], steps, true); + let s2 = slide(1001, vec![], vec![], true); + let s3 = slide(1002, vec![], vec![], true); + let pres = Presentation { slides: vec![s1, s2, s3], ..Default::default() }; + let mut s = ShowState::new(&pres); + s.next(&pres); + s.step_done(&pres); + assert_eq!(s.step, 1); + // A link away and back: still after step 1. + assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2))); + assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1000) }, None), LinkJump::Show(ShowAction::Slide(0))); + assert_eq!((s.step, s.playing), (1, false)); + // So does the back arrow. + s.goto(&pres, 1); + assert_eq!(s.prev(&pres), ShowAction::Slide(0)); + assert_eq!(s.step, 1); + // A slide the show has not been to is entered at its start; going back to one shows it fully + // built. + let mut s = ShowState::new(&pres); + s.goto(&pres, 2); + assert_eq!(s.prev(&pres), ShowAction::Slide(1)); + assert_eq!(s.step, ShowState::steps(&pres, 1)); +} + +#[test] +fn a_playing_main_step_is_finished_when_the_show_leaves() { + let a = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() }; + let s1 = slide(1000, vec![shape(1)], vec![a], true); + let s2 = slide(1001, vec![], vec![], true); + let pres = Presentation { slides: vec![s1, s2], ..Default::default() }; + let mut s = ShowState::new(&pres); + assert_eq!(s.next(&pres), ShowAction::PlayStep(0)); + s.goto(&pres, 1); + s.goto(&pres, 0); + assert_eq!((s.step, s.playing), (1, false)); +} + +#[test] +fn show_without_animation_ignores_triggers() { + let s = slide(1000, vec![shape(1), shape(2)], vec![exit_on_click(1, 2, 0)], true); + let mut pres = Presentation { slides: vec![s], ..Default::default() }; + pres.show.without_animation = true; + let r = Run::new(pres, vec![vec![hit(2, None), hit(1, None)]]); + assert_eq!(r.target(), None); +} + +#[test] +fn clocks_and_pointers_with_hostile_numbers_are_harmless() { + let mut r = two_slides(true); + let tl = r.tl(); + for now in [f64::NAN, f64::INFINITY, -1.0e300, 1.0e300, 0.0] { + r.show.tick(&tl, now); + let _ = r.show.click(&r.pres, &tl, &[], &|_| true, now, None); + } + // A parent index that points nowhere, or in a circle, is not followed forever. + let bad = vec![ + HitShape { id: ShapeId(1), parent: Some(7), is_group: true, link: None, under_pointer: true }, + HitShape { id: ShapeId(2), parent: Some(2), is_group: false, link: None, under_pointer: true }, + ]; + assert_eq!(r.show.target(&r.pres, &tl, &bad, &|_| true), None); +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index e73acfd9..dcb55484 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -57,6 +57,16 @@ pub struct TriggerPlay { pub t: f64, } +/// When a step of a triggered sequence was started, counted in started steps on the slide. Effects +/// of different triggers apply in this order, so that a shape hidden by one trigger and shown again +/// by another ends up as the later of the two left it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StepStamp { + pub trigger: ShapeId, + pub step: usize, + pub at: u64, +} + #[derive(Clone, Debug, Default)] struct StepInfo { auto: bool, @@ -379,6 +389,21 @@ impl Timeline { /// [`Timeline::state`] with the playheads of triggered sequences. pub fn state_with(&self, shape: ShapeId, para: Option, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState { + self.state_ordered(shape, para, step, t, triggers, &[]) + } + + /// [`Timeline::state_with`] applying the effects of triggered sequences in the order their steps + /// were started (`stamps`), after those of the main sequence. Without stamps they apply in + /// Animation Pane order. + pub fn state_ordered( + &self, + shape: ShapeId, + para: Option, + step: usize, + t: f64, + triggers: &[TriggerPlay], + stamps: &[StepStamp], + ) -> AnimState { let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p)); let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para); let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); @@ -391,7 +416,16 @@ impl Timeline { { st.visible = false; } - // Then play in time order: main sequence first, then triggered sequences. + // Then play in time order: main sequence first, then triggered sequences as they were clicked. + if !stamps.is_empty() { + let when = |e: &Effect| match e.timing.sequence { + Sequence::Main => 0, + Sequence::Trigger(tr) => { + stamps.iter().find(|s| s.trigger == tr && s.step == e.timing.step).map_or(u64::MAX, |s| s.at.saturating_add(1)) + } + }; + ordered.sort_by_key(|e| (when(e), e.timing.anim)); + } for e in &ordered { let (pstep, pt) = match e.timing.sequence { Sequence::Main => (step, t), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1f4dbfcd..70479e44 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,3 +25,6 @@ i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } + +[dev-dependencies] +deckcraft-anim = { workspace = true } diff --git a/crates/engine/tests/show_clicks.rs b/crates/engine/tests/show_clicks.rs new file mode 100644 index 00000000..6fa7d1a0 --- /dev/null +++ b/crates/engine/tests/show_clicks.rs @@ -0,0 +1,236 @@ +//! Slide show clicks on decks that went through a PPTX file: links, click-triggered animations and +//! what a slide looks like when the show comes back to it. + +use std::sync::Arc; + +use deckcraft_anim::{Click, HitShape, ShowAction, ShowState}; +use deckcraft_geom::Xfrm; +use deckcraft_model::anim::{AnimClass, AnimStart, Animation, Transition}; +use deckcraft_model::text::{Action, Hyperlink, TextBody}; +use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, SlideId, defaults}; + +fn shape(p: &mut Presentation, name: &str, text: &str, x: f64, y: f64, w: f64, h: f64) -> Shape { + Shape { + id: ShapeId(p.alloc_id()), + name: name.into(), + xfrm: Some(Xfrm::new(x, y, w, h)), + text: Some(TextBody::from_text(text)), + ..Default::default() + } +} + +fn link_to(slide: SlideId) -> Option { + Some(Hyperlink { action: Action::Slide { slide }, tooltip: String::new(), highlight_click: false }) +} + +/// Clicking `trigger` makes `target` disappear. +fn disappear_on_click(trigger: ShapeId, target: ShapeId) -> Animation { + Animation { + shape: target, + class: AnimClass::Exit, + effect: "disappear".into(), + start: AnimStart::OnClick, + duration_ms: 0, + trigger: Some(trigger), + ..Default::default() + } +} + +fn add_slide(p: &mut Presentation, shapes: Vec, animations: Vec, advance_on_click: bool) -> SlideId { + let layout = defaults::layout_of_kind(p, LayoutType::Blank).unwrap_or_default(); + let mut s = defaults::new_slide(p, layout); + s.shapes = shapes; + s.animations = animations; + s.transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() }); + let id = s.id; + p.slides.push(Arc::new(s)); + id +} + +/// Drops the slide `new_presentation` starts with. +fn start(p: &mut Presentation) { + p.slides.clear(); +} + +/// The slide show window's click handling, on a deck read from a PPTX file. +struct Show { + pres: Presentation, + state: ShowState, +} + +impl Show { + fn open(p: &Presentation) -> Show { + let bytes = deckcraft_pptx::export(p).expect("export"); + let pres = deckcraft_pptx::import(&bytes).expect("import"); + let state = ShowState::new(&pres); + Show { pres, state } + } + + fn hits(&self, x: f64, y: f64) -> (Vec, Vec) { + let placed = deckcraft_render::place_slide(&self.pres, self.state.slide); + let tl = ShowState::timeline(&self.pres, self.state.slide); + let hits = placed + .shapes + .iter() + .map(|s| HitShape { + id: s.id, + parent: s.parent, + is_group: s.is_group, + link: s.link.as_ref().map(|l| l.action.clone()), + under_pointer: s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1, + }) + .collect(); + let visible = placed + .shapes + .iter() + .map(|s| { + let a = self.state.anim_state(&tl, s.id, 0.0); + a.visible && a.opacity > 0.0 + }) + .collect(); + (hits, visible) + } + + /// Click the middle of the shape called `name` on the current slide. + fn click(&mut self, name: &str) -> Click { + let (x, y) = self.centre(name); + let (hits, vis) = self.hits(x, y); + let tl = ShowState::timeline(&self.pres, self.state.slide); + let visible = |id: ShapeId| hits.iter().zip(&vis).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v); + self.state.click(&self.pres, &tl, &hits, &visible, 0.0, None) + } + + fn centre(&self, name: &str) -> (f64, f64) { + let slide = &self.pres.slides[self.state.slide]; + let s = slide.shapes.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("no shape {name}")); + let x = s.xfrm.expect("xfrm"); + (x.x + x.w / 2.0, x.y + x.h / 2.0) + } + + fn shown(&self, name: &str) -> bool { + let slide = &self.pres.slides[self.state.slide]; + let Some(s) = slide.shapes.iter().find(|s| s.name == name) else { return false }; + let a = self.state.anim_state(&ShowState::timeline(&self.pres, self.state.slide), s.id, 0.0); + a.visible && a.opacity > 0.0 + } + + fn is_target(&self, name: &str) -> bool { + let (x, y) = self.centre(name); + let (hits, vis) = self.hits(x, y); + let tl = ShowState::timeline(&self.pres, self.state.slide); + let visible = |id: ShapeId| hits.iter().zip(&vis).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v); + let id = self.pres.slides[self.state.slide].shapes.iter().find(|s| s.name == name).map(|s| s.id); + self.state.target(&self.pres, &tl, &hits, &visible).map(|t| t.shape) == id + } +} + +/// An image that disappears when clicked, revealing a button that links to slide 2 and disappears +/// as well; slide 2 links back; the slides do not advance on a click. +fn hide_and_reveal_deck() -> Presentation { + let mut p = defaults::new_presentation(None); + start(&mut p); + let s2_id = SlideId(p.alloc_id()); + let _ = s2_id; + let message = shape(&mut p, "Message", "Both hidden", 100.0, 100.0, 600.0, 300.0); + let mut button = shape(&mut p, "Button", "Image hidden", 100.0, 100.0, 600.0, 300.0); + let picture = shape(&mut p, "Picture", "Picture", 100.0, 100.0, 600.0, 300.0); + let animations = vec![disappear_on_click(picture.id, picture.id), disappear_on_click(button.id, button.id)]; + let first = add_slide(&mut p, vec![message, button.clone(), picture], animations, false); + let mut back = shape(&mut p, "Back", "Go back", 100.0, 100.0, 600.0, 300.0); + back.click = link_to(first); + let second = add_slide(&mut p, vec![back], vec![], false); + // The button links to slide 2. + let s = Arc::make_mut(&mut p.slides[0]); + button.click = link_to(second); + if let Some(b) = s.shapes.iter_mut().find(|x| x.name == "Button") { + b.click = button.click.clone(); + } + p +} + +#[test] +fn hidden_shapes_stay_hidden_and_dead_when_the_show_comes_back() { + let mut show = Show::open(&hide_and_reveal_deck()); + assert!(show.is_target("Picture") && show.shown("Picture")); + assert_eq!(show.click("Picture"), Click::Animated); + assert!(!show.shown("Picture")); + // The button was under the picture; now it is on top. Its click follows the link. + assert!(show.is_target("Button")); + assert_eq!(show.click("Button"), Click::Show(ShowAction::Slide(1))); + assert_eq!(show.state.slide, 1); + assert_eq!(show.click("Back"), Click::Show(ShowAction::Slide(0))); + // Back on slide 1: both are gone, only the message is left, and it does nothing. + assert!(!show.shown("Picture") && !show.shown("Button") && show.shown("Message")); + assert!(!show.is_target("Message")); + for _ in 0..3 { + assert_eq!(show.click("Message"), Click::None); + } + assert_eq!(show.state.slide, 0); +} + +/// A board of three tiles, each linking to its question, which links back; the tile hides itself. +#[test] +fn a_game_board_hides_the_tiles_already_played() { + let mut p = defaults::new_presentation(None); + start(&mut p); + let mut tiles: Vec = (0..3).map(|i| shape(&mut p, &format!("Tile {i}"), "100", 50.0 + 250.0 * i as f64, 100.0, 200.0, 100.0)).collect(); + let animations: Vec = tiles.iter().map(|t| disappear_on_click(t.id, t.id)).collect(); + let board = add_slide(&mut p, tiles.clone(), animations, true); + let questions: Vec = (0..3) + .map(|i| { + let mut back = shape(&mut p, "Back", &format!("Question {i}"), 100.0, 100.0, 400.0, 200.0); + back.click = link_to(board); + add_slide(&mut p, vec![back], vec![], true) + }) + .collect(); + let s = Arc::make_mut(&mut p.slides[0]); + for (t, q) in tiles.iter_mut().zip(&questions) { + if let Some(x) = s.shapes.iter_mut().find(|x| x.id == t.id) { + x.click = link_to(*q); + } + } + let mut show = Show::open(&p); + for (i, played) in [1usize, 0, 2].into_iter().enumerate() { + assert_eq!(show.click(&format!("Tile {played}")), Click::Show(ShowAction::Slide(1 + played))); + assert_eq!(show.click("Back"), Click::Show(ShowAction::Slide(0))); + // Exactly the tiles played so far are gone. + let gone: Vec = (0..3).map(|t| !show.shown(&format!("Tile {t}"))).collect(); + assert_eq!(gone.iter().filter(|g| **g).count(), i + 1, "{gone:?}"); + assert!(gone[played]); + assert!(!show.is_target(&format!("Tile {played}"))); + } +} + +/// A score that cycles: each digit hides itself when clicked and shows the next; the last shows the +/// first again. Every digit has its own one-click trigger. +#[test] +fn a_score_ring_goes_round() { + let mut p = defaults::new_presentation(None); + start(&mut p); + let n = 4usize; + let digits: Vec = (0..n).map(|i| shape(&mut p, &format!("Score {i}"), &i.to_string(), 400.0, 100.0, 100.0, 100.0)).collect(); + let mut anims = vec![]; + for (i, d) in digits.iter().enumerate() { + anims.push(disappear_on_click(d.id, d.id)); + anims.push(Animation { + shape: digits[(i + 1) % n].id, + class: AnimClass::Entrance, + effect: "appear".into(), + start: AnimStart::WithPrevious, + duration_ms: 0, + trigger: Some(d.id), + ..Default::default() + }); + } + add_slide(&mut p, digits, anims, true); + let mut show = Show::open(&p); + let showing = |show: &Show| -> Vec { (0..n).filter(|i| show.shown(&format!("Score {i}"))).collect() }; + assert_eq!(showing(&show), vec![0]); + for click in 0..2 * n { + let now = click % n; + assert_eq!(showing(&show), vec![now], "before click {click}"); + assert!(show.is_target(&format!("Score {now}"))); + assert_eq!(show.click(&format!("Score {now}")), Click::Animated); + } + assert_eq!(showing(&show), vec![0]); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..d35ce74f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -49,7 +49,7 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; -pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; +pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_slide}; pub use table::row_heights as table_row_heights; pub use transition::{SlideParts, composite_transition, render_transition}; diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 0a0d4737..258bc592 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -3,7 +3,7 @@ use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Hyperlink, TextBody}; -use deckcraft_model::{Presentation, Shape, ShapeKind}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use deckcraft_text::{Opts, TextLayout}; use kurbo::{Affine, Rect}; @@ -22,12 +22,32 @@ pub struct PlacedLink { /// Slide-space bounding box. pub rect: Rect, pub link: Hyperlink, + /// The shape that owns the link (for a text hyperlink, the shape holding the text). + pub shape: ShapeId, + /// A hyperlink on text, not the shape's own action. + pub text: bool, +} + +/// A shape of the slide itself (not of its layout or master) with its slide-space bounding box: +/// what a click in a slide show lands on. +pub struct PlacedShape { + pub id: ShapeId, + /// Index into [`Placed::shapes`] of the group this shape is in. + pub parent: Option, + pub is_group: bool, + /// Slide-space bounding box. + pub rect: Rect, + /// The shape's own action (Insert ▸ Action), not its text hyperlinks. + pub link: Option, } #[derive(Default)] pub struct Placed { pub texts: Vec, pub links: Vec, + /// The slide's own shapes in drawing order (back to front; a group before its children). Hidden + /// shapes are left out. + pub shapes: Vec, } /// Text and links of slide `index` (master and layout graphics first, as rendered). @@ -40,22 +60,25 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed { if show_master { let mctx = Ctx::for_master(pres, rctx.master); for s in rctx.master.shapes.iter().filter(|s| s.ph.is_none()) { - place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0); + place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0, None, false); } } if show_layout && let Some(l) = rctx.layout { let lctx = Ctx::for_layout(pres, rctx.master, l); for s in l.shapes.iter().filter(|s| s.ph.is_none()) { - place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0); + place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0, None, false); } } for s in &slide.shapes { - place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0); + place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0, None, true); } out } -fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize) { +/// `group` is the index in `out.shapes` of the enclosing group; `own` is true for the slide's own +/// shapes (recorded in `out.shapes`), false for the layout's and master's. +#[allow(clippy::too_many_arguments)] +fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize, group: Option, own: bool) { if depth > 32 || s.hidden { return; } @@ -64,15 +87,22 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: return; } let m = parent * x.affine(); + let rect = m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)); if let Some(link) = &s.click { - out.links.push(PlacedLink { rect: m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)), link: link.clone() }); + out.links.push(PlacedLink { rect, link: link.clone(), shape: s.id, text: false }); } + let me = if own { + out.shapes.push(PlacedShape { id: s.id, parent: group, is_group: matches!(s.kind, ShapeKind::Group { .. }), rect, link: s.click.clone() }); + Some(out.shapes.len() - 1) + } else { + None + }; match &s.kind { ShapeKind::Group { children, child } => { let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); let gm = parent * deckcraft_geom::group_child_affine(&x, ch); for c in children { - place(out, rctx, fields, c, gm, depth + 1); + place(out, rctx, fields, c, gm, depth + 1, me, own); } } ShapeKind::Table(t) => { @@ -81,20 +111,21 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue }; let body = table::cell_body(rctx, t, r, c, cell); let tmp = Shape { text: Some(body.clone()), ..Default::default() }; - place_text(out, rctx, fields, &tmp, &body, rect, m); + place_text(out, rctx, fields, s.id, &tmp, &body, rect, m); } } ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {} _ => { let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return }; let tr = shape_geometry(s, x.w, x.h).text_rect; - place_text(out, rctx, fields, s, body, tr, m); + place_text(out, rctx, fields, s.id, s, body, tr, m); } } } /// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks. -fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { +#[allow(clippy::too_many_arguments)] +fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); let m = crate::text_transform(m, tr, layout.rotation); // Text hyperlinks: one box per line segment of a linked run. @@ -105,7 +136,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, bod let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first)); let r = Rect::new(x0, line.top, x1, line.bottom); - out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link }); + out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true }); } out.texts.push(PlacedText { layout, transform: m, body: body.clone() }); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..c3cfbb96 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,54 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +#[test] +fn placed_shapes_give_the_click_boxes_of_a_slide_in_drawing_order() { + use deckcraft_model::text::{Action, Hyperlink}; + let link = Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false }; + let rect = |id: u32, x: f64, click: Option| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 50.0, 100.0, 40.0)), + click, + ..Default::default() + }; + let group = Shape { + id: ShapeId(10), + xfrm: Some(Xfrm::new(300.0, 50.0, 200.0, 40.0)), + click: Some(link.clone()), + kind: ShapeKind::Group { children: vec![rect(11, 0.0, None), rect(12, 100.0, None)], child: Xfrm::new(0.0, 0.0, 200.0, 40.0) }, + ..Default::default() + }; + let hidden = Shape { hidden: true, ..rect(13, 0.0, Some(link.clone())) }; + let p = deck_with(vec![rect(1, 0.0, None), rect(2, 150.0, Some(link.clone())), hidden, group]); + let placed = place_slide(&p, 0); + // Back to front, a group before its children; the hidden shape is not there. + let ids: Vec = placed.shapes.iter().map(|s| s.id.0).collect(); + assert_eq!(ids, vec![1, 2, 10, 11, 12]); + // Only the shapes with an action carry a link. + let linked: Vec = placed.shapes.iter().filter(|s| s.link.is_some()).map(|s| s.id.0).collect(); + assert_eq!(linked, vec![2, 10]); + // The children know their group, whose box covers them. + let group = &placed.shapes[2]; + assert!(group.is_group && group.parent.is_none()); + for child in &placed.shapes[3..] { + assert_eq!(child.parent, Some(2)); + assert!(child.rect.x0 >= group.rect.x0 - 0.01 && child.rect.x1 <= group.rect.x1 + 0.01); + } + // Shape actions come out as links owned by their shape, not as text links. + assert!(placed.links.iter().all(|l| !l.text)); + assert_eq!(placed.links.iter().map(|l| l.shape.0).collect::>(), vec![2, 10]); +} + +#[test] +fn a_text_hyperlink_belongs_to_the_shape_that_holds_the_text() { + use deckcraft_model::text::{Action, Hyperlink}; + let mut body = TextBody::from_text("click here"); + for r in &mut body.paragraphs[0].runs { + r.props.link = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: false }); + } + let sh = Shape { id: ShapeId(7), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 60.0)), text: Some(body), ..Default::default() }; + let placed = place_slide(&deck_with(vec![sh]), 0); + assert!(placed.links.iter().any(|l| l.text && l.shape == ShapeId(7))); + assert!(placed.shapes.iter().all(|s| s.link.is_none()), "a text link is not the shape's own action"); +} diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..51fe5b70 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1,10 +1,10 @@ //! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`), //! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings. -use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline}; +use deckcraft_anim::{Click, HitShape, Layer, LinkJump, ShowAction, ShowState, Source, StepStamp, Timeline, TriggerPlay}; use deckcraft_model::text::Hyperlink; use deckcraft_model::{Presentation, ShapeId}; -use deckcraft_render::{ParaState, RenderOpts, ShapeState}; +use deckcraft_render::{ParaState, PlacedLink, PlacedShape, RenderOpts, ShapeState}; use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; use serde_json::json; @@ -40,8 +40,9 @@ pub struct Show { media_slide: Option, /// The slide shown before the current one (the Last Slide Viewed action). last_viewed: Option, - /// Click areas of hyperlinks and shape actions on slide `.0`, in slide points. - links: Option<(usize, Vec)>, + /// Click areas on slide `.0`, in slide points: hyperlinks and shape actions, and the slide's + /// shapes in drawing order. + links: Option<(usize, Vec, Vec)>, } #[derive(Clone, PartialEq)] @@ -52,6 +53,8 @@ struct Key { doc: usize, /// Media hidden while not playing. hidden: Vec, + /// Clicks played of each triggered animation (what the shapes look like when none is running). + plays: Vec<(ShapeId, usize)>, /// The frames animated GIFs show. gif: u64, } @@ -169,7 +172,9 @@ impl Show { /// On entering a slide: auto-play its first step when it begins With/After Previous. fn after_enter(&mut self, doc: &Presentation, now: f64) { - let auto = self.timeline(doc).step_is_auto(0); + // A slide the show comes back to is not started again: only a first visit has an automatic + // first step. + let auto = self.timeline(doc).step_is_auto(0) && self.state.step == 0; if auto || self.autoplay { let a = self.state.next(doc); if matches!(a, ShowAction::PlayStep(_)) { @@ -179,8 +184,8 @@ impl Show { } } -fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { - let a = tl.state(id, None, step, t); +fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPlay], stamps: &[StepStamp]) -> ShapeState { + let a = tl.state_ordered(id, None, step, t, plays, stamps); ShapeState { visible: a.visible, opacity: a.opacity, @@ -314,15 +319,18 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { _ => {} } } + // The clock of the triggered animations runs before anything reacts to the pointer. + show.tick_triggers(&doc, now); let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx); let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None }; let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; - let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None }; + let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; + let pointing = under.as_ref().is_some_and(|u| u.pick.is_some()); if let Some(id) = gif_click { let _ = app.run("media.gifPlay", json!({"id": id})); } else if let Some((id, clip)) = media_click { app.media.toggle(&doc, id, &clip, crate::media::Owner::Show); - } else if clicked && let Some(l) = &link { + } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) { let last = show.last_viewed; match show.state.follow(&doc, &l.action, last) { LinkJump::Show(a) => actions.push(a), @@ -336,7 +344,14 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } else if show.blank.is_some() { show.blank = None; } else { - actions.push(show.state.next(&doc)); + // A shape with an action or a triggered animation, or the background. + let none = Under::default(); + match show.click_slide(&doc, under.as_ref().unwrap_or(&none), now) { + Click::Show(a) => actions.push(a), + Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Click::Exit => keep = false, + Click::Animated | Click::None => {} + } } } if secondary && !show.ended { @@ -395,8 +410,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } else { let idx = show.state.slide; let step = show.state.step; - let animating = show.state.playing; - let t = if animating { now - show.step_start } else { 0.0 }; + // A main step or a triggered animation is running: the picture changes every frame. + let animating = show.state.playing || show.state.trigger_playing(); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let plays = show.state.trigger_plays(); + let stamps = show.state.trigger_stamps(); let media = crate::media::slide_media(&doc, idx); let hidden: Vec = media .iter() @@ -409,14 +427,15 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); crate::media::gif_repaint(&ctx, &gifs); let gif = crate::media::gif_key(&gifs); - let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), gif }; + let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); + let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif }; let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { show.tex.as_ref().map(|(_, t)| t.clone()) } else { let tl = show.timeline(&doc).clone(); let hide = hidden.clone(); let f = move |id: ShapeId| { - let mut st = state_for(&tl, id, step, t); + let mut st = state_for(&tl, id, step, t, &plays, &stamps); if hide.contains(&id) { st.visible = false; } @@ -454,7 +473,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { (Some(tr), Some(tl)) => { let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); let hidden_b = |id: ShapeId| { - let st = state_for(&tl, id, 0, 0.0); + let st = state_for(&tl, id, 0, 0.0, &[], &[]); !st.visible || st.opacity <= 0.0 || hidden.contains(&id) }; tr.from @@ -579,7 +598,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height())); } ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - } else if link.is_some() { + } else if pointing { ctx.set_cursor_icon(egui::CursorIcon::PointingHand); } else if now - show.last_move > 2.5 && !show.reading { ctx.set_cursor_icon(egui::CursorIcon::None); @@ -725,9 +744,34 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O }) } -/// The link under the pointer: the topmost shape action or text hyperlink on the current slide -/// (a text link wins over its shape's action). -fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -> Option { +/// What a click on the pointer's spot acts on. +#[derive(Clone)] +enum Pick { + /// A hyperlink in text. + Text(Hyperlink), + /// A shape with an action or a triggered animation. + Shape, +} + +/// The slide's shapes at the pointer, for [`ShowState::click`]. +#[derive(Default)] +struct Under { + shapes: Vec, + /// Whether each of `shapes` is currently shown (an animation may have hidden it). + visible: Vec, + pick: Option, +} + +impl Under { + fn visible(&self, id: ShapeId) -> bool { + self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v) + } +} + +/// What is under the pointer on the shown slide: the slide's shapes with their animation state, the +/// topmost of them that does something when clicked, and a text hyperlink on it. Only shapes that +/// are shown can be clicked, and a shape without an action hides the ones beneath it. +fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation, now: f64) -> Option { let p = ctx.input(|i| i.pointer.hover_pos())?; let full = ui.max_rect(); let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; @@ -735,11 +779,72 @@ fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) - let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64; let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k); let i = show.state.slide; - if show.links.as_ref().is_none_or(|(s, _)| *s != i) { - show.links = Some((i, deckcraft_render::place_slide(doc, i).links)); + if show.links.as_ref().is_none_or(|(s, ..)| *s != i) { + let placed = deckcraft_render::place_slide(doc, i); + show.links = Some((i, placed.links, placed.shapes)); + } + show.timeline(doc); + let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None }; + let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1; + let hits: Vec = shapes + .iter() + .map(|s| HitShape { + id: s.id, + parent: s.parent, + is_group: s.is_group, + link: s.link.as_ref().map(|l| l.action.clone()), + under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1), + }) + .collect(); + let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps()); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let visible: Vec = shapes + .iter() + .map(|s| { + let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps); + a.visible && a.opacity > 0.0 + }) + .collect(); + let mut under = Under { shapes: hits, visible, pick: None }; + let shown = |id: ShapeId| under.visible(id); + // A hyperlink in text applies when its shape is the one on top. + let top = show.state.top(doc, tl, &under.shapes, &shown); + let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| { + let mut at = top; + (0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) { + Some(h) if h.id == l.shape => true, + Some(h) => { + at = h.parent; + false + } + None => false, + }) + }); + under.pick = match text { + Some(l) => Some(Pick::Text(l.link.clone())), + None => show.state.target(doc, tl, &under.shapes, &shown).map(|_| Pick::Shape), + }; + Some(under) +} + +impl Show { + /// Advance the clock of the running triggered animations. + fn tick_triggers(&mut self, doc: &Presentation, now: f64) { + self.timeline(doc); + if let Some((_, tl)) = &self.timeline { + self.state.tick(tl, now); + } + } + + /// A click on the slide: the shape under the pointer acts, or the background advances. + fn click_slide(&mut self, doc: &Presentation, under: &Under, now: f64) -> Click { + self.timeline(doc); + let last = self.last_viewed; + match &self.timeline { + Some((_, tl)) => self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last), + None => Click::None, + } } - let (_, links) = show.links.as_ref()?; - links.iter().rev().find(|l| l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1).map(|l| l.link.clone()) } /// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. @@ -878,7 +983,7 @@ mod tests { let ctx = egui::Context::default(); let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR); let mut show = Show::new(&doc, 0, false, false); - show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone())); + show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone())); assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); let tr = show.trans.as_ref().expect("the fade plays"); assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id())); diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 8ec5d0f4..5f190902 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -67,7 +67,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Effect Options (direction, sequence by paragraph) | yes | yes | | Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; rewind lost on PPTX save (#77) | | By-paragraph text builds, sub-bullets follow parents | yes | yes | -| Triggers (on click of a shape) | yes | yes | +| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step | | Triggers on a media bookmark | yes | no | | Animation Painter | yes | yes (#54) | | Animation Pane: list, reorder, remove | yes | yes | @@ -94,4 +94,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| | 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | +| 2026-10-13 | minor | Triggers now play in the slide show (they were read and written but never started), effects of different triggers apply in the order they were clicked | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..dd60c4f8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -159,6 +159,20 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Animation Pane, effect count and preview Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. +### Interactive decks: media-action links, audio-triggered effects and click boxes +- **Evidence:** the slide show plays click-triggered animations, keeps each slide's state across + visits and ignores shapes an animation has hidden (`crates/anim/src/interact.rs`). Still open, + found by running three classroom game decks (Jeopardy-style boards, hide-and-reveal, score + counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486 + shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are + not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do + not follow a shape that a motion path has moved; the PPTX writer turns every "on click" after the + first effect of a trigger into "after previous", so a trigger with several click steps saved from + DeckCraft plays all of them at once. +- **Impact:** games built around sound effects on buttons, or around a button that counts up with + repeated clicks, lose that part when they are saved from DeckCraft or played in it. +- **Estimate:** 4–8 h. + ### Notes and Handout masters - **Evidence:** checklist row missing; PPTX writes a notes master but there's no view to edit it, and no handout master at all. @@ -257,6 +271,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | +| 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fd981595..bc15277d 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -20,6 +20,9 @@ ready** (estimated), **10–16 h** to parity. | Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Hidden slides skipped | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) | +| A shape with a triggered animation plays it when clicked; shapes an animation has hidden cannot be clicked; a shape without an action on top of one with an action blocks it; the pointer is a hand over what can be clicked | yes | yes | Click boxes are bounding boxes; "play sound" actions (`ppaction://media`) do nothing yet | +| A slide the show returns to (link, arrow keys) looks as it was left; a triggered animation goes on where it stopped | yes | yes | A main-sequence step still playing when the show leaves is finished | +| "Advance on click" off: a click never leaves the slide (animation steps still play, keys still move on) | yes | yes | | | Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | | Animated GIFs play; pause from their corner button | yes | yes (#39) | | | See All Slides grid during the show | yes | no | | @@ -73,4 +76,5 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Click handling for interactive decks: triggers, per-slide state kept across visits, visible-shape hit testing with hand cursor, advance on click | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu | From cf640c4b08df98a60669141cd97e8e3e4e22598c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 13:07:49 +0200 Subject: [PATCH 304/376] CLI: convert and run refuse output formats DeckCraft can't write (#715) convert, run --save and run --export picked the format from the output extension and fell back to native .deckcraft bytes for any extension they didn't know, so out.odp, out.gif, out.svg and out.outline were zips with exit status 0. They now fail before writing anything, with the extension and the supported ones in the error. --- apps/deckcraft-cli/src/main.rs | 15 +++++++- apps/deckcraft-cli/tests/convert.rs | 57 +++++++++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 11 ++++++ crates/engine/src/tests.rs | 13 +++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 apps/deckcraft-cli/tests/convert.rs diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index fd1d96ea..3c4d618b 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -240,9 +240,9 @@ fn render_transition(args: &[String]) -> Result<(), String> { fn convert(args: &[String]) -> Result<(), String> { let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; + let fmt = output_format(out)?; let mut s = Session::new(); open(&mut s, Some(input), false)?; - let fmt = deckcraft_engine::cmd::file::format_for_path(out); let r = if matches!(fmt, "png" | "jpeg") { s.execute("file.export", &json!({"path": out, "slide": 0})) } else { @@ -253,6 +253,16 @@ fn convert(args: &[String]) -> Result<(), String> { Ok(()) } +/// The format `convert`, `run --save` and `run --export` write to `path`, or an error naming the +/// supported extensions when `path`'s extension isn't one (rather than native bytes under that name). +fn output_format(path: &str) -> Result<&'static str, String> { + use deckcraft_engine::cmd::file::{WRITABLE_EXTENSIONS, writable_format_for_path}; + writable_format_for_path(path).ok_or_else(|| { + let ext = std::path::Path::new(path).extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_else(|| "(none)".into()); + format!("{path}: unsupported output format {ext} (supported: .{})", WRITABLE_EXTENSIONS.join(", .")) + }) +} + fn run(args: &[String]) -> Result<(), String> { let mut input = None; let mut sample = false; @@ -272,6 +282,9 @@ fn run(args: &[String]) -> Result<(), String> { other => return Err(format!("unknown option `{other}`")), } } + for p in save.iter().chain(&export) { + output_format(p)?; + } let mut s = Session::new(); open(&mut s, input.as_deref(), sample)?; for c in cmds { diff --git a/apps/deckcraft-cli/tests/convert.rs b/apps/deckcraft-cli/tests/convert.rs new file mode 100644 index 00000000..5de25e51 --- /dev/null +++ b/apps/deckcraft-cli/tests/convert.rs @@ -0,0 +1,57 @@ +//! `deckcraft-cli convert` writes the format OUT's extension names, or fails without writing. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn cli(args: &[&Path]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() +} + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn convert_rejects_output_formats_it_cannot_write() { + let dir = temp_dir("convert-unsupported"); + let input = dir.join("in.pptx"); + let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + for name in ["out.odp", "out.gif", "out.svg", "out.outline", "out"] { + let target = dir.join(name); + let out = cli(&[Path::new("convert"), &input, &target]); + assert!(!out.status.success(), "{name}: convert should fail"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("unsupported output format") && err.contains(".pptx"), "{name}: {err}"); + assert!(!target.exists(), "{name}: nothing is written"); + } + + let target = dir.join("out.deckcraft"); + let out = cli(&[Path::new("convert"), &input, &target]); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(std::fs::read(&target).unwrap().starts_with(b"PK\x03\x04")); + + let target = dir.join("out.txt"); + let out = cli(&[Path::new("convert"), &input, &target]); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(!std::fs::read(&target).unwrap().starts_with(b"PK"), "an outline is text, not a zip"); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn run_rejects_unsupported_save_and_export_paths_before_running() { + let dir = temp_dir("run-unsupported"); + for flag in ["--save", "--export"] { + let target = dir.join("deck.odp"); + let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", flag]).arg(&target).output().unwrap(); + assert!(!out.status.success(), "{flag}: run should fail"); + assert!(String::from_utf8_lossy(&out.stderr).contains("unsupported output format .odp"), "{flag}"); + assert!(!target.exists(), "{flag}: nothing is written"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f6424f7c..89dea68f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -182,6 +182,17 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result> { deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string())) } +/// The output extensions [`format_for_path`] maps to a writer of that format. +pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "txt", "md"]; + +/// The format a file named `path` is written in, or `None` when its extension names no format +/// DeckCraft writes (`.odp`, `.gif`, `.svg`, no extension …): [`format_for_path`] would save +/// those as native `.deckcraft` bytes under the wrong name. +pub fn writable_format_for_path(path: &str) -> Option<&'static str> { + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path)) +} + pub fn format_for_path(path: &str) -> &'static str { let l = path.to_ascii_lowercase(); if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..2727a717 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -181,6 +181,19 @@ fn sample_deck_builds_and_renders() { assert!(v["slides"].as_array().unwrap().len() >= 8); } +#[test] +fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() { + use crate::cmd::file::writable_format_for_path as f; + assert_eq!(f("a.deckcraft"), Some("deckcraft")); + assert_eq!(f("dir/A.PPTX"), Some("pptx")); + assert_eq!(f("a.pdf"), Some("pdf")); + assert_eq!(f("a.jpg"), Some("jpeg")); + assert_eq!(f("a.txt"), Some("outline")); + for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] { + assert_eq!(f(path), None, "{path}"); + } +} + #[test] fn save_and_reopen_native() { let mut s = Session::new(); From d78e657e4cb1e490ceb5445785c052d9cf5ad963 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 09:33:03 -0400 Subject: [PATCH 305/376] Release 0.7.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 56 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4044fce6..4cf2cd89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.6.1" +version = "0.7.0" dependencies = [ "arboard", "cpal", @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -914,14 +914,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.6.1" +version = "0.7.0" dependencies = [ "proptest", ] [[package]] name = "deckcraft-cli" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -930,14 +930,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-color", "deckcraft-fonts", @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.6.1" +version = "0.7.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-model", "serde", @@ -982,7 +982,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.6.1" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -990,7 +990,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "thiserror 2.0.21", @@ -1016,11 +1016,11 @@ dependencies = [ [[package]] name = "deckcraft-matroska" -version = "0.6.1" +version = "0.7.0" [[package]] name = "deckcraft-mcp" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.6.1" +version = "0.7.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1068,15 +1068,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.6.1" +version = "0.7.0" [[package]] name = "deckcraft-opus" -version = "0.6.1" +version = "0.7.0" [[package]] name = "deckcraft-pdf" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-fonts", "deckcraft-model", @@ -1091,7 +1091,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1107,7 +1107,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1126,7 +1126,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.6.1" +version = "0.7.0" dependencies = [ "chrono", "deckcraft-color", @@ -1140,7 +1140,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1162,7 +1162,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1171,7 +1171,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.6.1" +version = "0.7.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -5667,7 +5667,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index b8596156..5bb1f9af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] [workspace.package] -version = "0.6.1" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 5d31b787368199104016d37ccb29be05b60c7581 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:01:42 +0300 Subject: [PATCH 306/376] pptx: write linked pictures with r:link so PowerPoint resolves external images --- crates/pptx/src/write/dml.rs | 12 ++++++++++-- crates/pptx/tests/roundtrip.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 16a069fe..a960df46 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -95,6 +95,9 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { /// `a:blipFill` / `p:blipFill`. pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) { + // An image that lives outside the package is a linked picture: `a:blip` names it through + // `r:link`; `r:embed` may only reference a part inside the package. + let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some()); let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" })); let adj = &pf.adjust; @@ -106,7 +109,7 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil || adj.clear_color.is_some() || adj.bilevel.is_some() || adj.alpha_inv; - let a = A::new().a("r:embed", &rid); + let a = if linked { A::new().a("r:link", &rid) } else { A::new().a("r:embed", &rid) }; if has_kids { w.open("a:blip", a); if let Some(c) = adj.clear_color { @@ -521,7 +524,12 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") { Some(rid) => { w.open0("a:buBlip"); - w.empty("a:blip", A::new().a("r:embed", rid)); + let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) { + A::new().a("r:link", &rid) + } else { + A::new().a("r:embed", &rid) + }; + w.empty("a:blip", a); w.close("a:buBlip"); } None => w.empty("a:buChar", A::new().a("char", "•")), diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..dafa6c03 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,31 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +#[test] +fn linked_pictures_are_written_with_r_link() { + // A picture whose bytes live outside the package must be referenced with `r:link`; `r:embed` + // may only name a part inside the package (PowerPoint won't load the linked image otherwise). + let mut p = defaults::new_presentation(None); + let media = deckcraft_model::MediaId(5000); + p.media.push(deckcraft_model::MediaItem { + id: media, + name: "linked.png".into(), + content_type: "image/png".into(), + data: Arc::new(Vec::new()), + link: Some("file:///tmp/linked.png".into()), + }); + let mut pic = shape(&mut p, "Linked", 100.0, 100.0, 200.0, 150.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }; + Arc::make_mut(&mut p.slides[0]).shapes.push(pic); + + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read"); + assert!(xml.contains(r#"r:link="rId"#), "{xml}"); + assert!(!xml.contains(r#"r:embed="rId"#), "{xml}"); + let mut rels = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/_rels/slide1.xml.rels").expect("rels"), &mut rels).expect("read"); + assert!(rels.contains(r#"Target="file:///tmp/linked.png" TargetMode="External""#), "{rels}"); +} From a837c26eda9503133d41f1403f89f24d31b73671 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:00:05 -0400 Subject: [PATCH 307/376] Engine: Selection pane select uses the checked shape id param (#97 after main's selection.select) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/selection.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/selection.rs b/crates/engine/src/cmd/selection.rs index 20890418..76eaa706 100644 --- a/crates/engine/src/cmd/selection.rs +++ b/crates/engine/src/cmd/selection.rs @@ -28,7 +28,10 @@ pub fn specs() -> Vec { /// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden /// object from the list shows it again. fn select(s: &mut Session, p: &Value) -> Result { - let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default(); + let ids = match ids_param(p, "ids") { + Some(ids) => ids, + None => id_param(p, "id", "selection.select")?.map(|i| vec![i]).unwrap_or_default(), + }; let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false)); let st = s.doc()?; let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some()); From edaef792e048ef5c8cf9fc4b0c2ee82ca8bb6cc7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:11:36 -0400 Subject: [PATCH 308/376] Fix up merged PRs: Edit Points hits select their shape on right click; Hyperlink sound fields in #713's tests Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 10 +++++++++- crates/engine/tests/show_clicks.rs | 2 +- crates/pptx/tests/corpus.rs | 2 ++ crates/render/src/tests.rs | 4 ++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 226f372a..feb7d641 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -972,7 +972,15 @@ impl Session { } let hit = self.hit_test(p, tol); let id = match &hit { - Some(Hit::Shape { id, .. } | Hit::Resize { id, .. } | Hit::Rotate { id } | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. }) => Some(*id), + Some( + Hit::Shape { id, .. } + | Hit::Resize { id, .. } + | Hit::Rotate { id } + | Hit::Adjust { id, .. } + | Hit::LineEnd { id, .. } + | Hit::Handle { id, .. } + | Hit::Vertex { id, .. }, + ) => Some(*id), None => None, }; if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { diff --git a/crates/engine/tests/show_clicks.rs b/crates/engine/tests/show_clicks.rs index 6fa7d1a0..bc7b93b8 100644 --- a/crates/engine/tests/show_clicks.rs +++ b/crates/engine/tests/show_clicks.rs @@ -20,7 +20,7 @@ fn shape(p: &mut Presentation, name: &str, text: &str, x: f64, y: f64, w: f64, h } fn link_to(slide: SlideId) -> Option { - Some(Hyperlink { action: Action::Slide { slide }, tooltip: String::new(), highlight_click: false }) + Some(Hyperlink { action: Action::Slide { slide }, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false }) } /// Clicking `trigger` makes `target` disappear. diff --git a/crates/pptx/tests/corpus.rs b/crates/pptx/tests/corpus.rs index 1f14f6ee..c37b3555 100644 --- a/crates/pptx/tests/corpus.rs +++ b/crates/pptx/tests/corpus.rs @@ -62,6 +62,8 @@ fn talk() -> Presentation { action: Action::Url { url: "https://example.org/north?q=1&x=2".into() }, tooltip: "Open the report".into(), highlight_click: false, + sound: None, + stop_sound: false, }), ..Default::default() }, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7eedf64c..de42531a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1743,7 +1743,7 @@ fn east_asian_vertical_text_stands_ideographs_upright() { #[test] fn placed_shapes_give_the_click_boxes_of_a_slide_in_drawing_order() { use deckcraft_model::text::{Action, Hyperlink}; - let link = Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false }; + let link = Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false }; let rect = |id: u32, x: f64, click: Option| Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, 50.0, 100.0, 40.0)), @@ -1783,7 +1783,7 @@ fn a_text_hyperlink_belongs_to_the_shape_that_holds_the_text() { use deckcraft_model::text::{Action, Hyperlink}; let mut body = TextBody::from_text("click here"); for r in &mut body.paragraphs[0].runs { - r.props.link = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: false }); + r.props.link = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false }); } let sh = Shape { id: ShapeId(7), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 60.0)), text: Some(body), ..Default::default() }; let placed = place_slide(&deck_with(vec![sh]), 0); From 47cf76f32c7ef148cd3ee23beaa82a886477b7ec Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:29:27 -0400 Subject: [PATCH 309/376] Fix up merged PRs: handout master csld takes tags; corpus test hyperlink gains sound fields Co-Authored-By: Claude Opus 5.5 --- crates/pptx/src/write/mod.rs | 2 +- crates/pptx/tests/corpus_kinds.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 228c9379..0ce5af26 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -586,7 +586,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { x.pkg.rels(&tname, &to.rels); let mut w = W::new(); root_open(&mut w, "p:handoutMaster", A::new()); - csld(&mut w, &mut x, &mut ho, &hm.name, hm.background.as_ref(), &hm.shapes); + csld(&mut w, &mut x, &mut ho, &hm.name, hm.background.as_ref(), &hm.shapes, &[]); clr_map(&mut w, &hm.color_map); w.close("p:handoutMaster"); x.pkg.xml_part(handout_path, CT_HANDOUT_MASTER, w.finish()); diff --git a/crates/pptx/tests/corpus_kinds.rs b/crates/pptx/tests/corpus_kinds.rs index 52dae76e..1f2d2f46 100644 --- a/crates/pptx/tests/corpus_kinds.rs +++ b/crates/pptx/tests/corpus_kinds.rs @@ -154,6 +154,8 @@ fn sections() -> Presentation { action: Action::Url { url: "https://example.org/brief?q=1&x=2".into() }, tooltip: "Open the brief".into(), highlight_click: false, + sound: None, + stop_sound: false, }), ..Default::default() }, From 4c1357b2e66b4a2351b25f6a7ead4d962ea73d91 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:29:52 -0400 Subject: [PATCH 310/376] Fix up merged PRs: shape.action fills main's new Hyperlink sound fields and CustomShow returns Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 4 ++-- crates/ui-egui/src/dialogs.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 930b4356..64eff386 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -686,7 +686,7 @@ fn shape_action(s: &mut Session, p: &Value) -> Result { if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { return Err(bad(CMD, format!("no custom show `{name}`"))); } - Some(Action::CustomShow { name }) + Some(Action::CustomShow { name, returns: true }) } "program" => Some(Action::Program { path: need("path")? }), "slide" => { @@ -698,7 +698,7 @@ fn shape_action(s: &mut Session, p: &Value) -> Result { }; let highlight = bool_param(p, "highlight").unwrap_or(true); let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); - let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight }); + let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false }); edit_shapes(s, p, CMD, |sh| { if hover { sh.hover = link.clone(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 60880b16..3ae2e03d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -486,7 +486,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { Some(Action::EndShow) => ("endShow", String::new()), Some(Action::PlayMedia) => ("playMedia", String::new()), Some(Action::Url { url }) => ("url", url.clone()), - Some(Action::CustomShow { name }) => ("customShow", name.clone()), + Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), Some(Action::Program { path }) => ("program", path.clone()), Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), }; From 4653cb4e18e34fd4ba1f09424fa8769ea2059089 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:30:15 -0400 Subject: [PATCH 311/376] Fix up merged PRs: Hyperlink dialog tests fill main's new sound fields Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b2047412..63a85070 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1527,7 +1527,7 @@ mod tests { }; fn link(action: Action, tip: &str) -> Hyperlink { - Hyperlink { action, tooltip: tip.into(), highlight_click: false } + Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false } } fn url(address: &str, tip: &str) -> Hyperlink { From 2f6629c4cb0708c1d077772527bcdcbf0579e5fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:37:42 -0400 Subject: [PATCH 312/376] Fix up merged PRs: theme browsing's importer sets vml_previews (#530 after #209-era field) Co-Authored-By: Claude Opus 5.5 --- crates/pptx/src/read/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index aa6bcdcf..1e5b4c6b 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -284,6 +284,7 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec< slide_by_part: HashMap::new(), custom_show_names: HashMap::new(), table_style_ids: Vec::new(), + vml_previews: HashMap::new(), }; let part = Part::new(&pkg, &theme_path, &doc); let theme = theme::read_theme(&mut imp, &part, &doc.root); From 0376c0b0e137111cb691d76402cdd8aa2f2671c3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:41:30 -0400 Subject: [PATCH 313/376] Fix up merged PRs: one parity summary, one dialogs gap entry, no duplicated architecture rows Recomputed docs/parity-checklist.md's summary with cargo xtask parity. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 -- docs/gaps.md | 8 ++------ docs/parity-checklist.md | 12 ++++-------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b1a64f63..da57f5bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,8 +26,6 @@ nothing below L6 uses egui, eframe, winit or rfd. | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (228 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | -| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | -| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with diff --git a/docs/gaps.md b/docs/gaps.md index b820ac27..eee83d1b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -300,12 +300,8 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. -- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). Size and Position is one of the 28. 8–14 h. -- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). Date and Time is in. 8–14 h. +- **Dialogs**: 30 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, Replace Fonts, Print, + Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index eb6cf53b..10f21882 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -8,12 +8,8 @@ The summary between ` | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | +| P1 | 65 | 39 | 21 | 5 | 76% | +| P2 | 54 | 14 | 17 | 23 | 42% | | Area | Parity | |---|---| @@ -22,7 +18,7 @@ The summary between ` | Slides | 90% | | Views | 93% | | Masters & themes | 85% | -| Shapes | 88% | +| Shapes | 91% | | Format | 83% | | Arrange | 100% | | Text | 80% | @@ -31,7 +27,7 @@ The summary between ` | SmartArt | 50% | | Pictures | 48% | | Media | 67% | -| Transitions | 92% | +| Transitions | 100% | | Animations | 79% | | Slide show | 66% | | Review | 60% | From b5499d7157081452735e8a1d13dc253cf6469c40 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:41:50 -0400 Subject: [PATCH 314/376] Fix up merged PRs: waterfall axis scale uses the running totals main's axis_scale now reads plotted_extent rather than value_range, which #537 taught about waterfalls; route waterfall charts to value_range there too. Co-Authored-By: Claude Opus 5.5 --- crates/model/src/chart.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 694d06cd..42a8378d 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -369,6 +369,9 @@ impl Chart { /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite /// values; `None` when no series uses the axis. fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind == ChartType::Waterfall { + return (!secondary).then(|| self.value_range()); + } let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); if ser.is_empty() { return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); From 7d87b6e6683295d08cb2af96edf334be30d4ab9a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:45:01 -0400 Subject: [PATCH 315/376] Fix up merged PRs: transition sounds and media cues pass the document uid Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index d617254b..32145e48 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -442,7 +442,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.cue_media(&doc, step, now - show.step_start); } show_media(app, &mut show, doc_uid, &doc); - run_media_cues(app, &mut show, &doc); + run_media_cues(app, &mut show, doc_uid, &doc); // Draw. let full = ui.max_rect(); let painter = ui.painter_at(full); @@ -792,7 +792,7 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentat if let Some(media) = sound { // Loop Until Next Sound: it repeats until another slide's sound or Stop Previous Sound. let clip = deckcraft_model::MediaClip { media, volume: 1.0, loop_play: sound_loop, ..Default::default() }; - app.media.play(doc, TRANSITION_SOUND, &clip, None, crate::media::Owner::Show); + app.media.play(doc_uid, doc, TRANSITION_SOUND, &clip, None, crate::media::Owner::Show); } if let Some(i) = cur { for (id, _, clip) in crate::media::slide_media(doc, i) { @@ -805,15 +805,15 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentat /// Carry out the media effects the show's steps set off: Play starts from the beginning unless /// the clip is already playing, Pause toggles, Stop stops. -fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { +fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { for (id, effect) in std::mem::take(&mut show.media_cues) { let Some(clip) = crate::media::find_clip(doc, id) else { continue }; match effect.as_str() { - "pause" => app.media.toggle(doc, id, &clip, crate::media::Owner::Show), + "pause" => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show), "stop" => app.media.stop(id), // Already playing (it also starts automatically): leave it running. _ if app.media.is_playing(id) => {} - _ => app.media.play(doc, id, &clip, Some(0.0), crate::media::Owner::Show), + _ => app.media.play(doc_uid, doc, id, &clip, Some(0.0), crate::media::Owner::Show), } } } From 0a470c3b1fa4f6e87c529ea8bf00f66d786c5834 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:45:41 -0400 Subject: [PATCH 316/376] Fix up merged PRs: prefetch GIF check passes the document uid Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index dbf59930..8bcbfa43 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -584,7 +584,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { // frame depends on when it is entered). if !animating && show.trans.is_none() && !show.preview_only { let next = deckcraft_anim::show_order(&doc).into_iter().find(|i| *i > idx); - let wanted = next.filter(|n| app.media.gifs(&doc, *n, now, &app.session.gif_paused).is_empty()); + let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); if let Some(n) = wanted && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) { From 32a49e8bf66c7552432df5783b22f3d951c17026 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:47:08 -0400 Subject: [PATCH 317/376] Fix up merged PRs: a trendline caption in a tiny chart no longer panics f64::clamp panics when the plot is narrower than the caption's minimum width; use main's clamp_soft, as the rest of the chart code now does. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index f90ab549..1e481510 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -860,7 +860,7 @@ fn draw_trendlines( let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1; let box_h = base * 1.3 * lines as f64 + 2.0; let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64; - let box_w = (widest * base * 0.62).clamp(48.0, plot.width() * 0.72); + let box_w = clamp_soft(widest * base * 0.62, 48.0, plot.width() * 0.72); let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n); ctx.set_transform(m); ctx.set_paint(color(Rgba::WHITE, 1.0)); From 0f28ac96ed63a985bfc78d2072382712f788a4a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:49:35 -0400 Subject: [PATCH 318/376] Fix up merged PRs: reversed category axes and trendline forecasts agree #640 orders category slots bottom-to-top on horizontal bars (or reversed via c:orientation) while #603 pads the axis for trendline forecasts. Slots now include the forecast padding on the right side, and trendlines map their category positions through the same slot order as the bars. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index fe8df06a..4622e9c6 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -650,7 +650,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, } } } - draw_trendlines(ctx, rctx, c, m, plot, band, back, ncat, horiz, base, text_col, &to_axis); + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, ncat, horiz, base, text_col, &to_axis); // Axis line. let mut ax = BezPath::new(); if horiz { @@ -744,7 +746,7 @@ const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, fn map_xy( plot: Rect, band: f64, - back: f64, + slot: &impl Fn(f64) -> f64, horiz: bool, scatter: bool, sx0: f64, @@ -765,7 +767,7 @@ fn map_xy( plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() } else { let origin = if horiz { plot.y0 } else { plot.x0 }; - origin + band * (x - 1.0 + back + 0.5) + origin + band * (slot(x - 1.0) + 0.5) }; if !along.is_finite() { return None; @@ -811,7 +813,7 @@ fn draw_trendlines( m: Affine, plot: Rect, band: f64, - back: f64, + slot: &impl Fn(f64) -> f64, ncat: usize, horiz: bool, base: f64, @@ -861,8 +863,8 @@ fn draw_trendlines( let mut any = false; for w in samples.windows(2) { let (Some(a), Some(b)) = ( - map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), - map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), ) else { prev = None; continue; From b61cf33836d7e662b69026a0511eafdfd7fdd267 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:49:36 -0400 Subject: [PATCH 319/376] Fix up merged PRs: custom-show links carry 'returns'; prefetch follows the show's own order Co-Authored-By: Claude Opus 5.5 --- crates/anim/src/show.rs | 2 +- crates/anim/src/tests.rs | 4 ++-- crates/ui-egui/src/show.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index 2c49cc0c..0f71113b 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -347,7 +347,7 @@ impl ShowState { Action::FirstSlide => self.visit(pres).first().copied(), Action::LastSlide => self.visit(pres).last().copied(), Action::LastViewed => last_viewed, - Action::CustomShow { name } => { + Action::CustomShow { name, .. } => { let Some(order) = custom_indices(pres, name) else { return LinkJump::None }; let Some(&i) = order.first() else { return LinkJump::None }; self.order = Some(order); diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index f8fdfd4b..ab4d5146 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -726,10 +726,10 @@ fn playing_a_custom_show_stays_on_its_slides() { let mut s = ShowState::new(&p); p.slides[4] = Arc::new(Slide { id: SlideId(1004), ..Default::default() }); - assert_eq!(s.follow(&p, &Action::CustomShow { name: "Ends".into() }, None), LinkJump::Show(ShowAction::Slide(0))); + assert_eq!(s.follow(&p, &Action::CustomShow { name: "Ends".into(), returns: true }, None), LinkJump::Show(ShowAction::Slide(0))); assert_eq!(s.custom_name(), Some("Ends")); assert_eq!(s.next(&p), ShowAction::Slide(4)); - assert_eq!(s.follow(&p, &Action::CustomShow { name: "Missing".into() }, None), LinkJump::None); + assert_eq!(s.follow(&p, &Action::CustomShow { name: "Missing".into(), returns: true }, None), LinkJump::None); assert_eq!(s.slide, 4, "an unknown custom show leaves the current slide"); } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index e23e8575..b3ac7f3a 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -693,7 +693,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { // While this slide rests, render the next one's first frame (not one with GIFs, whose // frame depends on when it is entered). if !animating && show.trans.is_none() && !show.preview_only { - let next = deckcraft_anim::show_order(&doc).into_iter().find(|i| *i > idx); + let next = show.state.peek_next(&doc); let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); if let Some(n) = wanted && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) From 6902c4bf92ffec0617c943a25d04985372ed836a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:50:27 -0400 Subject: [PATCH 320/376] Fix up merged PRs: every presenter-view button uses the light-on-dark style (#221) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index fe345e28..5bb57e59 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1749,22 +1749,22 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no }); }); ui.horizontal(|ui| { - if ui.button(if paused { "Resume" } else { "Pause" }).clicked() { + if button(ui, if paused { "Resume" } else { "Pause" }).clicked() { show.timer.toggle(now); } - if ui.button("Reset").clicked() { + if button(ui, "Reset").clicked() { show.timer.reset(now); } - if ui.button(if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() { + if button(ui, if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() { toggle_blank(show, Color32::BLACK); } - if ui.button(if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() { + if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() { toggle_blank(show, Color32::WHITE); } - if ui.button(if show.grid { "All slides on" } else { "All slides" }).clicked() { + if button(ui, if show.grid { "All slides on" } else { "All slides" }).clicked() { show.grid = !show.grid; } - if ui.button(show.zoom_label()).clicked() { + if button(ui, show.zoom_label()).clicked() { show.zoom_key(); } }); From 5089d615bfbee0c860c40fc5d99d428f264d1479 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:50:37 -0400 Subject: [PATCH 321/376] Fix up merged PRs: scatter trendlines follow the value x axis #662 gives scatter and bubble charts one x scale over every series; #603's trendlines still mapped x over each series' own extent, so a fit drifted off its points. Use the chart's x scale where there is one. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 822f1bfd..5b2017a3 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -690,7 +690,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, } // A category position (0-based, fractional) as a slot, the way `slot` places the bars. let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; - draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, ncat, horiz, base, text_col, &to_axis); + draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); // Axis line. let mut ax = BezPath::new(); if horiz { @@ -852,6 +852,7 @@ fn draw_trendlines( plot: Rect, band: f64, slot: &impl Fn(f64) -> f64, + xscale: Option<(f64, f64, f64)>, ncat: usize, horiz: bool, base: f64, @@ -879,8 +880,11 @@ fn draw_trendlines( xs.push(x); ys.push(y); } - // The same x extent the scatter marks use, so the fit sits on the points. - let (sx0, sx1) = if scatter { + // The same x extent the scatter marks use, so the fit sits on the points: the chart's + // value x axis where it has one, else the series' own x values. + let (sx0, sx1) = if let (true, Some((lo, hi, _))) = (scatter, xscale) { + (lo, hi) + } else if scatter { let mut lo = f64::MAX; let mut hi = f64::MIN; for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) { From 0ac222a5437d0763adb023b05bd041a580973600 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:51:49 -0400 Subject: [PATCH 322/376] Fix up merged PRs: split ribbon buttons carry their label once (#512's label predates main's) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 47fc53a4..63ab3934 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -124,10 +124,6 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y)); let main_resp = if split { ui.interact(main_rect, resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() }; - // Screen readers (and UI tests) find the button by its text. - if !label.is_empty() { - main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); - } let arrow_resp = split.then(|| { ui.interact( Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max), From f9c1000e356ad0bdfb6d57709c830783d3895968 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:51:53 -0400 Subject: [PATCH 323/376] Fix up merged PRs: rustfmt; drop doc rows the union merge left twice Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 -- crates/engine/src/cmd/mod.rs | 2 +- docs/control-protocol.md | 2 -- docs/file-format-parity.md | 17 ++++++----------- docs/gaps.md | 5 ----- docs/parity-checklist.md | 2 -- docs/slideshow-parity.md | 8 +------- 7 files changed, 8 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dbfa8213..27b9d9f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,8 +139,6 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md). corpus, embedded fonts, #70, #74, #77, EMF/WMF and SVG. 36–64 h. 2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG. `corpus_media.rs` keeps 3-D models and animation bounce end. 36–64 h. -2. **PPTX on real decks:** a larger owned corpus and the remaining round-trip losses; #70, #74, #77; - EMF/WMF and SVG. 36–64 h. 3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h. 4. **Output:** vector PDF, printing. 10–16 h. 5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6f5825a9..dcfa20db 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,8 +2,8 @@ pub mod animation; pub mod arrange; -pub mod background; pub mod artistic; +pub mod background; pub mod design; pub mod edit; pub mod file; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index abb982d7..0b7909de 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,8 +18,6 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | | `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick\|contextclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse (`contextclick`: the right-button press that selects before the context menu) | -| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | -| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show: digits then Enter go to that slide, G is See All Slides, Z arms zoom, T pauses the presenter timer, R resets it, `[` and `]` change notes size, Esc clears a slide number, the grid and zoom before it leaves the show) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f592b52f..f6df0c8d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -76,10 +76,8 @@ the round trip even though DeckCraft doesn't model it. | Presentation, slide size, sections (p14), custom shows | yes | yes | | | Slide masters, layouts, placeholders and inheritance | yes | yes | | | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | -| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. No notes-master editing view | -| Handout master | no | no | | -| Notes slides and notes master | yes | yes | No notes-master editing view | -| Handout master | yes | yes | Header, footer, date and slide-number text is kept. No handout-master editing view | +| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master | +| Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master | | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | @@ -116,13 +114,10 @@ the round trip even though DeckCraft doesn't model it. ### What would move the PPTX number 1. A larger owned corpus (corporate templates, SmartArt, chartex charts, EMF, embedded fonts, - every transition and animation) in a corpus repo. `crates/pptx/tests/corpus.rs` already checks - open → save → reopen (parts and text) for decks we author. Still needed: render checks and a - PowerPoint reference render for each (owner runs PowerPoint). -1. A larger owned corpus (corporate templates, SmartArt, chartex charts, EMF, embedded fonts, - every transition and animation) in a corpus repo, with a PowerPoint reference render for each. - `crates/pptx/tests/corpus_kinds.rs` already checks tables, charts, animations, media, sections - and masters/layouts (open → save → reopen). + every transition and animation) in a corpus repo. `crates/pptx/tests/corpus.rs` and + `corpus_kinds.rs` already check open → save → reopen (parts and text) for decks we author: + tables, charts, animations, media, sections and masters/layouts among them. Still needed: + render checks and a PowerPoint reference render for each (owner runs PowerPoint). 2. The round-trip losses above (#70, #74, #77). 3. SVG rendering. EMF+ records inside EMF/WMF are still not drawn. diff --git a/docs/gaps.md b/docs/gaps.md index efd21e33..5bcd70a0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -123,7 +123,6 @@ Detail in [file-format-parity.md](file-format-parity.md). exist to compare against. `corpus_charts.rs` round-trips one owned slice (a combo chart with data labels and trendlines, a merged table with diagonal borders, text columns, autofit, vertical text and a WordArt warp). That is still a generated deck, not a corpus of real ones. -### PPTX corpus is only decks we author - **Evidence:** `crates/pptx/tests/corpus_kinds.rs` builds decks in this repo and checks open → save → reopen (package parts and text): tables, charts, animations, media, sections, and several masters and layouts. A handout master with a header, footer, date and slide number used to disappear on @@ -133,10 +132,6 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style (craftrules `standards/test-corpora.md`), add render checks, have the owner open outputs in PowerPoint, fix what breaks. **Beta blocker.** - (corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is still mostly unknown. -- **Estimate:** 20–35 h: build an owned corpus in `storytold/photocraft-corpus` style (craftrules - `standards/test-corpora.md`), add import → render → export → re-import checks, have the owner open - outputs in PowerPoint, fix what breaks. **Beta blocker.** ### Round trip loses media playback options and animation rewind (#74, #77) - **Evidence:** issues filed 2026-10-10 by an automated reviewer; media playback options and the diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index eae5e36a..0ce1a52e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -12,8 +12,6 @@ The summary between ` | P2 | 54 | 13 | 15 | 26 | 38% | | 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 | |---|---| diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index 5eb32d6c..129070be 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -35,11 +35,8 @@ ready** (estimated), **10–16 h** to parity. | Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | | Media playback with controls, play across slides, full-screen video | yes | yes | Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media`. WMA/WMV not decodable | | Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable; click sequence since #116 | -| Animated GIFs play; pause from their corner button | yes | yes (#39) | | | Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable. Slide Show ▸ Show Media Controls (on by default) hides the playback bar | | Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls | -| See All Slides grid during the show | yes | no | | -| Zoom into part of a slide during the show | yes | no | | | See All Slides grid during the show | yes | yes: G, or All on the show bar and in presenter view; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown | | Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it | The presenter keeps the whole slide and draws a frame on the zoomed part | | Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt | @@ -58,11 +55,8 @@ ready** (estimated), **10–16 h** to parity. | Pause / reset timer, clock | yes | yes: Pause/Resume and Reset in presenter view (T pauses, R resets); the clock is local time, `--:--` where there is no clock | Pausing the timer does not pause the show, animations or rehearsal | | Slide counter, previous/next/end buttons | yes | yes | | | Keyboard navigation while Presenter View has focus | yes | yes | Shares the audience window's key handling; handled keys are consumed before buttons, and the presenter stays available at the end screen | -| Notes font size, scrolling | yes | scrolling only | | -| Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Beta blocker, 3–5 h | -| Swap displays | yes | no | | | Notes font size, scrolling | yes | yes: A- / A+ (and `[` / `]`), 12–48 pt in steps of 4, default 18; the notes scroll | | -| Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Open PR #433 places the show on a second display when one is attached. This change does not | +| Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Beta blocker, 3–5 h. Open PR #433 places the show on a second display when one is attached. This change does not | | Swap displays | yes | no | Still open on #433 | | Pen, laser, black screen, zoom from presenter view | yes | Black, White and Zoom buttons; the audience zoom is framed on the presenter slide | Pen and the laser pointer are still keys only; there is no laser yet | | See all slides from presenter view | yes | yes: All slides opens the grid on the show | | From bb3627f9549130b3e0f1107b70f3f185046aa135 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:53:06 -0400 Subject: [PATCH 324/376] Fix up merged PRs: minor gridlines on a logarithmic axis #666's minor gridlines stepped in value units, but #669 scales a log axis in exponents of its base; map each minor step back through the base. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 54d989f8..c6f35fdc 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -411,7 +411,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500); let mut g = BezPath::new(); for k in (0..=n).filter(|k| k % 5 != 0) { - let pos = to_v(min + k as f64 * minor); + // On a logarithmic axis `min`, `max` and `step` are exponents of the base. + let e = min + k as f64 * minor; + let pos = to_v(log.map_or(e, |(b, _)| b.powf(e))); if horiz { g.move_to((pos, plot.y0)); g.line_to((pos, plot.y1)); From 5c7e9fc6c3013d5eca57404128d3a114fc77778b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:53:13 -0400 Subject: [PATCH 325/376] Format the merged PRs (cargo fmt) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ae207d1d..50a92942 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1258,12 +1258,12 @@ fn js_now() -> f64 { #[cfg(test)] mod animpane_tests; #[cfg(test)] +mod browsetheme_tests; +#[cfg(test)] mod eyedropper_tests; #[cfg(test)] mod huge_deck_tests; #[cfg(test)] -mod browsetheme_tests; -#[cfg(test)] mod ime_tests; #[cfg(test)] mod replacefonts_tests; From 3450180f19bafec9cfe26a21e5d5d9d26c60e33e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:55:38 -0400 Subject: [PATCH 326/376] Fix up merged PRs: EMF+ clipping alongside GDI clip regions main added its own clip.rs (GDI rectangle regions carried on every drawn item); #582 added a clip.rs that cuts drawing geometrically to the EMF+ clip rectangle. Keep main's module, move #582's to cliprect.rs, keep the EMF+ rectangle on the canvas as plus_clip, and carry the region field through the geometric clipper. Co-Authored-By: Claude Opus 5.5 --- crates/metafile/src/canvas.rs | 4 ++-- crates/metafile/src/cliprect.rs | 20 ++++++++++---------- crates/metafile/src/tests.rs | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 7a661fac..2745a465 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -536,7 +536,7 @@ impl Canvas { let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); if dev.len() >= 2 { let even_odd = self.even_odd; - self.push(Raw::Pattern { ops: dev, even_odd, tile }); + self.push(Raw::Pattern { ops: dev, even_odd, tile, region: None }); } } @@ -564,7 +564,7 @@ impl Canvas { } } let even_odd = self.even_odd; - self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }) }); + self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }), region: None }); } /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs index 882affbd..652ad35b 100644 --- a/crates/metafile/src/cliprect.rs +++ b/crates/metafile/src/cliprect.rs @@ -48,10 +48,10 @@ fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { /// Clips one drawn item; a shape that is both filled and stroked comes back as two items. pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { match raw { - Raw::Path { ops, fill, stroke, even_odd } => { + Raw::Path { ops, fill, stroke, even_odd, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if inside(b, c) { - return vec![Raw::Path { ops, fill, stroke, even_odd }]; + return vec![Raw::Path { ops, fill, stroke, even_odd, region }]; } if disjoint(b, c) { return Vec::new(); @@ -61,33 +61,33 @@ pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { if fill.is_some() { let f = clip_fill(&subs, c); if !f.is_empty() { - out.push(Raw::Path { ops: f, fill, stroke: None, even_odd }); + out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); } } if stroke.is_some() { let s = clip_stroke(&subs, c); if !s.is_empty() { - out.push(Raw::Path { ops: s, fill: None, stroke, even_odd }); + out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region }); } } out } - Raw::Pattern { ops, even_odd, tile } => { + Raw::Pattern { ops, even_odd, tile, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if inside(b, c) { - return vec![Raw::Pattern { ops, even_odd, tile }]; + return vec![Raw::Pattern { ops, even_odd, tile, region }]; } if disjoint(b, c) { return Vec::new(); } let f = clip_fill(&flatten(&ops), c); - if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile }] } + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } } - Raw::Bitmap { rect, img } => crop(rect, img, c).map(|(rect, img)| vec![Raw::Bitmap { rect, img }]).unwrap_or_default(), - Raw::Text(mut t) => { + Raw::Bitmap { rect, img, region } => crop(rect, img, c).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), + Raw::Text { text: mut t, region } => { let cur = t.clip.map_or(c, |[a, b, d, e]| intersect(c, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); t.clip = Some(cur.map(|v| v as f32)); - vec![Raw::Text(t)] + vec![Raw::Text { text: t, region }] } } } diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 634ecd36..81993a9e 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1733,7 +1733,7 @@ fn draw_image(id: u16, dst: [f32; 4]) -> Vec { fn emfplus_draw_image_places_the_bitmap() { let p = plus_pic(vec![prec(0x4008, 0x0500 | 4, &bitmap_2x2()), draw_image(4, [10.0, 10.0, 20.0, 40.0])]); match &p.items[0] { - Item::Bitmap { rect, width, height, rgba } => { + Item::Bitmap { rect, width, height, rgba, .. } => { assert_eq!((*width, *height), (2, 2)); assert_eq!(rect, &[0.0, 0.0, 20.0, 40.0]); assert_eq!(&rgba[..8], &[255, 0, 0, 255, 0, 255, 0, 255]); From 389c4dd5d0f9d865bd511104c45d5320f8731965 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:56:56 -0400 Subject: [PATCH 327/376] Fix up merged PRs: #588's polygon clip and nested metafiles with clip regions Move #588's clip.rs changes into cliprect.rs, carry main's per-item clip region through the polygon clipper, and build nested-metafile items with the region field main added (the nested picture's own regions are not carried over; each item takes the canvas's current clip). Co-Authored-By: Claude Opus 5.5 --- crates/metafile/src/cliprect.rs | 20 ++++++++++---------- crates/metafile/src/emfplus.rs | 21 ++++++++++++++------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs index 8290dd37..396f4402 100644 --- a/crates/metafile/src/cliprect.rs +++ b/crates/metafile/src/cliprect.rs @@ -143,10 +143,10 @@ fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool { pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() }; match raw { - Raw::Path { ops, fill, stroke, even_odd } => { + Raw::Path { ops, fill, stroke, even_odd, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if bbox_inside(b, clip) { - return vec![Raw::Path { ops, fill, stroke, even_odd }]; + return vec![Raw::Path { ops, fill, stroke, even_odd, region }]; } if disjoint(b, cb) { return Vec::new(); @@ -156,34 +156,34 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { if fill.is_some() { let f = clip_fill(&subs, clip); if !f.is_empty() { - out.push(Raw::Path { ops: f, fill, stroke: None, even_odd }); + out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); } } if stroke.is_some() { let s = clip_stroke(&subs, clip); if !s.is_empty() { - out.push(Raw::Path { ops: s, fill: None, stroke, even_odd }); + out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region }); } } out } - Raw::Pattern { ops, even_odd, tile } => { + Raw::Pattern { ops, even_odd, tile, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if bbox_inside(b, clip) { - return vec![Raw::Pattern { ops, even_odd, tile }]; + return vec![Raw::Pattern { ops, even_odd, tile, region }]; } if disjoint(b, cb) { return Vec::new(); } let f = clip_fill(&flatten(&ops), clip); - if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile }] } + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } } - Raw::Bitmap { rect, img } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img }]).unwrap_or_default(), - Raw::Text(mut t) => { + Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), + Raw::Text { text: mut t, region } => { // Text clips to a rectangle: the one around the polygon. let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); t.clip = Some(cur.map(|v| v as f32)); - vec![Raw::Text(t)] + vec![Raw::Text { text: t, region }] } } } diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index a8c93775..fb757b63 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -737,15 +737,21 @@ impl Painter { }) .collect() }; + // The nested picture's own clip regions are not carried over: each item takes the + // canvas's current clip. for item in &pic.items { if cv.full() { break; } let raw = match item { - Item::Path { segs, fill, stroke, even_odd } => { - Raw::Path { ops: ops(segs), fill: *fill, stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), even_odd: *even_odd } - } - Item::Pattern { segs, even_odd, tile } => Raw::Pattern { + Item::Path { segs, fill, stroke, even_odd, .. } => Raw::Path { + ops: ops(segs), + fill: *fill, + stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), + even_odd: *even_odd, + region: None, + }, + Item::Pattern { segs, even_odd, tile, .. } => Raw::Pattern { ops: ops(segs), even_odd: *even_odd, tile: Arc::new(RawTile { @@ -754,12 +760,13 @@ impl Painter { px: tile.rgba.clone(), cell: f64::from(tile.cell[0]) * scale, }), + region: None, }, - Item::Bitmap { rect, width, height, rgba } => { + Item::Bitmap { rect, width, height, rgba, .. } => { let (a, b) = (pt(rect[0], rect[1]), pt(rect[0] + rect[2], rect[1] + rect[3])); let mut img = Rgba { w: *width as usize, h: *height as usize, bottom_up: false, px: rgba.clone() }; crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); - Raw::Bitmap { rect: [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()], img } + Raw::Bitmap { rect: [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()], img, region: None } } Item::Text(t) => { let mut t = t.clone(); @@ -768,7 +775,7 @@ impl Painter { let (p, q) = (pt(x0, y0), pt(x1, y1)); t.clip = Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32]); } - Raw::Text(t) + Raw::Text { text: t, region: None } } }; cv.push_device(raw); From 31e22977f75bd2f3118d81db8e79c2ed71cb5428 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:05:00 -0400 Subject: [PATCH 328/376] Fix up merged PRs: the palette test skips UI commands that need a value (#440 hides them) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 85d55396..c6f1e640 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -305,7 +305,11 @@ mod tests { assert_eq!(exports[0].0, "app.exportDialog"); } // Open…, Save As…, Audio/Video from File… and Notes had the same duplicates: every UI command is the only entry with its label. - for (id, label, _, _) in crate::UI_COMMANDS { + // Commands that need a value (Set Zoom's percent) are left out of the palette. + for (id, label, _, params) in crate::UI_COMMANDS { + if deckcraft_engine::cmd::needs_arguments(params) { + continue; + } let same: Vec<_> = palette_items(&session, &label.to_lowercase()).into_iter().filter(|(_, l, _)| l == label).collect(); assert_eq!(same.len(), 1, "{label}"); assert_eq!(same[0].0, *id, "{label}"); From 0c3f4eee63a16f920d61d62e2de29f2fc2233afb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:07:31 -0400 Subject: [PATCH 329/376] Fix up merged PRs: Slide Master Preserve/placeholders know the notes and handout master views Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ribbon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 875eff5f..5c5bf74b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -3093,7 +3093,7 @@ fn master_checks(app: &SlideApp) -> (bool, bool, bool) { let preserve = match st.selection.target { Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve), Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve), - Target::Slides => false, + Target::Slides | Target::Handout | Target::NotesMaster => false, }; let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title())); let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind())); From b2b378a3f0b6d7bc6d4686ac6c8546846fabd0fa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:13:50 -0400 Subject: [PATCH 330/376] Fix up merged PRs: dedupe doc rows left by the union merges One EMF / WMF parity row naming both GDI clip regions and EMF+ playback; drop the stale Tables, Charts, chartex and SmartArt rows that came back with the chartex PRs; one SVG-gap sentence; one charts-gap paragraph. Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 9 ++------- docs/gaps.md | 22 +++++++--------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6a3cde7a..b99afa0e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -50,8 +50,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing; EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions; EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | @@ -91,13 +90,9 @@ the round trip even though DeckCraft doesn't model it. | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | Merges and both diagonal borders round-trip | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) | -| Tables with styles | yes | yes | | -| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | diff --git a/docs/gaps.md b/docs/gaps.md index ebba81af..824f7cbe 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -138,10 +138,8 @@ Detail in [file-format-parity.md](file-format-parity.md). ### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens, - pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ records are - not drawn). - pattern and hatch brushes, bitmaps and text; EMF+ only pictures play their EMF+ records; - clip regions are not drawn). + pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ only + pictures play their EMF+ records). Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback. - **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. - **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates). @@ -207,10 +205,6 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other - chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are not drawn. A combo - chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back - when the chart is rebuilt. chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else written from the model, with a plain chart fallback). Waterfalls float and total, funnels, @@ -220,13 +214,11 @@ Detail in [file-format-parity.md](file-format-parity.md). (their cached geography too, also through edits) and drawn as value-coloured tiles with a colour scale, since DeckCraft ships no map boundaries. Radar charts are drawn polar (filled or lines), stock charts as high-low lines with close dashes or up/down bars and saved as - `c:stockChart`, and combo charts start as columns with a line. Secondary-axis writing and - trendlines are tracked separately. - whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are drawn: linear, - exponential, logarithmic, polynomial, power and moving average, with forecast and the equation - and R² label. A moving average is drawn with neither label. A combo chart's secondary value axis - is read (`y2_*`, `Series.secondary_axis`) and - is not written back when the chart is rebuilt. + `c:stockChart`, and combo charts start as columns with a line. Trendlines round-trip + (`corpus_charts.rs`) and are drawn: linear, exponential, logarithmic, polynomial, power and + moving average, with forecast and the equation and R² label. A moving average is drawn with + neither label. A combo chart's secondary value axis is read (`y2_*`, + `Series.secondary_axis`) and is not written back when the chart is rebuilt. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). From a85024558d444b5aa9be83a7b875a5910f7ba3ed Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:16:40 -0400 Subject: [PATCH 331/376] Fix up merged PRs: Quick Access Toolbar defaults keep main's Export button and custom-show start Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f9b90f38..ed040f0f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1345,19 +1345,19 @@ mod tests { assert!(app.run("app.qat", json!({"add": "no.such"})).is_err()); app.run("app.qat", json!({"remove": "edit.redo"})).unwrap(); assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "show.fromStart", "insert.table", "view.pane"]); - assert_eq!(app.ui.qat[4].label, "Table"); + assert_eq!(app.ui.qat[5].label, "Table"); // Saved with the rest of the UI state and read back. let back: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); assert_eq!(back.qat, app.ui.qat); harness.run_steps(2); let app = harness.state_mut(); app.run("app.qat", json!({"remove": "view.pane", "params": {"pane": "selection"}})).unwrap(); - assert_eq!(app.ui.qat.len(), 5); + assert_eq!(app.ui.qat.len(), 6); app.run("app.qat", json!({"reset": true})).unwrap(); assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); // An older settings file without the toolbar gets the defaults. let old: UiState = serde_json::from_value(json!({"tab": "Home"})).unwrap(); - assert_eq!(old.qat.len(), 5); + assert_eq!(old.qat.len(), 6); } #[test] From 0609ca01cbf7a0d1828c08869f907d758f8fe8ee Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:27:15 -0400 Subject: [PATCH 332/376] Fix up merged PRs: right-click on the edited text box's frame selects that box Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 76f45079..25f71b9f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -998,7 +998,8 @@ impl Session { | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } - | Hit::Vertex { id, .. }, + | Hit::Vertex { id, .. } + | Hit::Frame { id }, ) => Some(*id), None => None, }; From 8ee597216dd03b049cbcfe8da75ebd17c02d7c40 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:31:01 -0400 Subject: [PATCH 333/376] Fix up landed PRs from review - #178: ToUnicode ranges looked up without indexing - #465: frame_num gaps are concealed (bounded) even when the SPS forbids them; cut streams keep playing - #471: control request lines up to 256 MB so picture/media inserts still fit - #473: a merge that loses its only row or column is removed, with regression tests - #521: AutoCorrect no longer rewrites real words (cant, wont, im) - #654: inherit_placeholder_geometry's doc comment back on its function Co-Authored-By: Claude Opus 5.5 --- apps/deckcraft/src/control_server.rs | 3 ++- crates/engine/src/cmd/text.rs | 3 --- crates/h264/src/decoder.rs | 5 ++--- crates/h264/src/synth_tests.rs | 11 ++++++----- crates/model/src/table.rs | 22 ++++++++++++++++++++-- crates/pdf/src/lib.rs | 2 +- crates/pptx/src/read/mod.rs | 8 ++++---- 7 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/deckcraft/src/control_server.rs b/apps/deckcraft/src/control_server.rs index 6dbf0740..966657b0 100644 --- a/apps/deckcraft/src/control_server.rs +++ b/apps/deckcraft/src/control_server.rs @@ -35,7 +35,8 @@ impl Default for Limits { fn default() -> Self { Self { max_clients: 16, - max_line: 16 * 1024 * 1024, + // Picture and media inserts carry base64 payloads: room for files of about 190 MB. + max_line: 256 * 1024 * 1024, queue: 64, idle_timeout: Duration::from_secs(600), write_timeout: Duration::from_secs(30), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index cce51e6d..990acf5d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -368,8 +368,6 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[ ("dont", "don't"), ("doesnt", "doesn't"), ("didnt", "didn't"), - ("cant", "can't"), - ("wont", "won't"), ("isnt", "isn't"), ("embarass", "embarrass"), ("enviroment", "environment"), @@ -379,7 +377,6 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[ ("goverment", "government"), ("happend", "happened"), ("i", "I"), - ("im", "I'm"), ("ive", "I've"), ("immediatly", "immediately"), ("independant", "independent"), diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 1735e9ba..37ef1f29 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -476,9 +476,8 @@ impl Decoder { } else { let max = sps.max_frame_num(); if sh.frame_num != self.prev_ref_frame_num && sh.frame_num != (self.prev_ref_frame_num + 1) % max { - // A gap is only legal when the SPS allows it (7.4.2.1.1); otherwise the stream is broken - // and filling it would only cost work, so the picture is rejected. - ensure!(sps.gaps_in_frame_num_allowed, "frame_num gap in a stream that does not allow gaps"); + // A gap is only legal when the SPS allows it (7.4.2.1.1), but a cut or lossy stream has + // them anyway: those are concealed too rather than dropping the picture. // frame_num gap (8.2.5.2): insert "non-existing" frames. Their samples are never used by // conforming streams; copy the latest decoded frame for concealment. let meta = Arc::new(output_meta(sps, pts, false)); diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index c2286b85..ec5f601d 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -592,12 +592,13 @@ fn gap_stream(gaps: bool) -> Vec { } #[test] -fn frame_num_gap_is_rejected_when_the_sps_forbids_gaps() { +fn frame_num_gap_is_concealed_even_when_the_sps_forbids_gaps() { + // A cut or lossy stream: the gap is filled (bounded) instead of dropping the picture. let mut dec = Decoder::with_threads(1); - let res = dec.decode(&gap_stream(false), 0); - let _ = dec.flush(); - assert!(res.is_err() || dec.take_error().is_some(), "a forbidden gap must be reported"); - assert_eq!(dec.stats().frame_num_gaps, 0); + let mut out = dec.decode(&gap_stream(false), 0).unwrap(); + out.extend(dec.flush()); + assert_eq!(out.len(), 2); + assert_eq!(dec.stats().frame_num_gaps, 1); } #[test] diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index af35746e..1abd1177 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -321,7 +321,8 @@ impl Merge { } else { self }; - (m.rs > 1 || m.gs > 1).then_some(m) + // A merge that lost its only row or column is gone, not a 0-span merge over its neighbours. + (m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m) } fn delete_col(self, at: usize) -> Option { let m = if at < self.c { @@ -331,7 +332,8 @@ impl Merge { } else { self }; - (m.rs > 1 || m.gs > 1).then_some(m) + // A merge that lost its only row or column is gone, not a 0-span merge over its neighbours. + (m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m) } } @@ -552,6 +554,22 @@ mod tests { assert_topology(&t); } + #[test] + fn deleting_the_only_row_or_column_of_a_merge_removes_it() { + // A 1x3 merge across a row: deleting that row leaves no merge behind. + let mut t = Table::new(3, 3, 300.0, 30.0); + assert!(t.merge(1, 0, 1, 2)); + assert!(t.delete_row(1)); + assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.grid_span <= 1)))); + assert_topology(&t); + // A 3x1 merge down a column: deleting that column leaves no merge behind. + let mut t = Table::new(3, 3, 300.0, 30.0); + assert!(t.merge(0, 1, 2, 1)); + assert!(t.delete_col(1)); + assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.row_span <= 1)))); + assert_topology(&t); + } + #[test] fn insert_delete_merge_split() { let mut t = Table::new(3, 3, 300.0, 30.0); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 3f3e4fb9..5f213e09 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -467,7 +467,7 @@ impl Exporter<'_> { .enumerate() .map(|(gi, g)| { let next = run.glyphs.get(gi + 1).map_or(g.1 + run.face.advance(g.0) * run.size / run.face.upem.max(1.0), |nx| nx.1); - let range = ranges[gi].clone(); + let range = ranges.get(gi).cloned().unwrap_or(text.len()..text.len()); KrillaGlyph::new(GlyphId::new(g.0), ((next - g.1) / run.size) as f32, 0.0, ((g.2 - y0) / run.size) as f32, 0.0, range, None) }) .collect(); diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index e0a35ec1..5f0f3747 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -745,10 +745,6 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> { cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect() } -/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the -/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a -/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left -/// is a rectangle. /// The anchor the theme's shape defaults (`a:objectDefaults/a:spDef/a:bodyPr`) give new shapes /// text, if any. fn theme_shape_anchor(t: &deckcraft_model::Theme) -> Option { @@ -796,6 +792,10 @@ fn default_text_anchor(p: &mut Presentation) { } } +/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the +/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a +/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left +/// is a rectangle. fn inherit_placeholder_geometry(p: &mut Presentation) { let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty()); // Masters first, then layouts (from their masters), then slides (from layouts and masters). From 2dc22843e4296af733ef792c3da9bb4821951904 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:31:11 -0400 Subject: [PATCH 334/376] Fix up merged PRs: the missing-recent test reads main's toast, which now carries open errors Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6ebaeedc..fec400d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1939,7 +1939,7 @@ mod tests { // drawn message is a second node that mentions the name (the OS text after it varies). harness.get_by_label("missing.deckcraft").click(); harness.run_steps(2); - assert!(harness.state().status.is_some(), "the open error was raised"); + assert!(harness.state().toast.is_some(), "the open error was raised"); assert!( harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry" From 2a410e54fa1635733d2af8be98e0a00cd3f7fa7c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:37:30 -0400 Subject: [PATCH 335/376] Fix up merged PRs: main's newer textures (document window, SmartArt previews, show grid) go through textures::upload so they stay within the GPU max texture side (#652) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/doc_window.rs | 2 +- crates/ui-egui/src/show.rs | 2 +- crates/ui-egui/src/textures.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs index 4445550d..dfff3d4b 100644 --- a/crates/ui-egui/src/doc_window.rs +++ b/crates/ui-egui/src/doc_window.rs @@ -164,7 +164,7 @@ fn ensure_texture( slide, &RenderOpts { scale: w as f64 / doc.slide_size.width.max(1.0), size: Some((w, h)), edit: true, threads: 0, ..Default::default() }, ); - let tex = ctx.load_texture("doc-window-slide", crate::textures::to_color_image(&img, grayscale), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ctx, "doc-window-slide", crate::textures::to_color_image(&img, grayscale)); if let Some(win) = app.doc_window.as_mut() { win.key = Some(key); win.tex = Some(tex.clone()); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 6406aacb..6e6268ad 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1689,7 +1689,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi i, &RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() }, ); - let tex = ctx.load_texture(format!("show-grid-{i}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false)); show.thumbs.push((i, tex.clone())); Some(tex) } diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 629f935a..9f52918a 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -89,7 +89,7 @@ impl Textures { let id = g.id; s.shapes = vec![g]; let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?; - let tex = ctx.load_texture(format!("smartart-{key}"), to_color_image(&img, false), TextureOptions::LINEAR); + let tex = upload(ctx, format!("smartart-{key}"), to_color_image(&img, false)); if self.smartart.len() > 300 { self.smartart.clear(); } From 7acb9faad1c90516cc5e883b29a8da6c179ce67b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:38:03 -0400 Subject: [PATCH 336/376] Fix up merged PRs: one format.tabs and one Tabs dialog for #567/#569 and #637 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #569 and #637 each added a `format.tabs` command and a Tabs dialog. Keep one of each: - format.tabs takes #567/#569's add/move/remove/clear/set plus #637's strict `set` (unknown alignment or non-object entries are errors, stops within 0.05 pt collapse, last wins) and opens the Tabs dialog when called with no fields, as #637 did. A defaultTab-only call no longer rewrites the stops. - The Tabs dialog is #569's; it now seeds from the dialog's params when Paragraph (or format.tabs) opened it, and on OK hands the applied stops back to Paragraph. - Tests adapted: an empty format.tabs opens the dialog; the Paragraph → Tabs test sets stops through #569's dialog fields. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/ruler_tests.rs | 4 +++- crates/ui-egui/src/dialogs.rs | 8 +++++++- crates/ui-egui/src/paragraph_dialog_tests.rs | 3 +-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs index 95c87c6c..e920ce93 100644 --- a/crates/engine/src/cmd/ruler_tests.rs +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -50,7 +50,9 @@ fn tabs_add_move_remove_clear_and_undo() { assert_eq!(tabs(&mut s).len(), 2); s.execute("format.tabs", &json!({"clear": true})).unwrap(); assert!(tabs(&mut s).is_empty()); - assert!(s.execute("format.tabs", &json!({})).is_err()); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog"); assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); for i in 0..100 { s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb890e1f..e570233b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2773,7 +2773,9 @@ const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "R /// list (inches, as PowerPoint's dialog) and applied together with OK. fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !d.fields.contains_key("stops") { - let r = app.session.execute("format.ruler", &json!({})).unwrap_or_default(); + // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the + // dialog; otherwise they are read from the caret's paragraph. + let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; let stops: Vec = r["tabs"] .as_array() .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) @@ -2846,6 +2848,10 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { app.set_status(e); + } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { + // Back in Paragraph, its Tabs… shows what was just applied. + ret.insert("tabs".into(), json!(set)); + ret.insert("defaultTab".into(), json!(dt)); } } ok || cancel diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs index cba8fe53..fb8e796b 100644 --- a/crates/ui-egui/src/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -71,8 +71,7 @@ fn tabs_from_paragraph_applies_a_stop_and_returns() { assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); { let d = h.state_mut().dialog.as_mut().unwrap(); - d.params["editing"] = json!(true); - d.params["edit"] = json!([{"pos": 144.0, "align": "ctr"}]); + d.fields.insert("stops".into(), "2:ctr".into()); } h.get_by_label("OK").click_accesskit(); // The Tabs OK frame restores Paragraph; the next frame fills its fields. From 1659e0656511f9e14f392fbf7b8c9c74bc39bc83 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:39:10 -0400 Subject: [PATCH 337/376] Fix up merged PRs: Draw Table's table lookup uses the checked shape id param (#544 after #97); its test no longer writes to /tmp Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 9ba5eed0..373c94c1 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -461,7 +461,8 @@ fn point_param(p: &Value, key: &str) -> Option<(f64, f64)> { /// The table under slide point `at` (frontmost), or the one named by `id`, with its origin. pub(crate) fn table_at(s: &Session, p: &Value, at: (f64, f64)) -> Option<(deckcraft_model::ShapeId, (f64, f64))> { let st = s.doc().ok()?; - let want = id_param(p, "id"); + // A malformed id names no table (never the one under the pointer instead). + let want = id_param(p, "id", "table.draw").ok()?; st.shapes().iter().rev().find_map(|sh| { let x = sh.xfrm?; let ShapeKind::Table(t) = &sh.kind else { return None }; From 3b28496c36dda4f724d26c3ecc1d137ef2dda61d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:40:09 -0400 Subject: [PATCH 338/376] Fix up merged PRs: main's newer params docs fit describe's grammar; help pages for bench and render-transition file.export's slide range and transition.sound's keys parse as parameters, and the subcommands main added since #303 have their own help page. Co-Authored-By: Claude Opus 5.5 --- apps/deckcraft-cli/src/help.rs | 24 ++++++++++++++++++++++++ apps/deckcraft-cli/tests/cli.rs | 2 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/transition.rs | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 4245172a..5803dc81 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -30,6 +30,20 @@ slide. Prints each image's path and size. --edit draw what the editor shows: placeholder prompts and their dashed outlines deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", + }, + Subcommand { + name: "render-transition", + usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png", + summary: "draw frames of the transition into a slide as PNG images", + details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N), +as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as +OUT-t0.250.png and so on. Prints each image's path, size, transition and time. + + --slide N the slide the transition leads into; indices start at 0 + --t T,T... the times to draw, comma-separated + --width PX the image width in pixels, twice the slide width in points by default + + deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png", }, Subcommand { name: "convert", @@ -114,6 +128,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT. --sample opens the sample presentation.", + }, + Subcommand { + name: "bench", + usage: "bench (FILE | --sample) [--runs N] [--json]", + summary: "time opening, drawing, saving and exporting a presentation", + details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to +save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the +median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json. + + deckcraft-cli bench deck.pptx --runs 10", }, Subcommand { name: "links", diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs index 3f1ed98b..8932c909 100644 --- a/apps/deckcraft-cli/tests/cli.rs +++ b/apps/deckcraft-cli/tests/cli.rs @@ -14,7 +14,7 @@ fn stderr(o: &Output) -> String { String::from_utf8_lossy(&o.stderr).into_owned() } -const SUBCOMMANDS: [&str; 9] = ["info", "render", "convert", "run", "commands", "describe", "app", "mcp", "links"]; +const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"]; fn words(s: &str) -> String { s.split_whitespace().collect::>().join(" ") diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7f47d742..9b1030d7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|\"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?}", has_doc, export), cmd!( query "file.info", "Info", diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs index 4c798125..4047faf2 100644 --- a/crates/engine/src/cmd/transition.rs +++ b/crates/engine/src/cmd/transition.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Sound", ["Transitions", "Timing"], None, - "{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?; none?: true (No Sound); stopPrevious?: true; loop?: bool (Loop Until Next Sound); index?} — the sound plays as the slide comes in", + "{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?, none?: true (No Sound), stopPrevious?: true, loop?: bool (Loop Until Next Sound), index?} — the sound plays as the slide comes in", has_slide, sound ), From 7dcfe0f989ffe4fb573162a39a349e07f1887069 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:40:18 -0400 Subject: [PATCH 339/376] Fix up merged PRs: a textless shape still copies as the objects marker so Ctrl+V pastes it (#182), not as a picture (#702) With only an image on the system clipboard egui-winit drops Ctrl+V, so #702's picture copy of a textless shape undid main's #182 fix. Text keeps #702's HTML, and empty plain text falls back to the marker. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/lib.rs | 58 ++++++++------------------------------- docs/ui-parity.md | 4 +-- 3 files changed, 15 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3883b475..ce2d2b7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -210,7 +210,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` | | 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% | | 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero | -| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them), and a shape with no text copies out as a picture | +| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0ad85c8..d5f3e36f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1120,59 +1120,24 @@ impl SlideApp { /// Offer the copy or cut on the system clipboard. /// - /// Slides go out as plain text. A textless shape goes out as a picture (Keynote and Pages - /// paste that). Text goes out as HTML plus the same plain words, so a paste back into - /// DeckCraft still uses the internal clipboard. + /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste + /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the + /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard + /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { - // Cut deletes the shapes, so the picture is taken first. - let picture = self.textless_selection_image(); let cmd = if cut { "edit.cut" } else { "edit.copy" }; let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); - if !self.session.clipboard.slides.is_empty() { - ctx.copy_text(system_clipboard_text(&text)); - return; - } - if let Some(img) = picture { - ctx.copy_image(img); - return; - } - if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) { + if self.session.clipboard.slides.is_empty() + && !html.is_empty() + && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + { return; } ctx.copy_text(system_clipboard_text(&text)); } - /// A picture of the selected shapes when none of them has text, on the slide (not a master). - fn textless_selection_image(&self) -> Option { - let st = self.session.active()?; - if st.selection.target != deckcraft_engine::Target::Slides || st.selection.text.is_some() || st.selection.shapes.is_empty() { - return None; - } - let mut plain = String::new(); - let mut any = false; - for id in &st.selection.shapes { - let Some(sh) = st.shape(*id) else { continue }; - any = true; - if let Some(t) = &sh.text { - if !plain.is_empty() { - plain.push('\n'); - } - plain.push_str(&t.text()); - } - } - if !any || !plain.is_empty() { - return None; - } - let slide = st.current_slide()?; - let img = deckcraft_render::render_shapes(&st.doc, slide, &st.selection.shapes, 2.0)?; - if img.width == 0 || img.height == 0 { - return None; - } - Some(textures::to_color_image(&img, false)) - } - /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1943,14 +1908,15 @@ mod tests { assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); } - /// A shape with no text copies out as a picture. + /// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can + /// still paste it here (#182). #[test] - fn a_textless_shape_copies_as_a_picture() { + fn a_textless_shape_copies_as_the_objects_marker() { let mut h = step_app(Services::default()); h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); h.event(egui::Event::Copy); h.run_steps(1); let cmds = &h.output().platform_output.commands; - assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyImage(img) if img.size[0] > 0 && img.size[1] > 0)), "{cmds:?}"); + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e72bc696..768512f2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -133,7 +133,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | | Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | -| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), a textless shape copies out as a picture, and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | +| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | ## Keyboard shortcuts @@ -223,7 +223,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 | | 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout | | 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip | -| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) and a picture of a textless shape with other apps | +| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | From 2173cf9569c44bb6296f618fc93c1725997c44d9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:40:38 -0400 Subject: [PATCH 340/376] Fix up merged PRs: Draw Table and Table Eraser sit in the Table Design tab Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ribbon.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d5c4501b..d2c87aea 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2816,6 +2816,18 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { }); }); }); + // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. + group(ui, |ui| { + use deckcraft_engine::ToolKind; + use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; + let cur = app.session.tool.kind.clone(); + for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { + let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); + } + } + }); } /// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame. @@ -2887,18 +2899,6 @@ fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::Col p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c); } p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside); - // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. - group(ui, |ui| { - use deckcraft_engine::ToolKind; - use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; - let cur = app.session.tool.kind.clone(); - for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { - let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); - if big_button_ex(ui, icon, label, true, false, on).0.clicked() { - app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); - } - } - }); } pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { From 367b079d64f6fdd7cc4063711ddbd70b5fbc6724 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:42:15 -0400 Subject: [PATCH 341/376] Fix up #609: spelling language tags can't leave the dictionary folders; long words get no suggestions Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 +- crates/engine/src/spell.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8bfbcbe4..7e367974 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,8 +18,8 @@ pub mod links; pub mod recovery; pub mod sample; pub mod sounds; -pub mod templates; pub mod spell; +pub mod templates; pub mod tools; use std::sync::Arc; diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 4c089812..a903f7ee 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -320,6 +320,10 @@ impl Dictionary { /// Up to `n` corrections for a misspelt `word`, best first. pub fn suggest(&self, word: &str, n: usize) -> Vec { + // Edit-distance candidates grow with the word: nothing is suggested for a run-on token. + if word.chars().count() > MAX_SUGGEST_CHARS { + return vec![]; + } let cap = word.chars().next().is_some_and(char::is_uppercase); let lower = if cap && word.chars().skip(1).all(|c| !c.is_uppercase()) { word.to_lowercase() } else { word.to_string() }; let w: Vec = lower.chars().collect(); @@ -438,8 +442,17 @@ fn names(lang: &str) -> (String, String) { (full, base) } +/// A language tag as dictionaries are named: 1–16 ASCII letters, digits, `-` or `_`. +fn valid_lang(lang: &str) -> bool { + (1..=16).contains(&lang.len()) && lang.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + /// The `.aff`/`.dic` pair for `lang` in `dirs`: the exact locale first, then any of its language. pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> { + // A language tag names a file in the dictionary folders and nothing else: no `..`, no separators. + if !valid_lang(lang) { + return None; + } let (full, base) = names(lang); let pair = |d: &Path, stem: &str| { let (a, b) = (d.join(format!("{stem}.aff")), d.join(format!("{stem}.dic"))); @@ -468,6 +481,9 @@ pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> { None } +/// Longest word `Dictionary::suggest` generates corrections for. +const MAX_SUGGEST_CHARS: usize = 64; + type Cache = Mutex>>>; fn cache() -> &'static Cache { @@ -529,6 +545,19 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; } } + #[test] + fn a_language_tag_cannot_leave_the_dictionary_folders() { + let dir = std::env::temp_dir().join(format!("deckcraft-dict-lang-{}", std::process::id())); + let inner = dir.join("dicts"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write(dir.join("evil.aff"), "SET UTF-8\n").unwrap(); + std::fs::write(dir.join("evil.dic"), "1\nword\n").unwrap(); + assert!(find_in(std::slice::from_ref(&inner), "../evil").is_none()); + assert!(find_in(std::slice::from_ref(&inner), "en/../../evil").is_none()); + assert!(!valid_lang("") && !valid_lang("a".repeat(17).as_str()) && valid_lang("en-US") && valid_lang("pt_BR")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn suggestions_come_from_edits_and_replacements() { let d = d(); From 740a57ddbabad8da84a1ad4d709274a80fb4ea65 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:43:36 -0400 Subject: [PATCH 342/376] Fix up merged PRs: table borders under a right-click select the table; the cell caret is drawn once Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 4 +++- crates/ui-egui/src/canvas.rs | 11 ----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 97a4cfb4..9c0dcabc 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1025,7 +1025,9 @@ impl Session { | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } | Hit::Vertex { id, .. } - | Hit::Frame { id }, + | Hit::Frame { id } + | Hit::TableColumn { id, .. } + | Hit::TableRow { id, .. }, ) => Some(*id), None => None, }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3c504f3d..33553a83 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -612,17 +612,6 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] .map(|q| xf.to_screen(f.affine * q)); poly(painter, pts, Stroke::new(2.0, t.accent)); - // The composition is in the cell text; underline it where that text is drawn (#101). - for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { - painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret)); - } - if !ts.is_range() { - let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); - if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { - painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret)); - } - } } // Smart guides, marquee, previews. for g in &app.session.tool.guides { From 8d68b2fe9d25c0b60ce60485a5dc3cc7cc795a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Wed, 14 Oct 2026 20:35:43 +0200 Subject: [PATCH 343/376] Render: an unfilled shape's glow follows its line A glow always filled the shape's whole silhouette, so a frame with no fill (a rounded-rectangle border with a soft glow, common in corporate decks) came out as a solid tinted panel over the slide. PowerPoint glows such a shape along its outline only and leaves the inside clear; a filled shape's or a picture's glow is unchanged. The line glow uses the same width and blur as the outline part of a filled shape's glow. Checked against PowerPoint's own PDF export of 31 real decks (597 slides): 66 slides closer to PowerPoint, none further; the frames' slides go from a mean pixel difference of ~29 to ~2.5-8 (of 255), and the 90th percentile over all slides from 12.9 to 10.0. --- crates/render/src/lib.rs | 6 +++++- crates/render/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index df7e43c2..89bb07ef 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1388,7 +1388,11 @@ impl Renderer { ctx.set_paint(color(c.with_alpha(255), 1.0)); ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round)); ctx.stroke_path(&silhouette); - ctx.fill_path(&silhouette); + // A shape without a fill (an outline, a frame) glows along its line only: the inside + // stays clear, as PowerPoint draws it. + if content.is_some() || fill_alpha.is_some() { + ctx.fill_path(&silhouette); + } ctx.pop_layer(); } if let Some(rf) = &e.reflection { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d7aec2a6..e5f8d450 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -344,6 +344,34 @@ fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() { assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true)); } +#[test] +fn glow_of_an_unfilled_shape_follows_its_line() { + // A frame (no fill, thin line) with a strong glow: the glow lines its edge, the inside stays + // the slide's white, as PowerPoint draws it. + let glow = |fill: Option| { + let mut sh = Shape { + id: ShapeId(610), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 300.0)), + fill, + line: Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 240)), 0.25)), + ..Default::default() + }; + sh.effects = Some(deckcraft_model::Effects { + glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)), radius: 8.0 }), + ..Default::default() + }); + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let img = glow(Some(Fill::None)); + let inside = img.pixel(300, 250); + assert!(inside[0] > 245 && inside[1] > 245, "{inside:?}"); + let edge = img.pixel(300, 97); + assert!(edge[0] < 230, "{edge:?}"); + // A filled shape's glow is still the whole silhouette's (the fill covers its inside). + let img = glow(Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255))))); + assert!(img.pixel(300, 94)[0] < 240); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); From 526e31d1f7d6c582805b7237cd52c928f7afa051 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:44:40 -0400 Subject: [PATCH 344/376] Fix up merged PRs: a picked file remembers main's Handout and Notes Master views (#479) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a19f7c7..dfc6a443 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -96,6 +96,8 @@ enum FilePlace { master: MasterId, layout: LayoutId, }, + /// The handout or notes master (one per presentation, so no id is needed). + NotesPage(Target), /// The document was open but had no current slide, master or layout. Unspecified, } @@ -1169,6 +1171,7 @@ impl SlideApp { }, None => FilePlace::Unspecified, }, + t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), }; FileTarget::Document { doc: d.uid, place } } @@ -1189,6 +1192,7 @@ impl SlideApp { matches!(d.selection.target, Target::Layout { master: mi, layout: li } if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) } + FilePlace::NotesPage(t) => d.selection.target == *t, FilePlace::Unspecified => false, } } @@ -1293,6 +1297,7 @@ impl SlideApp { .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) } + FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), FilePlace::Unspecified => Err("there was no slide to put it on".into()), } } From e90e26545388df50393872fec36db652a2c76ee0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:45:12 -0400 Subject: [PATCH 345/376] Fix up merged PRs: Grid Options guides follow #559, and its dialog test finds its own Spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turning guides on in Grid Options (#536) now adds the two centre guides to a presentation without any, as View › Guides does since #559 stopped drawing fixed centre lines. - The Grid Options dialog test looked for any "Spacing" label; main's ribbon has Line Spacing too, so it looks for "Spacing (pt)". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f8f188b1..d27f163c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -588,6 +588,10 @@ impl SlideApp { } if let Some(b) = guides { self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } } } else { self.dialog = Some(dialogs::Dialog::new("gridOptions")); @@ -1690,7 +1694,7 @@ mod tests { harness.run_steps(2); assert!(harness.query_by_label_contains("Display grid on screen").is_some()); assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); - assert!(harness.query_by_label_contains("Spacing").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); let show_grid = harness .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") .next() From 748b83a28442e45aa59875f0774f85821e5fb6b9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:46:09 -0400 Subject: [PATCH 346/376] Fix up merged PRs: one re-export of the table cell helpers (#618 and #626 both added it) Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1eacf0d7..df7e43c2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -57,7 +57,6 @@ pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts pub use table::cell_body as table_cell_body; pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights; -pub use table::{cell_body as table_cell_body, cell_rects as table_cell_rects}; pub use transition::{SlideParts, composite_transition, render_transition}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). From 560928af94e9149cbc44d4e46355cf42421a8672 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:46:41 -0400 Subject: [PATCH 347/376] Fix up merged PRs: cargo fmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dfc6a443..e5b5b884 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,8 +19,8 @@ pub mod icons; pub mod media; pub mod menus; pub mod panes; -pub mod qat; mod preview; +pub mod qat; pub mod ribbon; pub mod selpane; pub mod settings; @@ -1524,9 +1524,7 @@ impl SlideApp { let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); - if self.session.clipboard.slides.is_empty() - && !html.is_empty() - && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) { return; } From e6862f18ae91969a98a0305e272e0971c1d707e8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:49:10 -0400 Subject: [PATCH 348/376] Fix up merged PRs: WordArt warp handles on main's warp catalog and hover hit list - #519's handle test iterated the old `ALL` preset list; main has `CATALOG` now. - The hover hit-test match lists every Hit variant; add #519's Hit::Warp. - Drop the duplicate "tabs" dialog title that #569 and #637 both added. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 3 ++- crates/geom/src/warp/tests.rs | 2 +- crates/ui-egui/src/dialogs.rs | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 418c1de4..7dbf9038 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1104,7 +1104,8 @@ impl Session { | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } - | Hit::Vertex { id, .. }, + | Hit::Vertex { id, .. } + | Hit::Warp { id, .. }, ) => Some(*id), None => None, }; diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index ac231cf0..c04ddd10 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -281,7 +281,7 @@ fn hostile_adjusts_and_boxes_stay_finite() { #[test] fn every_preset_has_one_handle_per_adjust() { let r = rect(); - for name in ALL { + for name in CATALOG.iter().map(|p| p.id) { let hs = handles(name, &[], r); assert_eq!(hs.len(), adjust_count(name), "{name}"); let defaults = adjust_defaults(name); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e3b23245..c3010053 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -47,7 +47,6 @@ fn title(id: &str) -> &'static str { "paragraph" => "Paragraph", "tabs" => "Tabs", "font" => "Font", - "tabs" => "Tabs", "about" => "About DeckCraft", "preferences" => "Preferences", "gridOptions" => "Grid Options", From 69e212e3afef295f83972533e53739d21e72562b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:50:49 -0400 Subject: [PATCH 349/376] Fix up merged PRs: the IME notes test reads the formatted draft's text; drop an unused import Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ime_tests.rs | 4 ++-- crates/ui-egui/src/sorter.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index 911a2d1a..c9bc7dab 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -132,14 +132,14 @@ fn korean_composes_in_the_notes_pane() { for pre in ["ㄴ", "노", "놑", "노트"] { h.event(preedit(pre)); h.run_steps(1); - assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one"); + assert_eq!(h.state().notes_buf.2.text, pre, "the preedit replaces the last one"); } let ime = h.output().platform_output.ime.expect("the notes field turns the input method on"); let slide = h.state().slide_rect.unwrap(); assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide"); h.event(Event::Ime(ImeEvent::Commit("노트".into()))); h.run_steps(1); - assert_eq!(h.state().notes_buf.2, "노트"); + assert_eq!(h.state().notes_buf.2.text, "노트"); // Focus leaves the field (Escape): the notes are committed as one step. h.key_press(egui::Key::Escape); h.run_steps(2); diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 03afb638..e33842a0 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; -use crate::{SlideApp, widgets}; +use crate::SlideApp; /// Sorter-only state. Nothing here is part of the document. #[derive(Debug, Default)] From ed13dd6158fb403f943fe65770ff96ed0ab3f322 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:54:35 -0400 Subject: [PATCH 350/376] Fix up merged PRs: GIF export goes through main's export path The Export dialog's GIF choice runs through SlideApp::export_as (desktop and web download), .gif is a writable output extension for convert/run, and the CLI help pages describe GIF output and --export-params. A file.export `slides` range string ("1-3,5") works for GIF as for PDF. Co-Authored-By: Claude Opus 5.5 --- apps/deckcraft-cli/src/help.rs | 11 +++++++---- apps/deckcraft-cli/tests/convert.rs | 2 +- crates/engine/src/tests.rs | 3 ++- crates/ui-egui/src/lib.rs | 2 ++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 5803dc81..d6158f46 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -50,19 +50,22 @@ OUT-t0.250.png and so on. Prints each image's path, size, transition and time. usage: "convert IN OUT", summary: "save a presentation in the format OUT's extension names", details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names: -.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide. Any other -extension is written as .deckcraft. Prints OUT. +.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an +animated GIF with one frame per slide. Other extensions are refused. Prints OUT. deckcraft-cli convert deck.deckcraft deck.pptx", }, Subcommand { name: "run", - usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT] [--print]", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]", summary: "run commands on a presentation, then save or export it", details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample, else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the -current slide as .png or .jpg, or the presentation as .pdf or .pptx. +current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per +slide). --export-params passes file.export options as one JSON object, such as +'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF +(`deckcraft-cli describe file.export` lists them). Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one JSON object. Quote it for the shell. A command that needs no parameters is just its id. diff --git a/apps/deckcraft-cli/tests/convert.rs b/apps/deckcraft-cli/tests/convert.rs index 5de25e51..4f162994 100644 --- a/apps/deckcraft-cli/tests/convert.rs +++ b/apps/deckcraft-cli/tests/convert.rs @@ -21,7 +21,7 @@ fn convert_rejects_output_formats_it_cannot_write() { let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - for name in ["out.odp", "out.gif", "out.svg", "out.outline", "out"] { + for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] { let target = dir.join(name); let out = cli(&[Path::new("convert"), &input, &target]); assert!(!out.status.success(), "{name}: convert should fail"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e0d71985..a81450a0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -237,7 +237,8 @@ fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() { assert_eq!(f("a.pdf"), Some("pdf")); assert_eq!(f("a.jpg"), Some("jpeg")); assert_eq!(f("a.txt"), Some("outline")); - for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] { + assert_eq!(f("a.gif"), Some("gif")); + for path in ["a.odp", "a.bmp", "a.svg", "a.outline", "a", "dir.v2/a"] { assert_eq!(f(path), None, "{path}"); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a9876e0a..34545650 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -506,6 +506,7 @@ impl SlideApp { "pptx" => ("pptx", "pptx", false), "outline" | "txt" => ("txt", "outline", false), "deckcraft" => ("deckcraft", "deckcraft", false), + "gif" => ("gif", "gif", false), other => { self.set_status(format!("DeckCraft can't export “{other}”")); return false; @@ -535,6 +536,7 @@ impl SlideApp { Ok(if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }) } "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), + "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), other => deckcraft_engine::cmd::file::save_bytes(&doc, other), }; self.download(&suggested, bytes) From 0e51b7b1fcd933c86271cd51e7c487754e7925f6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:57:31 -0400 Subject: [PATCH 351/376] Fix up #67: use get_mut in the outline-morph cache instead of indexing Co-Authored-By: Claude Opus 5.5 --- crates/render/src/morph_path.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/render/src/morph_path.rs b/crates/render/src/morph_path.rs index 33af94ed..6947053f 100644 --- a/crates/render/src/morph_path.rs +++ b/crates/render/src/morph_path.rs @@ -76,9 +76,12 @@ impl Cache { self.morphs.len() - 1 } }; - let (_, m, used) = &mut self.morphs[k]; - *used = true; let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); + let Some((_, m, used)) = self.morphs.get_mut(k) else { + // Not reachable (k was just found or pushed); align afresh rather than panic. + return Source::of(pm, s).build().at(pm.t, scale(pm.from_box), scale(pm.to_box)); + }; + *used = true; m.at(pm.t, scale(pm.from_box), scale(pm.to_box)) } From a4ea5b66db01fb79577cfb482f6052f6caee455d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:59:50 -0400 Subject: [PATCH 352/376] Fix up #87: the web PNG/JPEG export path in SlideApp::export_as downloads every slide Port web_image_files into main's export_as (keeping its DPI tags) and drop the PR's old dialogs.rs export branch, which main no longer has. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 16 ------------ crates/ui-egui/src/lib.rs | 47 ++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 342c78a8..9ca9839a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2605,22 +2605,6 @@ mod alt_text_tests { } } -/// Images for the browser download: the current slide, or every slide (named `1.png`, `2.png`... like the native export) when `all`. -fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec)> { - let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; - let single = slides.len() == 1; - slides - .into_iter() - .enumerate() - .map(|(k, i)| { - let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }); - let bytes = if ext == "png" { img.to_png() } else { img.to_jpeg(92) }; - let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; - (file, bytes) - }) - .collect() -} - #[cfg(test)] mod tests { use deckcraft_model::text::{Action, Hyperlink, Run, TextBody}; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3df4477d..adac6654 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -470,10 +470,13 @@ impl SlideApp { let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; let bytes = match format { "png" | "jpeg" => { - let scale = 2.0; - let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale, ..Default::default() }); - let dpi = deckcraft_render::dpi_for_scale(scale); - Ok(if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }) + // Every slide for "all slides", one download each, named like the desktop export (#64). + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let mut any = false; + for (file, bytes) in web_image_files(&doc, slide, all, &name, ext) { + any = self.download(&file, Ok(bytes)) || any; + } + return any; } "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), other => deckcraft_engine::cmd::file::save_bytes(&doc, other), @@ -1292,6 +1295,25 @@ pub fn now_ms() -> f64 { } } +/// Images for the browser download: the current slide, or every slide (named `1.png`, +/// `2.png`... like the native export) when `all`. +fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec)> { + let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + let single = slides.len() == 1; + let scale = 2.0; + let dpi = deckcraft_render::dpi_for_scale(scale); + slides + .into_iter() + .enumerate() + .map(|(k, i)| { + let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; + let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; + (file, bytes) + }) + .collect() +} + #[cfg(target_arch = "wasm32")] fn js_now() -> f64 { js_sys::Date::now() @@ -1324,6 +1346,23 @@ mod tests { use super::*; + /// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). + #[test] + fn web_export_all_slides_gives_one_file_per_slide() { + let mut doc = deckcraft_model::Presentation::default(); + if let Some(first) = doc.slides.first().cloned() { + doc.slides.push(first.clone()); + doc.slides.push(first); + } + assert_eq!(doc.slides.len(), 3); + let all = web_image_files(&doc, 1, true, "Deck", "png"); + let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); + let one = web_image_files(&doc, 1, false, "Deck", "png"); + assert_eq!(one.len(), 1); + assert_eq!(one[0].0, "Deck.png"); + } + fn title_text(app: &SlideApp) -> String { let d = app.session.active().unwrap(); let sh = &d.doc.slides[0].shapes[0]; From 330fc16c8cb36874dba497632302a4d37d6e43e2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:02:43 -0400 Subject: [PATCH 353/376] Fix up merged PRs: zoom anchors on the unsnapped slide rect; zoom tests allow the pixel-grid snap Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 36317c1d..84a5a84a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -109,12 +109,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); - // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). + // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). The + // zoom anchor keeps the unsnapped rect, so snapping never accumulates into drift. let ppp = ui.ctx().pixels_per_point(); - let slide_rect = crate::textures::device_pixel_rect(content_rect.translate(-offset), ppp); + let exact_rect = content_rect.translate(-offset); + let slide_rect = crate::textures::device_pixel_rect(exact_rect, ppp); ui.data_mut(|d| { d.insert_temp(scroll_id, offset); - d.insert_temp(last_id, (slide_rect, scale)); + d.insert_temp(last_id, (exact_rect, scale)); }); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; @@ -907,8 +909,10 @@ mod tests { h.run_steps(30); } + /// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel + /// grid (#184). fn assert_near(a: Pos2, b: Pos2, what: &str) { - assert!((a - b).length() < 0.5, "{what}: {a:?} moved to {b:?}"); + assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}"); } /// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. From 320e3a971a9432de2634a0a3cbb360a5c665c36c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:04:16 -0400 Subject: [PATCH 354/376] Fix up merged PRs: one Editing group on Home, fmt, docs after the format/canvas/dialog merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home: #556's Find and Replace join main's Replace Fonts… in one Editing group with the Selection Pane, as PowerPoint lays it out; the separate group pushed Replace Fonts… out of reach at 1400 px. The Replace Fonts test picks the dialog's Replace button, not the ribbon's. - cargo fmt. - docs: parity summary regenerated with `cargo xtask parity`; ui-parity's ruler row says tab stops are set on the ruler (#567/#569) as well as in the Tabs dialog. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/review.rs | 1 - crates/ui-egui/src/canvas.rs | 1 - crates/ui-egui/src/lib.rs | 4 +-- crates/ui-egui/src/replacefonts_tests.rs | 3 ++- crates/ui-egui/src/ribbon.rs | 9 +++---- docs/parity-checklist.md | 34 +++++------------------- docs/ui-parity.md | 2 +- 7 files changed, 15 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 13f4bde0..0becb304 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -398,7 +398,6 @@ fn replace_word(s: &mut Session, p: &Value) -> Result { 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()) } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a732b413..e65b3c51 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1154,7 +1154,6 @@ fn grid_fits(span: f32, step: f32) -> bool { span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 } - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e1892a08..61b31f1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1401,12 +1401,12 @@ mod animpane_tests; #[cfg(test)] mod browsetheme_tests; #[cfg(test)] +mod compare_tests; +#[cfg(test)] mod eyedropper_tests; #[cfg(test)] mod huge_deck_tests; #[cfg(test)] -mod compare_tests; -#[cfg(test)] mod ime_tests; #[cfg(test)] mod replacefonts_tests; diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index cec694bf..205c65aa 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -50,7 +50,8 @@ fn ribbon_button_opens_the_dialog_and_replaces() { d.fields.insert("to".into(), "Lato".into()); } h.run_steps(2); - h.get_by_label("Replace").click(); + // The ribbon has a Replace button too (Find and Replace); the dialog's is drawn last. + h.query_all_by_label("Replace").last().expect("the dialog's Replace button").click(); h.run_steps(3); assert_eq!(run_font(&h).as_deref(), Some("Lato")); // The dialog stays open with a confirmation, like PowerPoint's. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index eb71d4d9..0298ea06 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1160,18 +1160,17 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. group(ui, |ui| { ui.vertical(|ui| { small(app, ui, Icon::Search, "Find", "app.find", json!({})); small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } }); - }); - group(ui, |ui| { // Also under Arrange, but that is disabled until something is selected (#132). big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - } }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index fe541d2e..8992c00c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,55 +3,33 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 80%** over 191 features. +**Weighted breadth parity: 82%** over 193 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 14 | 15 | 25 | 40% | -| P1 | 65 | 39 | 21 | 5 | 76% | -| P2 | 54 | 14 | 17 | 23 | 42% | -| 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% | +| P1 | 66 | 43 | 18 | 5 | 79% | +| P2 | 55 | 16 | 20 | 19 | 47% | | Area | Parity | |---|---| | Application shell | 81% | | Files | 71% | | Slides | 90% | -| Views | 93% | -| Views | 95% | -| Masters & themes | 85% | -| Views | 90% | +| Views | 98% | | Masters & themes | 88% | -| Shapes | 88% | | Shapes | 91% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 82% | | Tables | 95% | -| Text | 81% | -| Text | 81% | -| Tables | 89% | | Charts | 65% | -| SmartArt | 33% | -| Pictures | 52% | | SmartArt | 50% | -| Pictures | 48% | +| Pictures | 52% | | Media | 67% | | Transitions | 100% | | Animations | 79% | | Slide show | 68% | -| Review | 60% | -| Slide show | 66% | | Review | 70% | | Draw | 33% | | Undo & clipboard | 100% | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1e07a75..f5b43424 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -139,7 +139,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | | Exact size/position, lock aspect | yes | yes | -| Rulers with indent markers and tab stops | yes | rulers yes; tab stops are set in the Tabs dialog, not on the ruler | +| Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | From fc118f17102e8874c52350c7fd37dff8cc8bfab7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:04:42 -0400 Subject: [PATCH 355/376] Fix up #88: an image paste needs Command/Ctrl held on the V release Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 704dba95..a9258ce1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1018,7 +1018,11 @@ impl SlideApp { for e in &events { match e { egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, - egui::Event::Key { key: egui::Key::V, pressed: false, .. } => image_paste = !std::mem::take(&mut self.v_seen), + egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { + // Only a release with Command/Ctrl held is a paste: a bare V release is not. + let seen = std::mem::take(&mut self.v_seen); + image_paste = !seen && (modifiers.command || modifiers.ctrl); + } _ => {} } } @@ -1633,6 +1637,10 @@ mod tests { harness.event(v(false)); harness.run_steps(2); assert_eq!(pictures(harness.state()), 0); + // A V release without Command/Ctrl is not a paste. + harness.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. harness.event(v(false)); harness.run_steps(2); From c448a3dba9b965f9421494e09ee83920a67ea76a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:05:32 -0400 Subject: [PATCH 356/376] Fix up merged PRs: progress docs without the rows the union merges duplicated Stale status lines and table rows from the older PRs are folded into main's current ones. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 ----- docs/control-protocol.md | 2 -- docs/file-format-parity.md | 7 +------ docs/gaps.md | 9 +++------ docs/hardware-parity.md | 2 -- docs/mcp.md | 4 ++-- docs/parity-checklist.md | 3 --- docs/target-app-parity.md | 3 --- docs/ui-parity.md | 18 ++++-------------- 9 files changed, 10 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a901549a..458143c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,10 +33,6 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log for 2026-10-12; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; before that: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log: table cell editing and border resizing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export in the progress log, #171; P2 breadth 36% → 37%; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Background picture options; percentages unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON @@ -50,7 +46,6 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av | | Value | Kind | |---|---|---| | **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 39%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | -| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 37%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | | Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands | | **Ready for real work** | **~55%** | estimated: weighted dimensions below | | Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c6f45d92..a83a712d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -27,8 +27,6 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | | `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | -| `show.start` / `show.end` | `{from?: slide index, presenter?, reading?}` | Slide show | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 09de8768..597058c9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -12,8 +12,6 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text kept; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (formatted speaker notes) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171; previous: major, created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -82,12 +80,9 @@ the round trip even though DeckCraft doesn't model it. | Presentation, slide size, sections (p14), custom shows | yes | yes | | | Slide masters, layouts, placeholders and inheritance | yes | yes | | | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | -| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master | +| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master. Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown | | Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master | | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | -| Notes slides and notes master | yes | yes | Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown; no notes-master editing view | -| Handout master | no | no | | -| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | Effects: shadow, glow, soft edges, reflection | yes | yes | | diff --git a/docs/gaps.md b/docs/gaps.md index 7d23dc99..5ad63b51 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -25,7 +25,6 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export landed, #171; the video gap stays; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture options are pane controls, not a new dialog; dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with @@ -357,11 +356,9 @@ Detail in [ui-parity.md](ui-parity.md). - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Action Settings, Print, Insert Object…). 8–14 h. - **Dialogs**: 30 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, Replace Fonts, Print, - Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). Format Background picture options - (transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture) are - pane controls on `design.background`, not another dialog. 8–14 h. + Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. + Format Background picture options (transparency, tile, offset, scale, alignment, mirror, and Apply + to All copying a picture) are pane controls on `design.background`, not another dialog. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index acd70735..e2964b38 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,7 +1,6 @@ # Hardware parity with PowerPoint > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor rasters on the Retina pixel grid, #184; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -14,7 +13,6 @@ because it mostly follows from hardware use. | Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback | | Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) | | Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same | -| High-DPI / Retina | yes | yes | yes | yes | yes | | Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same | | High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes | | Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no | diff --git a/docs/mcp.md b/docs/mcp.md index 4cdcae69..5f6c5756 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -35,6 +35,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe COMMAND` prints any -`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` (GIF). `deckcraft-cli describe ID` prints any +`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` +(GIF). `deckcraft-cli describe COMMAND` prints any command's parameters. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 58a04ece..dd53bafd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -16,14 +16,11 @@ The summary between ` | P2 | 54 | 14 | 15 | 25 | 40% | | P1 | 65 | 39 | 21 | 5 | 76% | | P2 | 54 | 14 | 17 | 23 | 42% | -| P2 | 54 | 13 | 14 | 27 | 37% | | Area | Parity | |---|---| | Application shell | 81% | | Files | 71% | -| Application shell | 71% | -| Files | 72% | | Slides | 90% | | Views | 93% | | Masters & themes | 85% | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0319ca9c..d6474e8a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -17,7 +17,6 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (essentials discount no longer cites the palette Export failure, #79; percentages unchanged; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171: P2 breadth 36% → 37%, overall unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". @@ -30,7 +29,6 @@ number. | Number | Value | Kind | |---|---|---| | **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 39%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md) | -| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 37%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 | | Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists | | **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) | | **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) | @@ -243,7 +241,6 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc latin faces (one scheme on that master, not a library of saved font schemes, and not East Asian or complex-script faces). effect schemes (Design ▸ Effects: DeckCraft, Soft, Crisp, Lift, Glow, Plain), - backgrounds (solid, gradient, picture, pattern), slide size presets. backgrounds (solid, gradient, picture, pattern; a solid fill's transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture), slide size presets. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2853063c..64e68053 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -20,10 +20,6 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the frame of a text box being edited moves it; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (table cells: caret, clicks between cells, border resizing; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor slide rasters sit on the physical pixel grid, #184; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -93,9 +89,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options | -| Format Background pane | yes | yes | | Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | @@ -133,7 +127,6 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time | | Drag the frame of a text box being edited to move it | yes | yes | | Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells | -| Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | @@ -184,13 +177,10 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93). Colours, spacing, ribbon card and tab geometry follow the observations in `plan/powerpoint/01-observed-ui.md`; the ribbon and menu icons are original (~240, drawn in code) and must not imitate Microsoft's. Insert › Icons places Lucide stroke icons (ISC, release 1.56.0) -on the slide as shapes. No pixel comparison against PowerPoint screenshots has been run since -2026-10-07. Cross-app icon consistency is open (#73, #56). -`plan/powerpoint/01-observed-ui.md`; icons are original (~240, drawn in code or Lucide) and must not -imitate Microsoft's. The editing canvas, thumbnails and layout previews are drawn on the physical -pixel grid, so a Retina display does not blur them between pixels (#184). No pixel comparison -against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon consistency is open -(#73, #56). +on the slide as shapes. The editing canvas, thumbnails and layout previews are drawn on the +physical pixel grid, so a Retina display does not blur them between pixels (#184). No pixel +comparison against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon +consistency is open (#73, #56). ## Estimates From 6d23d5b5b55b91841e47d9bb104466477ef7d0e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:09:26 -0400 Subject: [PATCH 357/376] Fix up #139: the Tab test turns off AutoCorrect, which main now capitalises with Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64296eab..cda33549 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1661,7 +1661,11 @@ mod tests { app.logic(ui.ctx()); app.ui(ui); }, - SlideApp::new(Session::with_new(), Services::default()), + { + let mut session = Session::with_new(); + session.prefs.autocorrect = false; + SlideApp::new(session, Services::default()) + }, ); harness.run_steps(2); let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; From a0facd60af4be004ce86303d9bdb220830d1e7bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:10:48 -0400 Subject: [PATCH 358/376] Fix up merged PRs: cargo fmt, and the write_file tests may unwrap Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 ++ crates/engine/src/tools.rs | 7 +------ crates/ui-egui/src/backstage.rs | 6 +----- crates/ui-egui/src/lib.rs | 5 +---- crates/ui-egui/src/panes.rs | 7 +++++-- crates/ui-egui/src/sorter.rs | 2 +- 6 files changed, 11 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b0b76f29..127ae31f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -643,6 +643,8 @@ mod color_at_tests; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::write_file; fn scratch(name: &str) -> std::path::PathBuf { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 9c0dcabc..73f41ffb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1064,12 +1064,7 @@ impl Session { || matches!( self.hit_test(p, tol), Some( - Hit::Resize { .. } - | Hit::Rotate { .. } - | Hit::Adjust { .. } - | Hit::Frame { .. } - | Hit::TableColumn { .. } - | Hit::TableRow { .. } + Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Frame { .. } | Hit::TableColumn { .. } | Hit::TableRow { .. } ) ) { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index fa413c74..9eb50898 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -613,11 +613,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); } } - let resp = if missing { - resp.on_hover_text(format!("{path} — this file can't be found")) - } else { - resp.on_hover_text(&path) - }; + let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon // (a text glyph would need a font that has it). let x = ui.allocate_rect( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34545650..3b67ea71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1943,10 +1943,7 @@ mod tests { harness.get_by_label("missing.deckcraft").click(); harness.run_steps(2); assert!(harness.state().toast.is_some(), "the open error was raised"); - assert!( - harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, - "the open error is drawn on the page, next to the entry" - ); + assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); } /// #172: a Recent entry can be removed from the list. diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 76cde256..89ea94a4 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1096,8 +1096,11 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("idea-{name}"), crate::textures::to_color_image(&img, false), crate::textures::editor_texture_options()); + let tex = ui.ctx().load_texture( + format!("idea-{name}"), + crate::textures::to_color_image(&img, false), + crate::textures::editor_texture_options(), + ); cache.tex.push((name, tex, px, h)); }; for (id, _) in &ideas { diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 92d6d2e9..28b4b1a5 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -6,9 +6,9 @@ use deckcraft_model::{Presentation, SlideId}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; use serde_json::json; +use crate::SlideApp; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; -use crate::SlideApp; /// Sorter-only state. Nothing here is part of the document. #[derive(Debug, Default)] From 4b6f307ebbc777426a8bf1704de6eb11986ce683 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:19:02 -0400 Subject: [PATCH 359/376] Fix up #169: auto RTL lives only in the engine and right-aligns only unaligned text Drop the layout-time direction detection in crates/text (and its test); the engine's promote_rtl_if_needed sets align Right only when neither the paragraph nor what it inherits (list styles, layout, master) resolves an alignment. right_to_left_paragraphs types its original Arabic text again, after an explicit LTR so the toggle it tests starts from left-to-right. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 64 ++++++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 22 +++++++++--- crates/text/src/lib.rs | 7 ++-- crates/text/src/tests.rs | 13 ------- 4 files changed, 78 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0d43c62f..b2c924a3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -305,6 +305,7 @@ fn typed(s: &Session, body: &TextBody, at: Pos, text: &str) -> String { } pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) -> Result { + let inherited = inherited_aligns_for_edit(s); let text = { let st = s.doc()?; match &st.selection.text { @@ -324,7 +325,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) t.caret = c; for pi in at.0..=c.0 { if let Some(para) = body.paragraphs.get_mut(pi) { - promote_rtl_if_needed(para); + promote_rtl_if_needed(para, &inherited); } } c @@ -333,10 +334,37 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) Ok(json!({"caret": [pos.0, pos.1]})) } -fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph) { +/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an +/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false` +/// when the shape can't be resolved (notes, a shape that is gone). +fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] { + let mut out = [false; 9]; + let Ok(st) = s.doc() else { return out }; + let Some(sh) = st.shape(id) else { return out }; + let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out }; + for (lvl, set) in out.iter_mut().enumerate() { + let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() }; + *set = resolve::para(&ctx, sh, ¶).align.is_some(); + } + out +} + +/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes). +fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] { + match s.doc().ok().and_then(|st| st.selection.text.clone()) { + Some(t) if !t.notes => inherited_aligns(s, t.shape), + _ => [false; 9], + } +} + +/// A paragraph with no direction of its own whose first strong character is right-to-left +/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the +/// paragraph or what it inherits, `inherited`) sets its alignment. +fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) { if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { para.props.rtl = Some(true); - if para.props.align.is_none() { + let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false); + if para.props.align.is_none() && !inherits_align { para.props.align = Some(Align::Right); } } @@ -612,6 +640,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result { } pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { + let inherited = inherited_aligns_for_edit(s); let pos = edit_text(s, |body, t| { let (a, b) = t.ordered(); let at = ed::delete(body, a, b); @@ -620,7 +649,7 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { t.caret = c; for pi in at.0..=c.0 { if let Some(para) = body.paragraphs.get_mut(pi) { - promote_rtl_if_needed(para); + promote_rtl_if_needed(para, &inherited); } } c @@ -1056,6 +1085,7 @@ fn set(s: &mut Session, p: &Value) -> Result { None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, }; let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); + let inherited = inherited_aligns(s, id); edit_shapes(s, &json!({"id": id}), "text.set", |sh| { let target = match (&mut sh.kind, cell) { (ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text), @@ -1074,7 +1104,7 @@ fn set(s: &mut Session, p: &Value) -> Result { para.level = *lv; para.props = pp.clone(); } - promote_rtl_if_needed(para); + promote_rtl_if_needed(para, &inherited); for r in &mut para.runs { r.props = keep.clone(); } @@ -1284,6 +1314,30 @@ fn replace_font(s: &mut Session, p: &Value) -> Result { Ok(json!({"replaced": n})) } +#[cfg(test)] +mod rtl_promote_tests { + use deckcraft_model::text::{Align, Paragraph, Run}; + + use super::promote_rtl_if_needed; + + fn para(text: &str) -> Paragraph { + Paragraph { runs: vec![Run::new(text)], ..Default::default() } + } + + #[test] + fn right_aligns_only_when_no_alignment_is_set_or_inherited() { + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right))); + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[true; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), None)); + let mut p = para("Hello مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (None, None)); + } +} + #[cfg(test)] mod replace_font_tests { use serde_json::json; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e62ce1a8..663324aa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -488,7 +488,9 @@ fn right_to_left_paragraphs() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "Year 2024 (Retrait) only"})).unwrap(); + // Explicitly left-to-right first, so typing Arabic doesn't already turn it right-to-left (#169). + s.execute("format.rtl", &json!({"on": false})).unwrap(); + s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap(); s.execute("format.rtl", &json!({})).unwrap(); let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); assert_eq!(para(&s).rtl, Some(true)); @@ -530,16 +532,26 @@ fn typing_kurdish_or_arabic_auto_detects_rtl_and_advances_caret_smoothly() { } last_x = cx; - // Caret must remain near the right margin (e.g. > 400.0 pt in title placeholder) - // and must NOT jump back to the left margin (e.g. ~7.2 pt). - assert!(cx > 400.0, "caret should not jump to left margin: {cx}"); + // The title is centred: the caret must NOT jump back to the left margin (~7.2 pt). + assert!(cx > 100.0, "caret should not jump to left margin: {cx}"); } // Caret advances leftwards overall as text is appended assert!(last_x < first_x.unwrap(), "caret should advance leftwards: last={last_x} first={:?}", first_x); let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); assert_eq!(para.rtl, Some(true), "auto-detected as RTL"); - assert_eq!(para.align, Some(deckcraft_model::text::Align::Right), "aligned right"); + assert_eq!(para.align, None, "the title keeps the alignment it inherits"); + + // A text box inherits the deck's default (left) alignment, which it keeps too. + s.execute("text.exit", &json!({})).ok(); + let id = s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": ""})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap(); + let st = s.doc().unwrap(); + let sh = st.shape(deckcraft_model::ShapeId(id as u32)).unwrap(); + let para = sh.text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(true)); + assert_eq!(para.align, None, "the text box keeps the alignment it inherits"); } #[test] diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 70c58a29..b9e28fb1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -712,11 +712,8 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, rtl: false, }; }; - let mut props = resolve::para(ctx, shape, para); - let rtl = props.rtl.unwrap_or_else(|| deckcraft_model::bidi::first_strong_is_rtl(¶.text())); - if rtl && para.props.align.is_none() { - props.align = Some(Align::Right); - } + let props = resolve::para(ctx, shape, para); + let rtl = props.rtl.unwrap_or(false); let mut styles: Vec