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
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