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>
This commit is contained in:
Nikita Rakitin
2026-10-14 01:12:38 +03:00
co-authored by Cursor
parent 457406a7a5
commit 0cbee85ea8
10 changed files with 472 additions and 28 deletions
+50 -8
View File
@@ -8,7 +8,16 @@ use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Presentation", ["File"], Some("Cmd+N"), "{theme?: name, size?: [w,h] pt, blank?: bool}", always, new),
cmd!(
noundo "file.new",
"New Presentation",
["File"],
Some("Cmd+N"),
"{theme?: name, template?: starter name, size?: [w,h] pt, blank?: bool}",
always,
new
),
cmd!(query "file.templates", "Starter Templates", [], None, "{} → [{name, summary, theme, slides}]", always, templates),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path}", always, open),
cmd!(noundo "file.openBytes", "Open Data", [], None, "{name, data: base64}", always, open_bytes),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?, format?: deckcraft|pptx}", has_doc, save),
@@ -49,18 +58,51 @@ fn theme_named(name: &str) -> Option<deckcraft_model::Theme> {
deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name))
}
fn new(s: &mut Session, p: &Value) -> Result<Value> {
let theme = str_param(p, "theme").and_then(theme_named).unwrap_or_default();
let size = p
.get("size")
fn slide_size(p: &Value) -> deckcraft_geom::Size {
p.get("size")
.and_then(Value::as_array)
.and_then(|a| Some(deckcraft_geom::Size::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)))
.filter(|s| s.width >= 1.0 && s.height >= 1.0 && s.width < 5000.0 && s.height < 5000.0)
.unwrap_or(defaults::WIDE);
let doc = defaults::blank_presentation(size, theme, !bool_or(p, "blank", false));
.unwrap_or(defaults::WIDE)
}
fn templates(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({
"templates": crate::templates::starters().iter().map(|t| json!({
"name": t.name,
"summary": t.summary,
"theme": t.theme,
"slides": t.slides.len(),
})).collect::<Vec<_>>(),
}))
}
fn new(s: &mut Session, p: &Value) -> Result<Value> {
let template = str_param(p, "template").filter(|t| !t.is_empty());
let theme_name = str_param(p, "theme").filter(|t| !t.is_empty());
if template.is_some() && theme_name.is_some() {
return Err(bad("file.new", "pass `theme` or `template`, not both"));
}
if template.is_some() && bool_or(p, "blank", false) {
return Err(bad("file.new", "a template is not a blank presentation"));
}
let size = slide_size(p);
let (doc, template_name) = if let Some(name) = template {
let doc = crate::templates::build(name, size)?;
let chosen = crate::templates::find(name).map(|t| t.name);
(doc, chosen)
} else {
let theme = theme_name.and_then(theme_named).unwrap_or_default();
(defaults::blank_presentation(size, theme, !bool_or(p, "blank", false)), None)
};
let slides = doc.slides.len();
let title = s.next_untitled();
let i = s.add_document(DocState::new(doc, None, title));
Ok(json!({"document": i}))
let mut out = json!({"document": i, "slides": slides});
if let Some(name) = template_name {
out["template"] = json!(name);
}
Ok(out)
}
/// Recognise and read presentation bytes (native, PPTX).
+1
View File
@@ -15,6 +15,7 @@ pub mod keys;
pub mod links;
pub mod recovery;
pub mod sample;
pub mod templates;
pub mod tools;
use std::sync::Arc;
+337
View File
@@ -0,0 +1,337 @@
//! 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"))));
}
}