Merge PR #520 from NikitkaRa: Insert: Summary, Section and Slide Zoom with Return to Zoom

Fix-ups in the merge (review): zoom targets, sections and titles are looked up with get()
instead of indexing; Hyperlink literals carry both main's sound fields and zoom_return;
duplicated test statements left by the test-file union are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-14 15:58:31 -04:00
co-authored by Claude Opus 5.5
20 changed files with 458 additions and 18 deletions
+27 -1
View File
@@ -4,7 +4,7 @@
use std::collections::BTreeMap;
use deckcraft_model::Presentation;
use deckcraft_model::text::Action;
use deckcraft_model::text::{Action, Hyperlink};
use crate::Timeline;
use crate::interact::SlideRun;
@@ -78,6 +78,9 @@ pub struct ShowState {
order: Option<Vec<usize>>,
/// Name of the custom show being played, when there is one.
custom: Option<String>,
/// Return to Zoom: (zoom slide, last zoomed slide). Advancing past the last zoomed slide goes
/// back to the zoom slide, fully built.
pub zoom: Option<(usize, usize)>,
}
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
@@ -252,6 +255,16 @@ impl ShowState {
self.playing = true;
return ShowAction::PlayStep(self.step);
}
if let Some((back, until)) = self.zoom
&& until == self.slide
&& back < pres.slides.len()
{
self.zoom = None;
self.enter(pres, back);
self.step = ShowState::steps(pres, back);
self.playing = false;
return ShowAction::Slide(back);
}
match self.next_index(pres) {
Some(i) => {
self.enter(pres, i);
@@ -331,6 +344,7 @@ impl ShowState {
if i >= pres.slides.len() {
return ShowAction::None;
}
self.zoom = None;
self.enter(pres, i);
ShowAction::Slide(i)
}
@@ -363,6 +377,18 @@ impl ShowState {
}
}
/// Follow a clicked link, honouring a zoom's Return to Zoom (see [`Hyperlink::zoom_return`]).
pub fn follow_link(&mut self, pres: &Presentation, link: &Hyperlink, last_viewed: Option<usize>) -> LinkJump {
let from = self.slide;
let jump = self.follow(pres, &link.action, last_viewed);
if let Some(until) = link.zoom_return.and_then(|id| pres.slides.iter().position(|s| s.id == id))
&& matches!(jump, LinkJump::Show(ShowAction::Slide(_)))
{
self.zoom = Some((from, until));
}
jump
}
/// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the
/// show uses timings.
pub fn advance_after(&self, pres: &Presentation) -> Option<f64> {
+32
View File
@@ -996,3 +996,35 @@ fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() {
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));
}
/// Zoom with Return to Zoom: after the zoomed slides the show goes back to the zoom slide, fully
/// built; without it, or after jumping elsewhere, the show carries on in order.
#[test]
fn show_returns_to_the_zoom_slide() {
use deckcraft_model::text::{Action, Hyperlink};
let p = pres(6);
let link = |to: u32, last: Option<u32>| Hyperlink {
action: Action::Slide { slide: SlideId(1000 + to) },
tooltip: String::new(),
highlight_click: false,
zoom_return: last.map(|l| SlideId(1000 + l)),
sound: None,
stop_sound: false,
};
let mut s = ShowState::new(&p);
// A section zoom on slide 0 for slides 2–3.
assert_eq!(s.follow_link(&p, &link(2, Some(3)), None), LinkJump::Show(ShowAction::Slide(2)));
assert_eq!(s.next(&p), ShowAction::Slide(3));
assert_eq!(s.next(&p), ShowAction::Slide(0), "back to the zoom");
assert_eq!(s.step, ShowState::steps(&p, 0));
assert_eq!(s.next(&p), ShowAction::Slide(1), "then on in order");
// Without Return to Zoom the show carries on.
let mut s = ShowState::new(&p);
s.follow_link(&p, &link(4, None), None);
assert_eq!(s.next(&p), ShowAction::Slide(5));
// Jumping elsewhere (See All Slides, a number) forgets the zoom.
let mut s = ShowState::new(&p);
s.follow_link(&p, &link(2, Some(2)), None);
s.goto(&p, 4);
assert_eq!(s.next(&p), ShowAction::Slide(5));
}
+10 -2
View File
@@ -1584,6 +1584,7 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
highlight_click: bool_or(p, "highlightClick", false),
sound: None,
stop_sound: false,
zoom_return: None,
};
if s.doc()?.selection.text.is_some() {
return super::text::link_text(s, link, &display);
@@ -1632,7 +1633,7 @@ fn shape_action(s: &mut Session, p: &Value) -> Result<Value> {
};
let highlight = bool_param(p, "highlight").unwrap_or(true);
let tooltip = str_param(p, "tooltip").unwrap_or("").to_string();
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false });
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None });
edit_shapes(s, p, CMD, |sh| {
if hover {
sh.hover = link.clone();
@@ -1665,7 +1666,14 @@ fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
xfrm: Some(rect),
geom: Geom::preset(preset),
style: Some(ShapeStyle::accent(SchemeSlot::Accent1)),
click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false }),
click: action.map(|a| Hyperlink {
action: a,
tooltip: String::new(),
highlight_click: true,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
};
let id = add_shape(s, shape, true)?;
+2
View File
@@ -26,6 +26,7 @@ pub mod smartart;
pub mod table;
pub mod text;
pub mod transition;
pub mod zoom;
use deckcraft_geom::Xfrm;
use deckcraft_model::ShapeId;
@@ -183,6 +184,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(inspect::specs());
v.extend(media::specs());
v.extend(artistic::specs());
v.extend(zoom::specs());
v
})
}
+191
View File
@@ -0,0 +1,191 @@
//! Insert ▸ Zoom: Summary Zoom, Section Zoom and Slide Zoom.
//!
//! A zoom is a picture of the target slide that jumps to it in the slide show. With Return to
//! Zoom (on for summary and section zooms, as in PowerPoint) the show comes back to the zoom
//! slide after the target slide, or the target section's last slide. In .pptx a zoom is saved as
//! the linked picture PowerPoint itself writes as the fallback for older versions.
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::style::PictureFill;
use deckcraft_model::text::{Action, Hyperlink, TextBody};
use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, SlideId, defaults};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"insert.zoom",
"Zoom",
["Insert", "Links"],
None,
"{kind?: slide|section|summary (default slide); slides?: [index] (slide and summary zooms); sections?: [index] (section zoom); returnToZoom?: bool (default: on for section and summary, off for slide)}",
has_slide,
zoom
)]
}
/// One zoom tile: the slide it shows and jumps to, and the slide after which the show returns.
struct Target {
slide: SlideId,
index: usize,
last: SlideId,
}
/// `n` tiles of `aspect` (w / h) laid out in a centred grid inside `area`.
fn grid(n: usize, area: Xfrm, aspect: f64) -> Vec<Xfrm> {
let cols = (n as f64).sqrt().ceil().max(1.0) as usize;
let rows = n.div_ceil(cols).max(1);
let gap = 12.0;
let cw = (area.w - gap * (cols as f64 - 1.0)) / cols as f64;
let ch = (area.h - gap * (rows as f64 - 1.0)) / rows as f64;
let (w, h) = if cw / ch > aspect { (ch * aspect, ch) } else { (cw, cw / aspect) };
// A single slide zoom is a quarter of the slide wide, as PowerPoint inserts it.
let (w, h) = if n == 1 { (w.min(area.w / 2.0), h.min(area.w / 2.0 / aspect)) } else { (w, h) };
let total_w = cols.min(n) as f64 * w + gap * (cols.min(n) as f64 - 1.0);
let total_h = rows as f64 * h + gap * (rows as f64 - 1.0);
let (x0, y0) = (area.x + (area.w - total_w) / 2.0, area.y + (area.h - total_h) / 2.0);
(0..n).map(|k| Xfrm::new(x0 + (k % cols) as f64 * (w + gap), y0 + (k / cols) as f64 * (h + gap), w, h)).collect()
}
fn indices(p: &Value, key: &str, n: usize, what: &str) -> Result<Vec<usize>> {
let Some(Value::Array(a)) = p.get(key) else { return Ok(vec![]) };
let mut v = vec![];
for x in a {
let i = x
.as_u64()
.map(|i| i as usize)
.filter(|i| *i < n)
.ok_or_else(|| bad("insert.zoom", format!("there is no {what} {x} (indices count from 0)")))?;
if !v.contains(&i) {
v.push(i);
}
}
Ok(v)
}
fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_param(p, "kind").unwrap_or("slide");
let st = s.doc()?;
let doc = &st.doc;
let n = doc.slides.len();
let targets: Vec<Target> = match kind {
"slide" => indices(p, "slides", n, "slide")?
.into_iter()
.filter_map(|i| doc.slides.get(i).map(|sl| Target { slide: sl.id, index: i, last: sl.id }))
.collect(),
"section" => {
let secs = indices(p, "sections", doc.sections.len(), "section")?;
secs.into_iter()
.filter_map(|k| {
let sec = doc.sections.get(k)?;
let first = *sec.slides.first()?;
let last = *sec.slides.last()?;
Some(Target { slide: first, index: doc.slide_index(first)?, last })
})
.collect()
}
"summary" => {
let mut starts = indices(p, "slides", n, "slide")?;
if starts.is_empty() {
// Default: the first slide of every section, or every slide's start when there are none.
starts = doc.sections.iter().filter_map(|sec| sec.slides.first().and_then(|id| doc.slide_index(*id))).collect();
}
starts.sort_unstable();
starts
.iter()
.enumerate()
.filter_map(|(k, &i)| {
// Each part runs to the slide before the next part (or the end).
let end = starts.get(k + 1).map(|j| j.saturating_sub(1)).unwrap_or(n.saturating_sub(1)).max(i);
Some(Target { slide: doc.slides.get(i)?.id, index: i, last: doc.slides.get(end)?.id })
})
.collect()
}
other => return Err(bad("insert.zoom", format!("unknown zoom kind `{other}` (slide, section or summary)"))),
};
if targets.is_empty() {
return Err(bad("insert.zoom", "choose the slides (or sections) to zoom to"));
}
let back = bool_or(p, "returnToZoom", kind != "slide");
let size = doc.slide_size;
// Pictures of the targets, about 480 px wide.
let scale = (480.0 / size.width.max(1.0)).clamp(0.05, 4.0);
let pictures: Vec<(Vec<u8>, String)> = targets
.iter()
.map(|t| {
let (png, _, _) = super::file::render_png(doc, t.index, scale, false);
let title = doc.slides.get(t.index).map(|sl| sl.title()).unwrap_or_default();
let label =
if title.trim().is_empty() { format!("Slide {}", t.index + 1) } else { title.lines().next().unwrap_or("").trim().to_string() };
(png, label)
})
.collect();
let summary = kind == "summary";
let first_target = targets.iter().map(|t| t.index).min().unwrap_or(0);
s.edit(|doc, sel| {
let aspect = size.width / size.height.max(1.0);
let area = if summary {
Xfrm::new(size.width * 0.06, size.height * 0.26, size.width * 0.88, size.height * 0.66)
} else if targets.len() == 1 {
Xfrm::new(size.width * 0.5, size.height * 0.5, size.width * 0.45, size.height * 0.45)
} else {
Xfrm::new(size.width * 0.08, size.height * 0.12, size.width * 0.84, size.height * 0.8)
};
let boxes = grid(targets.len(), area, aspect);
let mut shapes = vec![];
for ((t, (png, label)), b) in targets.iter().zip(pictures).zip(boxes) {
let media = doc.add_media(&format!("zoom-{}.png", t.index + 1), "image/png", png);
shapes.push(Shape {
id: ShapeId(doc.alloc_id()),
name: format!(
"{} Zoom {}",
if summary {
"Summary"
} else if kind == "section" {
"Section"
} else {
"Slide"
},
t.index + 1
),
descr: label.clone(),
xfrm: Some(b),
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
click: Some(Hyperlink {
action: Action::Slide { slide: t.slide },
tooltip: label,
highlight_click: false,
zoom_return: back.then_some(t.last),
sound: None,
stop_sound: false,
}),
..Default::default()
});
}
let ids: Vec<ShapeId> = shapes.iter().map(|x| x.id).collect();
if summary {
// A new Title Only slide before the first part, as PowerPoint makes it.
let layout =
defaults::layout_of_kind(doc, LayoutType::TitleOnly).or_else(|| doc.slides.get(first_target).map(|x| x.layout)).unwrap_or_default();
let mut slide = defaults::new_slide(doc, layout);
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())) {
sh.text = Some(TextBody::from_text("Summary"));
}
slide.shapes.extend(shapes);
let at = first_target.min(doc.slides.len());
doc.slides.insert(at, Arc::new(slide));
sel.slide = at;
sel.slides.clear();
} else {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.zoom", "no slide"))?;
list.extend(shapes);
}
sel.shapes = ids.clone();
sel.text = None;
Ok(json!({"kind": kind, "ids": ids, "returnToZoom": back, "slide": sel.slide}))
})
}
+8 -1
View File
@@ -20,7 +20,14 @@ fn shape(p: &mut Presentation, name: &str, text: &str, x: f64, y: f64, w: f64, h
}
fn link_to(slide: SlideId) -> Option<Hyperlink> {
Some(Hyperlink { action: Action::Slide { slide }, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false })
Some(Hyperlink {
action: Action::Slide { slide },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
})
}
/// Clicking `trigger` makes `target` disappear.
+9 -2
View File
@@ -171,6 +171,7 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
});
let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
// The title's master style colours its text tx1; a link is hlink and underlined instead.
@@ -179,8 +180,14 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng")));
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink));
// A slide link looks the same.
let slide =
Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false });
let slide = Some(Hyperlink {
action: Action::Slide { slide: s.id },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
});
assert_eq!(resolve::run(&ctx, title, &para, &RunProps { link: slide, ..Default::default() }).fill, hlink);
// The run's own colour and underline win; u="none" removes the underline.
let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
+4
View File
@@ -344,6 +344,10 @@ pub struct Hyperlink {
/// `endSnd`: stop the sound that is already playing.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stop_sound: bool,
/// Zoom (Insert ▸ Zoom) with Return to Zoom: in the show, after this slide is shown and
/// advanced past, go back to the slide with the zoom. Saved to .pptx as a plain slide link.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zoom_return: Option<crate::SlideId>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+1
View File
@@ -489,6 +489,7 @@ pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> {
highlight_click: h.bool("highlightClick").unwrap_or(false),
stop_sound: h.bool("endSnd").unwrap_or(false),
sound: h.child("snd").and_then(|s| media_ref(imp, part, s)),
zoom_return: None,
})
}
+1
View File
@@ -783,6 +783,7 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
});
}
cnvpr(w, x, o, id, &s2);
+1
View File
@@ -64,6 +64,7 @@ fn talk() -> Presentation {
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
},
+20 -4
View File
@@ -14,7 +14,7 @@ use deckcraft_model::{Comment, CustomShow, Geom, LayoutType, PhType, Presentatio
const CHIME: &[u8] = b"RIFFdeckcraft-chime-WAVEfmt ";
fn link(action: Action) -> Hyperlink {
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false }
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
}
fn box_at(p: &mut Presentation, name: &str, x: f64, y: f64) -> Shape {
@@ -72,6 +72,7 @@ fn deck() -> Presentation {
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
},
@@ -97,6 +98,7 @@ fn deck() -> Presentation {
highlight_click: true,
sound: None,
stop_sound: false,
zoom_return: None,
});
a.shapes.push(site);
@@ -116,13 +118,26 @@ fn deck() -> Presentation {
}
let mut hover = box_at(&mut p, "Hover", 40.0, 500.0);
hover.hover =
Some(Hyperlink { action: Action::PreviousSlide, tooltip: "Back one".into(), highlight_click: false, sound: None, stop_sound: true });
hover.hover = Some(Hyperlink {
action: Action::PreviousSlide,
tooltip: "Back one".into(),
highlight_click: false,
sound: None,
stop_sound: true,
zoom_return: None,
});
a.shapes.push(hover);
let mut home = box_at(&mut p, "Home", 800.0, 460.0);
home.geom = Geom::preset("actionButtonHome");
home.click = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false });
home.click = Some(Hyperlink {
action: Action::FirstSlide,
tooltip: String::new(),
highlight_click: true,
sound: None,
stop_sound: false,
zoom_return: None,
});
a.shapes.push(home);
let mut board = box_at(&mut p, "To board", 140.0, 500.0);
@@ -132,6 +147,7 @@ fn deck() -> Presentation {
highlight_click: false,
sound: Some(chime),
stop_sound: false,
zoom_return: None,
});
a.shapes.push(board);
+1
View File
@@ -156,6 +156,7 @@ fn sections() -> Presentation {
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
},
+76 -3
View File
@@ -84,6 +84,7 @@ pub fn rich_deck() -> Presentation {
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
},
@@ -155,7 +156,14 @@ pub fn rich_deck() -> Presentation {
}],
..Default::default()
});
b.click = Some(Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false });
b.click = Some(Hyperlink {
action: Action::NextSlide,
tooltip: String::new(),
highlight_click: true,
sound: None,
stop_sound: false,
zoom_return: None,
});
let mut c = shape(&mut p, "Freeform", 450.0, 120.0, 100.0, 100.0);
c.geom = Geom::Custom {
paths: vec![CustomPath {
@@ -339,8 +347,14 @@ pub fn rich_deck() -> Presentation {
let target = p.slides[1].id;
let mut link = shape(&mut p, "Go to shapes", 600.0, 450.0, 200.0, 40.0);
link.text = Some(TextBody::from_text("Back to shapes"));
link.click =
Some(Hyperlink { action: Action::Slide { slide: target }, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false });
link.click = Some(Hyperlink {
action: Action::Slide { slide: target },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
});
let mut eq = shape(&mut p, "Equation", 100.0, 450.0, 300.0, 40.0);
eq.text = Some(TextBody {
paragraphs: vec![Paragraph {
@@ -1735,3 +1749,62 @@ fn legacy_ole_object_is_written_in_the_fallback_form() {
assert!(preview.is_some(), "the preview picture is kept");
assert!(parts.iter().any(|(_, k, _)| k.ends_with("/oleObject")), "the embedding is kept: {parts:?}");
}
/// Insert › Zoom: slide, section and summary zooms are linked pictures of their targets, with
/// Return to Zoom on section and summary zooms; a summary zoom makes its own slide; all of it is
/// one undo step and saves as linked pictures.
#[test]
fn zoom_inserts_linked_slide_pictures() {
use deckcraft_engine::Session;
use deckcraft_model::text::Action;
use serde_json::json;
let mut s = Session::with_new();
for t in ["Intro", "Plan", "Budget", "Risks"] {
s.execute("slide.new", &json!({"title": t})).unwrap();
}
// Slides: 0 title, 1 Intro, 2 Plan, 3 Budget, 4 Risks.
s.execute("slide.go", &json!({"index": 0})).unwrap();
let r = s.execute("insert.zoom", &json!({"slides": [2]})).unwrap();
assert_eq!(r["returnToZoom"], false);
let doc = s.doc().unwrap().doc.clone();
let zoom = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Slide Zoom")).expect("slide zoom");
let link = zoom.click.as_ref().unwrap();
assert_eq!(link.action, Action::Slide { slide: doc.slides[2].id });
assert_eq!(link.zoom_return, None);
assert_eq!(link.tooltip, "Plan");
let ShapeKind::Picture { fill } = &zoom.kind else { panic!() };
assert!(doc.media(fill.media).is_some_and(|m| m.data.starts_with(b"\x89PNG")));
// Section zoom: back after the section's last slide.
s.execute("section.add", &json!({"name": "Start", "at": 0})).unwrap();
s.execute("section.add", &json!({"name": "Money", "at": 3})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let money = doc.sections.iter().position(|x| x.name == "Money").unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
s.execute("insert.zoom", &json!({"kind": "section", "sections": [money]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let sec = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Section Zoom")).unwrap();
let last = *doc.sections[money].slides.last().unwrap();
assert_eq!(sec.click.as_ref().unwrap().zoom_return, Some(last));
// Summary zoom on slides 1 and 3: a new slide in front, parts 1–2 and 3–4.
let before = doc.slides.len();
s.execute("insert.zoom", &json!({"kind": "summary", "slides": [1, 3]})).unwrap();
let doc = s.doc().unwrap().doc.clone();
assert_eq!(doc.slides.len(), before + 1);
let sum = &doc.slides[1];
assert!(sum.title().contains("Summary"));
let tiles: Vec<_> = sum.shapes.iter().filter_map(|sh| sh.click.as_ref()).collect();
assert_eq!(tiles.len(), 2);
assert_eq!(tiles[0].zoom_return, Some(doc.slides[3].id), "part one runs to the slide before part two");
assert_eq!(tiles[1].zoom_return, Some(doc.slides.last().unwrap().id));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides.len(), before, "one undo step");
// Saved as linked pictures.
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap()).unwrap();
let z = back.slides[0].shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Picture { .. }) && sh.click.is_some()).unwrap();
assert!(matches!(z.click.as_ref().unwrap().action, Action::Slide { .. }));
assert!(s.execute("insert.zoom", &json!({"slides": [42]})).is_err());
assert!(s.execute("insert.zoom", &json!({"kind": "slide"})).is_err());
}
+11 -2
View File
@@ -863,6 +863,7 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
});
let hlink = Some(Fill::solid(ColorRef::scheme(deckcraft_color::SchemeSlot::Hlink)));
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))));
@@ -2039,7 +2040,8 @@ fn east_asian_vertical_text_stands_ideographs_upright() {
#[test]
fn placed_shapes_give_the_click_boxes_of_a_slide_in_drawing_order() {
use deckcraft_model::text::{Action, Hyperlink};
let link = Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false };
let link =
Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None };
let rect = |id: u32, x: f64, click: Option<Hyperlink>| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 50.0, 100.0, 40.0)),
@@ -2079,7 +2081,14 @@ fn a_text_hyperlink_belongs_to_the_shape_that_holds_the_text() {
use deckcraft_model::text::{Action, Hyperlink};
let mut body = TextBody::from_text("click here");
for r in &mut body.paragraphs[0].runs {
r.props.link = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false });
r.props.link = Some(Hyperlink {
action: Action::FirstSlide,
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
});
}
let sh = Shape { id: ShapeId(7), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 60.0)), text: Some(body), ..Default::default() };
let placed = place_slide(&deck_with(vec![sh]), 0);
+1 -1
View File
@@ -3313,7 +3313,7 @@ mod tests {
};
fn link(action: Action, tip: &str) -> Hyperlink {
Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false }
Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
}
fn url(address: &str, tip: &str) -> Hyperlink {
+60
View File
@@ -1238,6 +1238,8 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) {
});
});
group(ui, |ui| {
let r = big_button(ui, Icon::Zoom, "Zoom", enabled(app, "insert.zoom"));
egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(300.0).show(|ui| zoom_menu(app, ui));
big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"}));
let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton"));
egui::Popup::menu(&r).show(|ui| {
@@ -3508,3 +3510,61 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) {
}
});
}
/// Insert › Zoom: pick Summary, Section or Slide Zoom and what it zooms to, as PowerPoint's
/// Insert Zoom dialogs do (a checklist of slides or sections).
fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) {
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
let id = egui::Id::new("insert_zoom_menu");
let (mut kind, mut picked): (u8, Vec<usize>) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![])));
ui.horizontal(|ui| {
for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] {
if ui.selectable_label(kind == k, label).clicked() && kind != k {
kind = k;
picked.clear();
}
}
});
ui.separator();
let items: Vec<String> = if kind == 1 {
doc.sections.iter().map(|s| s.name.clone()).collect()
} else {
doc.slides
.iter()
.enumerate()
.map(|(i, s)| {
let t = s.title();
format!("{}. {}", i + 1, t.lines().next().unwrap_or("").trim())
})
.collect()
};
if items.is_empty() {
ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." });
}
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (i, label) in items.iter().enumerate() {
let mut on = picked.contains(&i);
if ui.checkbox(&mut on, label).changed() {
if on {
picked.push(i);
} else {
picked.retain(|x| *x != i);
}
}
}
});
ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone())));
ui.separator();
if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() {
let mut v = picked.clone();
v.sort_unstable();
let p = match kind {
1 => json!({"kind": "section", "sections": v}),
2 => json!({"kind": "summary", "slides": v}),
_ => json!({"kind": "slide", "slides": v}),
};
run(app, "insert.zoom", p);
ui.data_mut(|d| d.remove::<(u8, Vec<usize>)>(id));
ui.close();
}
}
+1 -1
View File
@@ -570,7 +570,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show);
} else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) {
let last = show.last_viewed;
match show.state.follow(&doc, &l.action, last) {
match show.state.follow_link(&doc, &l, last) {
LinkJump::Show(a) => actions.push(a),
LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
LinkJump::Exit => keep = false,
+1
View File
@@ -348,6 +348,7 @@ Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1
sounds (1–2 h), artistic effects (6–10 h), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
sounds (1–2 h), artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove background (6–10 h; needs a model or a classical
Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), transition
Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element (3–5 h; `insert.zoom` makes linked pictures with Return to Zoom), reuse slides (2–4 h), laser pointer (1–2 h), transition
sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical
matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile
+1 -1
View File
@@ -99,7 +99,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Slides | Reset slide | P1 | D |
| Slides | Slides from outline | P2 | D |
| Slides | Copy/paste slides between decks | P1 | P |
| Slides | Zoom (summary, section and slide zoom) | P2 | M |
| Slides | Zoom (summary, section and slide zoom) | P2 | P |
| Views | Normal view (thumbnails, slide, notes) | P0 | D |
| Views | Outline view | P1 | D |
| Views | Slide Sorter | P0 | D |