Files
deckcraft/crates/engine/src/cmd/slide.rs
T
FaabyyandClaude abdf5f337b Slides: a second Shift-click extends from the same anchor; the range clears cells and returns to the slides; a bad from is an error (#1105)
Review on #1107: read `from` with opt_usize_param, clear `sel.cells` and set
`target = Slides` as `slide.go` did, keep the anchor of the first Shift-click.
A kittest drives the Shift and Ctrl+Shift clicks in the slide sorter.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:50:42 +00:00

832 lines
35 KiB
Rust

//! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation.
use std::sync::Arc;
use deckcraft_model::text::TextBody;
use deckcraft_model::{LayoutId, LayoutType, Presentation, Section, SlideId, defaults};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session, Target};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"slide.new",
"New Slide",
["Home", "Slides"],
Some("Cmd+Shift+N"),
"{layout?: layout name or kind (title, titleAndContent, sectionHeader, twoContent, comparison, titleOnly, blank, contentWithCaption, pictureWithCaption), at?: index, title?: text, body?: text}",
has_doc,
new
),
cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate),
cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete),
cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide),
cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go),
cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next),
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
cmd!(noundo "slide.first", "First Slide", [], Some("Home"), "{}", has_slide, first),
cmd!(noundo "slide.last", "Last Slide", [], Some("End"), "{}", has_slide, last),
cmd!(noundo "slide.selectRange", "Select Slide Range", [], None, "{to: index, from?: index (default the current slide), add?: bool (keep the slides already selected)}", has_slide, select_range),
cmd!(noundo "slide.selectSlides", "Select Slides", [], None, "{ids?: [slide id], indices?: [index], add?: bool}", has_slide, select_slides),
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 | 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),
cmd!(
"section.remove",
"Remove Section",
["Home", "Slides", "Section"],
None,
"{index, slides?: bool (also delete its slides)}",
has_slide,
section_remove
),
cmd!("section.removeAll", "Remove All Sections", ["Home", "Slides", "Section"], None, "{}", has_slide, section_remove_all),
cmd!("section.move", "Move Section", [], None, "{index, to}", has_slide, section_move),
cmd!(
"slide.fromOutline",
"Slides from Outline…",
["Insert", "Slides From"],
None,
"{text, markdown?: bool (headings are slides, list items bullets)}",
has_doc,
from_outline
),
]
}
/// Resolve a layout by id, name or kind keyword.
pub fn find_layout(doc: &Presentation, key: &Value, master: usize) -> Option<LayoutId> {
if let Some(n) = key.as_u64() {
let id = LayoutId(u32::try_from(n).ok()?);
if doc.layout(id).is_some() {
return Some(id);
}
}
let s = key.as_str()?;
let m = doc.masters.get(master).or(doc.masters.first())?;
let norm = |x: &str| x.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k = norm(s);
let kind = match k.as_str() {
"title" | "titleslide" => Some(LayoutType::Title),
"titleandcontent" | "content" | "obj" => Some(LayoutType::TitleAndContent),
"sectionheader" | "section" => Some(LayoutType::SectionHeader),
"twocontent" | "two" => Some(LayoutType::TwoContent),
"comparison" => Some(LayoutType::Comparison),
"titleonly" => Some(LayoutType::TitleOnly),
"blank" => Some(LayoutType::Blank),
"contentwithcaption" => Some(LayoutType::ContentWithCaption),
"picturewithcaption" | "picture" => Some(LayoutType::PictureWithCaption),
"titleandverticaltext" => Some(LayoutType::TitleAndVerticalText),
"verticaltitleandtext" => Some(LayoutType::VerticalTitleAndText),
_ => None,
};
m.layouts.iter().find(|l| norm(&l.name) == k).or_else(|| kind.and_then(|kd| m.layouts.iter().find(|l| l.kind == kd))).map(|l| l.id)
}
fn current_master(doc: &Presentation, sel: &crate::Selection) -> usize {
doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0)
}
fn new(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|doc, sel| {
let master = current_master(doc, sel);
let layout = match p.get("layout") {
Some(k) => find_layout(doc, k, master).ok_or_else(|| bad("slide.new", format!("no layout {k}")))?,
None => {
// PowerPoint picks Title and Content after a title slide, else the current layout.
let cur = doc.slides.get(sel.slide).map(|x| x.layout);
let is_title = cur.and_then(|l| doc.layout(l)).is_some_and(|(_, l)| l.kind == LayoutType::Title);
match cur {
Some(l) if !is_title => l,
_ => defaults::layout_of_kind(doc, LayoutType::TitleAndContent).or(cur).unwrap_or_default(),
}
}
};
let mut slide = defaults::new_slide(doc, layout).ok_or_else(|| bad("slide.new", NO_ID_SPACE))?;
if let Some(t) = str_param(p, "title")
&& 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(t));
}
if let Some(b) = str_param(p, "body")
&& let Some(sh) = body_placeholder(&mut slide.shapes)
{
let mut body = TextBody::from_text(b);
// Leading tabs set the outline level, as in text.set.
for para in &mut body.paragraphs {
super::text::apply_leading_tabs(para);
}
sh.text = Some(body);
}
let at = usize_param(p, "at").unwrap_or(if doc.slides.is_empty() { 0 } else { sel.slide + 1 }).min(doc.slides.len());
let id = slide.id;
// Inherit the section of the slide before.
let section = at.checked_sub(1).and_then(|i| doc.slides.get(i)).and_then(|prev| doc.section_of(prev.id));
doc.slides.insert(at, Arc::new(slide));
if let Some(si) = section
&& let Some(sec) = doc.sections.get_mut(si)
{
let pos = at
.checked_sub(1)
.and_then(|i| doc.slides.get(i))
.and_then(|prev| sec.slides.iter().position(|x| *x == prev.id))
.map(|k| k + 1)
.unwrap_or(sec.slides.len());
sec.slides.insert(pos.min(sec.slides.len()), id);
} else if let Some(first) = doc.sections.first_mut() {
first.slides.insert(0, id);
}
sel.slide = at;
sel.slides = vec![id];
sel.shapes.clear();
sel.text = None;
sel.target = Target::Slides;
Ok(json!({"index": at, "id": id}))
})
}
/// The placeholder a new slide's `body` goes into: the layout's main content placeholder.
/// Prefers `idx="1"` (the content slot PowerPoint fills), then a full-size text placeholder over
/// a `half`/`quarter` one, then a text placeholder over a picture/chart/table one; ties keep
/// shape order, so a layout with a single body placeholder behaves as before.
fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcraft_model::Shape> {
use deckcraft_model::PhType;
let rank = |sh: &deckcraft_model::Shape| {
let ph = sh.ph.as_ref()?;
if ph.kind.is_title() {
return None;
}
let text = matches!(ph.kind, PhType::Body | PhType::Obj | PhType::SubTitle);
let small = matches!(ph.size.as_deref(), Some("half" | "quarter"));
Some((ph.idx != 1, small, !text))
};
let best = shapes.iter().enumerate().filter_map(|(i, sh)| rank(sh).map(|r| (r, i))).min()?.1;
shapes.get_mut(best)
}
fn index_of(s: &Session, p: &Value) -> Result<usize> {
let st = s.doc()?;
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("slide", format!("no slide {i}")));
}
Ok(i)
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
Some(i) => vec![i],
None => {
let st = s.doc()?;
let mut v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
if v.is_empty() {
v.push(st.selection.slide);
}
v.sort_unstable();
v
}
};
s.edit(|doc, sel| {
let last = *indices.last().unwrap_or(&0);
let mut new_ids = vec![];
for (k, i) in indices.iter().enumerate() {
let Some(src) = doc.slides.get(*i).map(|x| x.as_ref().clone()) else { continue };
let mut copy = src;
copy.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.duplicate", NO_ID_SPACE))?);
// Animations follow the new shape ids, triggers included.
let map = super::edit::reid("slide.duplicate", doc, &mut copy.shapes)?;
super::edit::remap_animations(&mut copy.animations, &map);
let section = doc.section_of(src_id(doc, *i));
let at = (last + 1 + k).min(doc.slides.len());
new_ids.push(copy.id);
let nid = copy.id;
doc.slides.insert(at, Arc::new(copy));
if let Some(si) = section
&& let Some(sec) = doc.sections.get_mut(si)
{
sec.slides.push(nid);
// Keep the section list in document order so the copy sits next to its source.
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
}
sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1));
sel.slides = new_ids.clone();
sel.shapes.clear();
sel.text = None;
Ok(json!({"ids": new_ids}))
})
}
fn src_id(doc: &Presentation, i: usize) -> SlideId {
doc.slides.get(i).map(|s| s.id).unwrap_or_default()
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<SlideId> = {
let st = s.doc()?;
if let Some(a) = p.get("ids").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
} else if !st.selection.slides.is_empty() {
st.selection.slides.clone()
} else {
st.current_slide().map(|x| vec![x.id]).unwrap_or_default()
}
};
if ids.is_empty() {
return Err(bad("slide.delete", "no such slide"));
}
s.edit(|doc, sel| {
let first = ids.iter().filter_map(|id| doc.slide_index(*id)).min().unwrap_or(0);
doc.slides.retain(|x| !ids.contains(&x.id));
for sec in &mut doc.sections {
sec.slides.retain(|x| !ids.contains(x));
}
sel.slide = first.min(doc.slides.len().saturating_sub(1));
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
sel.shapes.clear();
sel.text = None;
Ok(json!({"deleted": ids.len(), "remaining": doc.slides.len()}))
})
}
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
s.edit(|doc, sel| {
if from >= doc.slides.len() {
return Err(bad("slide.move", format!("no slide {from}")));
}
let sl = doc.slides.remove(from);
let to = to.min(doc.slides.len());
let id = sl.id;
doc.slides.insert(to, sl);
// Keep sections consistent with order: move the id into the section of its new neighbour.
if !doc.sections.is_empty() {
for sec in &mut doc.sections {
sec.slides.retain(|x| *x != id);
}
let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
if let Some(sec) = doc.sections.get_mut(si) {
sec.slides.push(id);
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
}
sel.slide = to;
sel.slides = vec![id];
Ok(json!({"index": to}))
})
}
fn go_to(s: &mut Session, i: usize) -> Result<Value> {
s.select(|doc, sel| {
let n = doc.slides.len();
sel.slide = i.min(n.saturating_sub(1));
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.range_anchor = None;
sel.target = Target::Slides;
})?;
Ok(json!({"index": s.doc()?.selection.slide}))
}
fn go(s: &mut Session, p: &Value) -> Result<Value> {
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).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<Value> {
let i = s.doc()?.selection.slide + 1;
go_to(s, i)
}
fn prev(s: &mut Session, _p: &Value) -> Result<Value> {
let i = s.doc()?.selection.slide.saturating_sub(1);
go_to(s, i)
}
fn first(s: &mut Session, _p: &Value) -> Result<Value> {
go_to(s, 0)
}
fn last(s: &mut Session, _p: &Value) -> Result<Value> {
let n = s.doc()?.doc.slides.len();
go_to(s, n.saturating_sub(1))
}
fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
let add = bool_or(p, "add", false);
let ids: Vec<SlideId> = {
let st = s.doc()?;
let mut v: Vec<SlideId> = p
.get("ids")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_u64).filter_map(|x| u32::try_from(x).ok()).map(SlideId).collect())
.unwrap_or_default();
if let Some(a) = p.get("indices").and_then(Value::as_array) {
v.extend(a.iter().filter_map(Value::as_u64).filter_map(|i| st.doc.slides.get(i as usize)).map(|x| x.id));
}
v
};
s.select(|doc, sel| {
if add {
for i in &ids {
if !sel.slides.contains(i) {
sel.slides.push(*i);
}
}
} else {
sel.slides = ids.clone();
}
if let Some(last) = ids.last().and_then(|i| doc.slide_index(*i)) {
sel.slide = last;
}
sel.shapes.clear();
sel.text = None;
sel.range_anchor = None;
})?;
Ok(json!({"selected": s.doc()?.selection.slides}))
}
/// Shift-click in the slide list: select every slide from `from` to `to`. Without `from` the range
/// starts at the anchor of the previous Shift-click (so a second Shift-click extends from the same
/// slide, as in PowerPoint), else at the current slide.
/// With `add` the slides already selected stay selected (Ctrl+Shift-click).
fn select_range(s: &mut Session, p: &Value) -> Result<Value> {
let to = usize_param(p, "to").ok_or_else(|| bad("slide.selectRange", "missing `to`"))?;
let given = opt_usize_param(p, "from", "slide.selectRange")?;
let add = bool_or(p, "add", false);
let (from, count) = {
let st = s.doc()?;
let sel = &st.selection;
let anchor = sel.range_anchor.filter(|(_, end)| *end == sel.slide).map(|(a, _)| a);
(given.or(anchor).unwrap_or(sel.slide), st.doc.slides.len())
};
if to >= count || from >= count {
return Err(bad("slide.selectRange", "no such slide"));
}
let (a, b) = (from.min(to), from.max(to));
s.select(|doc, sel| {
let range: Vec<SlideId> = doc.slides.iter().skip(a).take(b - a + 1).map(|x| x.id).collect();
if add {
for id in range {
if !sel.slides.contains(&id) {
sel.slides.push(id);
}
}
} else {
sel.slides = range;
}
sel.slide = to;
sel.range_anchor = Some((from, to));
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.target = Target::Slides;
})?;
Ok(json!({"selected": s.doc()?.selection.slides}))
}
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.hide")? {
Some(i) => vec![i],
None => {
let st = s.doc()?;
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
if v.is_empty() { vec![st.selection.slide] } else { v }
}
};
s.edit(|doc, _| {
let all_hidden = targets.iter().all(|i| doc.slides.get(*i).is_some_and(|x| x.hidden));
let value = bool_param(p, "hidden").unwrap_or(!all_hidden);
for i in &targets {
if let Some(sl) = doc.slides.get_mut(*i) {
Arc::make_mut(sl).hidden = value;
}
}
Ok(json!({"hidden": value}))
})
}
/// Re-map a slide's placeholders onto a new layout (content kept, matched by type/index).
pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> Result<()> {
let lay_shapes: Vec<deckcraft_model::Shape> =
doc.layout(layout).map(|(_, l)| l.shapes.clone()).ok_or_else(|| bad("slide.layout", "no such layout"))?;
let mut fresh = vec![];
for ls in lay_shapes.iter().filter(|x| x.ph.as_ref().is_some_and(|p| !p.kind.is_footer_kind())) {
let id = deckcraft_model::ShapeId(doc.alloc_id().ok_or_else(|| bad("slide.layout", NO_ID_SPACE))?);
fresh.push((id, ls.clone()));
}
let slide = doc.slides.get_mut(index).ok_or_else(|| bad("slide.layout", "no slide"))?;
let slide = Arc::make_mut(slide);
let mut old: Vec<deckcraft_model::Shape> = std::mem::take(&mut slide.shapes);
let mut out = vec![];
for (id, ls) in fresh {
let Some(lph) = ls.ph.clone() else { continue };
// Find matching old placeholder: same idx, else same title-ness/compatible type.
let k = old
.iter()
.position(|o| o.ph.as_ref().is_some_and(|p| p.kind == lph.kind && (p.idx == lph.idx || lph.kind.is_title())))
.or_else(|| old.iter().position(|o| o.ph.as_ref().is_some_and(|p| p.kind.is_title() == lph.kind.is_title() && !p.kind.is_footer_kind())));
match k {
Some(k) => {
let mut o = old.remove(k);
if let Some(p) = o.ph.as_mut() {
p.kind = lph.kind;
p.idx = lph.idx;
}
o.xfrm = None;
out.push(o);
}
None => {
out.push(deckcraft_model::Shape { id, name: ls.name.clone(), ph: Some(lph), text: Some(TextBody::default()), ..Default::default() })
}
}
}
// Leftover placeholders with content stay as they were (positioned); empty ones go.
for o in old {
let empty_ph = o.ph.is_some() && o.text.as_ref().is_none_or(|t| t.is_empty()) && matches!(o.kind, deckcraft_model::ShapeKind::Shape);
if !empty_ph {
out.push(o);
}
}
slide.shapes = out;
slide.layout = layout;
Ok(())
}
fn set_layout(s: &mut Session, p: &Value) -> Result<Value> {
let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?;
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.layout")? {
Some(i) => vec![i],
None => {
let st = s.doc()?;
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
if v.is_empty() { vec![st.selection.slide] } else { v }
}
};
s.edit(|doc, sel| {
let master = current_master(doc, sel);
let layout = find_layout(doc, &key, master).ok_or_else(|| bad("slide.layout", format!("no layout {key}")))?;
for i in &targets {
apply_layout(doc, *i, layout)?;
}
sel.shapes.clear();
sel.text = None;
Ok(json!({"layout": layout}))
})
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let i = index_of(s, p)?;
s.edit(|doc, sel| {
let layout = doc.slides.get(i).map(|x| x.layout).unwrap_or_default();
apply_layout(doc, i, layout)?;
if let Some(sl) = doc.slides.get_mut(i) {
let sl = Arc::make_mut(sl);
sl.background = None;
for sh in &mut sl.shapes {
if sh.ph.is_some() {
sh.xfrm = None;
sh.fill = None;
sh.line = None;
sh.effects = None;
sh.geom = Default::default();
if let Some(t) = sh.text.as_mut() {
t.body = Default::default();
t.list_style = Default::default();
for para in &mut t.paragraphs {
para.props = Default::default();
for r in &mut para.runs {
r.props = Default::default();
}
}
}
}
}
}
sel.shapes.clear();
Ok(())
})?;
ok()
}
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
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::<TextBody>(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) {
let notes = &mut Arc::make_mut(sl).notes;
match body {
Some(b) => *notes = b,
None => deckcraft_model::edit::replace_text(notes, &text),
}
}
Ok(())
})?;
ok()
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let i = index_of(s, p)?;
let name = str_param(p, "name").unwrap_or("").to_string();
s.edit(|doc, _| {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).name = name;
}
Ok(())
})?;
ok()
}
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
s.edit(|doc, _| {
let at = at.min(doc.slides.len().saturating_sub(1));
let ids: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
if doc.sections.is_empty() {
// The slides before `at` go into a "Default Section".
if at > 0 {
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
}
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
} else {
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
let si = doc.section_of(start).unwrap_or(0);
let sec = doc.sections.get_mut(si).ok_or_else(|| bad("section.add", "no section"))?;
let k = sec.slides.iter().position(|x| *x == start).unwrap_or(sec.slides.len());
let tail = sec.slides.split_off(k);
doc.sections.insert(si + 1, Section { name, slides: tail });
doc.sections.retain(|x| !x.slides.is_empty() || x.name != "Default Section");
}
Ok(json!({"sections": doc.sections.len()}))
})
}
fn section_rename(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("section.rename", "missing `index`"))?;
let name = str_param(p, "name").unwrap_or("").to_string();
s.edit(|doc, _| {
let sec = doc.sections.get_mut(i).ok_or_else(|| bad("section.rename", "no such section"))?;
sec.name = name;
Ok(())
})?;
ok()
}
fn section_remove(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("section.remove", "missing `index`"))?;
let with_slides = bool_or(p, "slides", false);
s.edit(|doc, sel| {
if i >= doc.sections.len() {
return Err(bad("section.remove", "no such section"));
}
let sec = doc.sections.remove(i);
if with_slides {
doc.slides.retain(|x| !sec.slides.contains(&x.id));
sel.slide = sel.slide.min(doc.slides.len().saturating_sub(1));
} else if let Some(prev) = i.checked_sub(1).and_then(|k| doc.sections.get_mut(k)) {
prev.slides.extend(sec.slides);
} else if let Some(next) = doc.sections.get_mut(0) {
let mut v = sec.slides;
v.append(&mut next.slides);
next.slides = v;
}
Ok(())
})?;
ok()
}
fn section_remove_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.edit(|doc, _| {
doc.sections.clear();
Ok(())
})?;
ok()
}
fn section_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("section.move", "missing `index`"))?;
let to = usize_param(p, "to").ok_or_else(|| bad("section.move", "missing `to`"))?;
s.edit(|doc, _| {
if i >= doc.sections.len() {
return Err(bad("section.move", "no such section"));
}
let sec = doc.sections.remove(i);
let to = to.min(doc.sections.len());
doc.sections.insert(to, sec);
// Reorder slides to follow sections.
let order: Vec<SlideId> = doc.sections.iter().flat_map(|x| x.slides.iter().copied()).collect();
doc.slides.sort_by_key(|x| order.iter().position(|o| *o == x.id).unwrap_or(usize::MAX));
Ok(())
})?;
ok()
}
fn from_outline(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
let markdown = bool_param(p, "markdown").unwrap_or(false);
s.edit(|doc, sel| {
let before = doc.slides.len();
let n = if markdown { deckcraft_format::markdown_to_slides(doc, &text) } else { deckcraft_format::outline_to_slides(doc, &text) };
// both append; move them after the current slide.
let at = (sel.slide + 1).min(before);
let new: Vec<_> = doc.slides.drain(before..).collect();
for (k, sl) in new.into_iter().enumerate() {
doc.slides.insert(at + k, sl);
}
// Join the section of the slide before, as slide.new does.
let ids: Vec<SlideId> = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect();
let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
let section = prev.and_then(|p| doc.section_of(p));
if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) {
let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len());
let pos = pos.min(sec.slides.len());
sec.slides.splice(pos..pos, ids);
} else if let Some(first) = doc.sections.first_mut() {
first.slides.splice(0..0, ids);
}
Ok(json!({"slides": n}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
for bad in [json!(-1), json!(1.5)] {
for (cmd, p) in [
("slide.delete", json!({"index": bad})),
("slide.duplicate", json!({"index": bad})),
("slide.move", json!({"from": bad, "to": 0})),
("section.add", json!({"at": bad})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
}
}
}
#[test]
fn duplicate_keeps_section_order_in_step_with_slides() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
s.execute("section.add", &json!({"at": 0})).expect("section");
let orig = s.doc().expect("doc").doc.slides.first().expect("slide").id;
s.execute("slide.duplicate", &json!({"index": 0})).expect("dup");
let st = s.doc().expect("doc");
let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect();
let sec = &st.doc.sections.first().expect("section").slides;
assert_eq!(all.len(), 4);
assert_eq!(sec, &all, "section order must match slide order");
assert_eq!(sec.first(), Some(&orig));
}
#[test]
fn new_slide_body_leading_tabs_become_levels() {
let mut s = Session::with_new();
let text = "Level one\n\tLevel two\n\t\tLevel three\nLevel one again";
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Indent", "body": text})).expect("new slide");
let st = s.doc().expect("doc");
let slide = st.doc.slides.get(st.selection.slide).expect("slide");
let body = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| !k.is_title())).expect("body");
let paras = &body.text.as_ref().expect("text").paragraphs;
let got: Vec<(u8, String)> = paras.iter().map(|p| (p.level, p.text())).collect();
let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")];
assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::<Vec<_>>());
}
fn deck_of(n: usize) -> Session {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < n {
s.execute("slide.new", &json!({})).expect("new slide");
}
s
}
fn selected_indices(s: &Session) -> Vec<usize> {
let st = s.doc().expect("doc");
let mut v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
v.sort_unstable();
v
}
#[test]
fn select_range_covers_every_slide_between() {
let mut s = deck_of(6);
s.execute("slide.go", &json!({"index": 1})).expect("go");
s.execute("slide.selectRange", &json!({"to": 4})).expect("range");
assert_eq!(selected_indices(&s), vec![1, 2, 3, 4]);
assert_eq!(s.doc().expect("doc").selection.slide, 4);
// Backwards works too, and replaces the earlier selection.
s.execute("slide.selectRange", &json!({"from": 5, "to": 3})).expect("range");
assert_eq!(selected_indices(&s), vec![3, 4, 5]);
}
#[test]
fn select_range_add_keeps_the_existing_selection() {
let mut s = deck_of(8);
s.execute("slide.selectSlides", &json!({"indices": [0, 1]})).expect("select");
s.execute("slide.go", &json!({"index": 4})).expect("go");
s.execute("slide.selectSlides", &json!({"indices": [4]})).expect("select");
s.execute("slide.selectRange", &json!({"to": 6, "add": true})).expect("range");
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
s.execute("slide.selectSlides", &json!({"indices": [0]})).expect("select");
s.execute("slide.selectRange", &json!({"from": 3, "to": 5, "add": true})).expect("range");
assert_eq!(selected_indices(&s), vec![0, 3, 4, 5]);
}
#[test]
fn select_range_rejects_out_of_range_slides() {
let mut s = deck_of(3);
assert!(s.execute("slide.selectRange", &json!({"to": 3})).is_err());
assert!(s.execute("slide.selectRange", &json!({"from": 9, "to": 1})).is_err());
assert!(s.execute("slide.selectRange", &json!({})).is_err());
}
#[test]
fn select_range_rejects_an_invalid_from_instead_of_using_the_current_slide() {
let mut s = deck_of(4);
s.execute("slide.go", &json!({"index": 2})).expect("go");
assert!(s.execute("slide.selectRange", &json!({"from": -1, "to": 3})).is_err());
assert!(s.execute("slide.selectRange", &json!({"from": "x", "to": 3})).is_err());
assert_eq!(selected_indices(&s), vec![2]);
}
#[test]
fn a_second_range_extends_from_the_original_anchor() {
let mut s = deck_of(8);
s.execute("slide.go", &json!({"index": 2})).expect("go");
s.execute("slide.selectRange", &json!({"to": 5})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5]);
// Shift-click further on: still anchored at 2, not at 5.
s.execute("slide.selectRange", &json!({"to": 7})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5, 6, 7]);
// Shift-click back across the anchor.
s.execute("slide.selectRange", &json!({"to": 0})).expect("range");
assert_eq!(selected_indices(&s), vec![0, 1, 2]);
// A plain click on another slide starts a new anchor.
s.execute("slide.go", &json!({"index": 4})).expect("go");
s.execute("slide.selectRange", &json!({"to": 6})).expect("range");
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
}
#[test]
fn select_range_clears_a_table_cell_selection_and_returns_to_the_slides() {
let mut s = deck_of(3);
s.select(|_, sel| {
sel.cells = Some((deckcraft_model::ShapeId(1), (0, 0), (1, 1)));
sel.target = crate::Target::Master { master: 0 };
})
.expect("cells");
s.execute("slide.selectRange", &json!({"to": 2})).expect("range");
let sel = &s.doc().expect("doc").selection;
assert!(sel.cells.is_none());
assert_eq!(sel.target, crate::Target::Slides);
}
}