Files
deckcraft/crates/engine/src/templates.rs
T
Nikita RakitinandCursor 0cbee85ea8 File: six original starter decks
File › New and the start screen offer Briefing, Lesson, Pitch, Field Notes,
Quarterly and Evening Talk. Each is a few slides of our own text on one of
our themes. file.new takes template, and file.templates lists them. Passing
a theme as well, or blank, is refused. PowerPoint's large gallery is still
open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 01:12:38 +03:00

338 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Original starter presentations for File › New.
//!
//! Each deck is a few slides of our own text on one of our themes. Nothing here is copied from
//! another product's templates.
use std::sync::Arc;
use deckcraft_geom::Size;
use deckcraft_model::text::TextBody;
use deckcraft_model::{Presentation, defaults};
use serde_json::Value;
use crate::Result;
use crate::cmd::{bad, slide::find_layout};
/// One slide of a starter: a layout, a title, text for each non-title placeholder in order, and
/// optional speaker notes.
pub struct SlideSeed {
pub layout: &'static str,
pub title: &'static str,
pub bodies: &'static [&'static str],
pub notes: &'static str,
}
/// A starter deck shown in the template gallery.
pub struct Starter {
pub name: &'static str,
pub summary: &'static str,
pub theme: &'static str,
pub slides: &'static [SlideSeed],
}
const fn seed(layout: &'static str, title: &'static str, bodies: &'static [&'static str], notes: &'static str) -> SlideSeed {
SlideSeed { layout, title, bodies, notes }
}
const STARTERS: &[Starter] = &[
Starter {
name: "Briefing",
summary: "A weekly check-in: what changed, what's stuck, and who does the next thing",
theme: "Harbor",
slides: &[
seed("title", "Weekly briefing", &["What changed, what's stuck, and what we do next"], "Open with what changed since last week."),
seed(
"titleAndContent",
"This week's agenda",
&["Shipped since last time\n\tOne concrete change\nStill open\n\tThe decision we need\nNext\n\tWho does it, and by when"],
"",
),
seed("sectionHeader", "Where we are", &["A short picture before the details"], ""),
seed(
"twoContent",
"Done and next",
&[
"Done\n\tThe change is in people's hands\n\tWe wrote down what we learned",
"Next\n\tPick the one risk to retire\n\tSay who owns it",
],
"",
),
seed("title", "See you next week", &["Bring one number and one question"], ""),
],
},
Starter {
name: "Lesson",
summary: "A short class that ends with the learner explaining the idea",
theme: "Meadow",
slides: &[
seed("title", "How a lock works", &["A short lesson for people new to the idea"], ""),
seed(
"titleAndContent",
"By the end",
&["You can say what a lock protects\n\tAnd what it does not\nYou can sketch one in three steps"],
"",
),
seed("sectionHeader", "The idea", &["One metaphor, then the real thing"], ""),
seed(
"titleAndContent",
"Three steps",
&["Name the thing being shared\nDecide who may change it\nLeave it so the next person can tell"],
"",
),
seed("title", "Try it", &["Explain a lock to someone who has never seen one"], ""),
],
},
Starter {
name: "Pitch",
summary: "A ten-minute conversation about one promise, not a tour of features",
theme: "Ember",
slides: &[
seed("title", "A quieter inbox", &["Ten minutes. One promise."], ""),
seed(
"titleAndContent",
"The mess",
&[
"People miss the one message that mattered\n\tIt arrived between newsletters\nThey spend the morning sorting\n\tAnd still feel behind",
],
"",
),
seed("sectionHeader", "A smaller promise", &["Show the one message. Hide the rest until asked."], ""),
seed(
"comparison",
"Today and after",
&[
"Today",
"The important note is buried\nFinding it takes a search",
"After",
"It sits at the top until you deal with it\nEverything else waits",
],
"",
),
seed("title", "The ask", &["Twenty people, two weeks, one question: did the right note surface?"], ""),
],
},
Starter {
name: "Field Notes",
summary: "What an outing set out to see, and what it actually found",
theme: "Coral Reef",
slides: &[
seed("title", "Saturday on the ridge", &["What we went to see, and what we found"], ""),
seed("titleAndContent", "Plan", &["Leave at dawn\n\tCold, clear, little wind\nWalk the west path\nBe back before dusk"], ""),
seed("sectionHeader", "What we saw", &["Notes taken on the spot"], ""),
seed(
"twoContent",
"Expected and found",
&[
"Expected\n\tA clear view from the ridge\n\tThe creek on the map",
"Found\n\tLow cloud on the ridge\n\tThe creek, and a better pencil",
],
"",
),
seed("title", "Pack list for next time", &["Warmer gloves. A second pencil. The map that shows the creek."], ""),
],
},
Starter {
name: "Quarterly",
summary: "A report meant to be read aloud in half an hour",
theme: "Paper",
slides: &[
seed("title", "Spring report", &["Read aloud in thirty minutes"], ""),
seed(
"titleAndContent",
"Headlines",
&["The waiting list got shorter\n\tBy the amount we promised\nTwo things slipped\n\tBoth have a new date"],
"",
),
seed("sectionHeader", "The numbers", &["Only the ones that change a decision"], ""),
seed("titleAndContent", "What we will stop", &["The report nobody reads\nThe meeting that repeats the report"], ""),
seed("title", "Questions we want", &["Where should the next quarter refuse work?"], ""),
],
},
Starter {
name: "Evening Talk",
summary: "A public talk the audience can check on the way home",
theme: "Nocturne",
slides: &[
seed("title", "Lights out", &["A talk about how cities sleep"], ""),
seed("titleAndContent", "Road map", &["Why the sky looks orange\nWhat a dark street is for\nOne change a block can make"], ""),
seed("sectionHeader", "Look up", &["The part you can check on the way home"], ""),
seed(
"titleAndContent",
"One change",
&["Shield the lamp so light goes down\n\tNot into bedroom windows\nLeave a path you can still walk"],
"",
),
seed("title", "Walk home", &["Count the lamps you can see from your pillow"], ""),
],
},
];
/// The starter gallery, in the order File › New shows it.
pub fn starters() -> &'static [Starter] {
STARTERS
}
/// Starters whose name, summary, theme or a slide title contains `query` (empty matches all).
pub fn matching(query: &str) -> Vec<&'static Starter> {
let q = query.trim().to_lowercase();
let hit = |text: &str| text.to_lowercase().contains(&q);
starters()
.iter()
.filter(|t| q.is_empty() || hit(t.name) || hit(t.summary) || hit(t.theme) || t.slides.iter().any(|slide| hit(slide.title)))
.collect()
}
pub fn find(name: &str) -> Option<&'static Starter> {
let name = name.trim();
starters().iter().find(|t| t.name.eq_ignore_ascii_case(name))
}
fn text_with_levels(text: &str) -> TextBody {
let mut body = TextBody::from_text(text);
for para in &mut body.paragraphs {
let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0);
if tabs > 0
&& let Some(run) = para.runs.first_mut()
{
run.text = run.text.trim_start_matches('\t').to_string();
para.level = tabs.min(8) as u8;
}
}
body
}
/// A new presentation filled with `name`'s slides. `size` is the slide size in points.
pub fn build(name: &str, size: Size) -> Result<Presentation> {
let starter = find(name).ok_or_else(|| {
let names = starters().iter().map(|t| t.name).collect::<Vec<_>>().join(", ");
bad("file.new", format!("no template `{name}`; use {names}"))
})?;
let theme = deckcraft_model::theme::builtin_themes()
.into_iter()
.find(|t| t.name.eq_ignore_ascii_case(starter.theme))
.ok_or_else(|| bad("file.new", format!("template `{}` names a missing theme `{}`", starter.name, starter.theme)))?;
let mut doc = defaults::blank_presentation(size, theme, false);
for seed in starter.slides {
let key = Value::String(seed.layout.to_string());
let layout = find_layout(&doc, &key, 0)
.ok_or_else(|| bad("file.new", format!("template `{}` uses an unknown layout `{}`", starter.name, seed.layout)))?;
let mut slide = defaults::new_slide(&mut doc, layout);
if !seed.title.is_empty()
&& let Some(sh) = slide.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(|k| k.is_title()))
{
sh.text = Some(text_with_levels(seed.title));
}
let mut bodies = seed.bodies.iter();
for sh in slide.shapes.iter_mut().filter(|sh| sh.ph_type().is_some_and(|k| !k.is_title())) {
let Some(text) = bodies.next() else { break };
if !text.is_empty() {
sh.text = Some(text_with_levels(text));
}
}
if bodies.next().is_some() {
return Err(bad("file.new", format!("template `{}` has more text than the `{}` layout holds", starter.name, seed.layout)));
}
if !seed.notes.is_empty() {
slide.notes = text_with_levels(seed.notes);
}
doc.slides.push(Arc::new(slide));
}
Ok(doc)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::Session;
fn body(doc: &Presentation, slide: usize, n: usize) -> (String, u8) {
let para_slide = &doc.slides[slide];
let text = para_slide
.shapes
.iter()
.filter(|sh| sh.ph_type().is_some_and(|k| !k.is_title()))
.nth(n)
.and_then(|sh| sh.text.as_ref())
.cloned()
.unwrap_or_default();
let level = text.paragraphs.get(1).map(|p| p.level).unwrap_or(0);
(text.text(), level)
}
#[test]
fn every_starter_opens_on_its_theme_and_is_a_clean_document() {
let mut s = Session::new();
for starter in starters() {
s.execute("file.new", &json!({"template": starter.name.to_lowercase()})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.doc.masters[0].theme.name, starter.theme, "{}", starter.name);
assert_eq!(st.doc.slides.len(), starter.slides.len(), "{}", starter.name);
assert_eq!(st.doc.slides[0].title(), starter.slides[0].title, "{}", starter.name);
assert!(!st.is_dirty(), "{}", starter.name);
assert!(st.history.undo.is_empty(), "{}", starter.name);
assert_eq!(st.selection.slide, 0);
}
let listed = s.execute("file.templates", &json!({})).unwrap();
assert_eq!(listed["templates"].as_array().map(Vec::len), Some(starters().len()));
}
#[test]
fn briefing_keeps_notes_columns_and_outline_levels() {
let mut s = Session::new();
s.execute("file.new", &json!({"template": "Briefing", "size": [800.0, 450.0]})).unwrap();
let doc = &s.active().unwrap().doc;
assert_eq!(doc.slide_size.width, 800.0);
assert_eq!(doc.slides[0].notes_text(), "Open with what changed since last week.");
let (agenda, level) = body(doc, 1, 0);
assert!(agenda.contains("One concrete change"), "{agenda}");
assert_eq!(level, 1);
let (done, _) = body(doc, 3, 0);
let (next, _) = body(doc, 3, 1);
assert!(done.starts_with("Done"), "{done}");
assert!(next.starts_with("Next"), "{next}");
}
#[test]
fn pitch_comparison_fills_both_columns() {
let doc = build("Pitch", defaults::WIDE).unwrap();
assert_eq!(body(&doc, 3, 0).0, "Today");
assert!(body(&doc, 3, 1).0.contains("buried"));
assert_eq!(body(&doc, 3, 2).0, "After");
assert!(body(&doc, 3, 3).0.contains("top"));
}
#[test]
fn search_matches_name_summary_and_theme() {
assert_eq!(matching("ridge").iter().map(|t| t.name).collect::<Vec<_>>(), vec!["Field Notes"]);
assert_eq!(matching("nocturne").iter().map(|t| t.name).collect::<Vec<_>>(), vec!["Evening Talk"]);
assert_eq!(matching("").len(), starters().len());
assert!(matching("facet").is_empty());
}
#[test]
fn unknown_blank_and_theme_are_refused() {
let mut s = Session::new();
let unknown = s.execute("file.new", &json!({"template": "Ion"})).unwrap_err();
assert!(unknown.to_string().contains("Briefing"), "{unknown}");
let both = s.execute("file.new", &json!({"template": "Lesson", "theme": "Harbor"})).unwrap_err();
assert!(both.to_string().contains("not both"), "{both}");
let blank = s.execute("file.new", &json!({"template": "Lesson", "blank": true})).unwrap_err();
assert!(blank.to_string().contains("blank"), "{blank}");
assert!(s.active().is_none());
}
#[test]
fn a_starter_round_trips_through_pptx() {
let doc = build("Evening Talk", defaults::WIDE).unwrap();
let bytes = deckcraft_pptx::export(&doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
assert_eq!(back.slides.len(), doc.slides.len());
for (before, after) in doc.slides.iter().zip(back.slides.iter()) {
assert_eq!(after.title(), before.title());
}
assert!(back.slides[3].shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text().contains("bedroom"))));
}
}