mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-01 22:47:04 -06:00
UI: Photo Album dialog inserts pictures as slides
Insert › Images opens Photo Album. insert.photoAlbum adds the pictures and text boxes as slides in the open presentation, one undo step, with layouts, frames and captions. Omitting pictures opens the dialog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,6 +33,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_slide,
|
||||
picture
|
||||
),
|
||||
cmd!(
|
||||
"insert.photoAlbum",
|
||||
"Photo Album…",
|
||||
["Insert", "Images"],
|
||||
None,
|
||||
"{pictures?: [{path?|data: base64, name?} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool} — omit pictures to open the dialog",
|
||||
has_doc,
|
||||
photo_album
|
||||
),
|
||||
cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table),
|
||||
cmd!(
|
||||
"insert.chart",
|
||||
@@ -372,6 +381,336 @@ fn picture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
const ALBUM_MAX_ITEMS: usize = 48;
|
||||
const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024;
|
||||
const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024;
|
||||
const ALBUM_MAX_TEXT: usize = 400;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AlbumLayout {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
per: usize,
|
||||
title: bool,
|
||||
fit: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AlbumFrame {
|
||||
None,
|
||||
Rect,
|
||||
Rounded,
|
||||
Simple,
|
||||
}
|
||||
|
||||
struct AlbumPic {
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
px_w: f64,
|
||||
px_h: f64,
|
||||
}
|
||||
|
||||
enum AlbumItem {
|
||||
Picture(AlbumPic),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
enum AlbumSlide {
|
||||
Pictures(Vec<AlbumPic>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Insert a photo album after the current slide.
|
||||
///
|
||||
/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one
|
||||
/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit.
|
||||
fn photo_album(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("pictures") {
|
||||
None | Some(Value::Null) => {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) });
|
||||
return Ok(json!({"dialog": "photoAlbum"}));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
let layout = album_layout(p)?;
|
||||
let frame = album_frame(p)?;
|
||||
let captions = album_captions(p)?;
|
||||
let items = album_items(p)?;
|
||||
let (snapshot, sel) = {
|
||||
let st = s.doc()?;
|
||||
(st.doc.clone(), st.selection.clone())
|
||||
};
|
||||
let result = insert_album(s, items, layout, frame, captions);
|
||||
if result.is_err()
|
||||
&& let Some(st) = s.active_mut()
|
||||
{
|
||||
st.doc = snapshot;
|
||||
st.selection = sel;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn album_norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn album_layout(p: &Value) -> Result<AlbumLayout> {
|
||||
let key = match p.get("layout") {
|
||||
None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
|
||||
Some(Value::String(s)) => s.as_str(),
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")),
|
||||
};
|
||||
match album_norm(key).as_str() {
|
||||
"fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }),
|
||||
"1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
|
||||
"1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }),
|
||||
"2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }),
|
||||
"2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }),
|
||||
"4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }),
|
||||
"4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }),
|
||||
_ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_frame(p: &Value) -> Result<AlbumFrame> {
|
||||
let key = match p.get("frame") {
|
||||
None => return Ok(AlbumFrame::None),
|
||||
Some(Value::String(s)) => s.as_str(),
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")),
|
||||
};
|
||||
match album_norm(key).as_str() {
|
||||
"none" => Ok(AlbumFrame::None),
|
||||
"rect" | "rectangle" => Ok(AlbumFrame::Rect),
|
||||
"rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded),
|
||||
"simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple),
|
||||
_ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_captions(p: &Value) -> Result<bool> {
|
||||
match p.get("captions") {
|
||||
None => Ok(false),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_items(p: &Value) -> Result<Vec<AlbumItem>> {
|
||||
let Some(arr) = p.get("pictures").and_then(Value::as_array) else {
|
||||
return Err(bad("insert.photoAlbum", "`pictures` must be a list"));
|
||||
};
|
||||
if arr.is_empty() {
|
||||
return Err(bad("insert.photoAlbum", "add at least one picture or text box"));
|
||||
}
|
||||
if arr.len() > ALBUM_MAX_ITEMS {
|
||||
return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes"));
|
||||
}
|
||||
let mut items = Vec::with_capacity(arr.len());
|
||||
let mut total = 0usize;
|
||||
for item in arr {
|
||||
let Some(obj) = item.as_object() else {
|
||||
return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box"));
|
||||
};
|
||||
let has_media = obj.contains_key("data") || obj.contains_key("path");
|
||||
if !has_media {
|
||||
let Some(text) = obj.get("text").and_then(Value::as_str) else {
|
||||
return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`"));
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(bad("insert.photoAlbum", "a text box needs some text"));
|
||||
}
|
||||
if text.chars().count() > ALBUM_MAX_TEXT {
|
||||
return Err(bad("insert.photoAlbum", "text is longer than 400 characters"));
|
||||
}
|
||||
items.push(AlbumItem::Text(text.to_string()));
|
||||
continue;
|
||||
}
|
||||
let (name, bytes) = media_bytes(item, "insert.photoAlbum")?;
|
||||
if bytes.len() > ALBUM_MAX_BYTES {
|
||||
return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB")));
|
||||
}
|
||||
let next = total.saturating_add(bytes.len());
|
||||
if next > ALBUM_MAX_TOTAL {
|
||||
return Err(bad("insert.photoAlbum", "the album is larger than 64 MB"));
|
||||
}
|
||||
total = next;
|
||||
let ct = content_type(&name, &bytes);
|
||||
if !ct.starts_with("image/") {
|
||||
return Err(bad("insert.photoAlbum", format!("{name} is not a picture")));
|
||||
}
|
||||
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
|
||||
return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read")));
|
||||
}
|
||||
let (px_w, px_h) = image_px(&bytes);
|
||||
items.push(AlbumItem::Picture(AlbumPic { name, bytes, px_w, px_h }));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn image_px(bytes: &[u8]) -> (f64, f64) {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_dimensions().ok())
|
||||
.map(|(w, h)| (w as f64, h as f64))
|
||||
.filter(|(w, h)| *w > 0.0 && *h > 0.0)
|
||||
.unwrap_or((4.0, 3.0))
|
||||
}
|
||||
|
||||
fn file_stem(name: &str) -> String {
|
||||
let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name);
|
||||
let stem = match base.rsplit_once('.') {
|
||||
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
|
||||
_ => base,
|
||||
};
|
||||
if stem.is_empty() { "Picture".to_string() } else { stem.to_string() }
|
||||
}
|
||||
|
||||
fn album_plan(items: Vec<AlbumItem>, per: usize) -> Vec<AlbumSlide> {
|
||||
let per = per.max(1);
|
||||
let mut out = Vec::new();
|
||||
let mut batch = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
AlbumItem::Text(text) => {
|
||||
if !batch.is_empty() {
|
||||
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
|
||||
}
|
||||
out.push(AlbumSlide::Text(text));
|
||||
}
|
||||
AlbumItem::Picture(pic) => {
|
||||
batch.push(pic);
|
||||
if batch.len() >= per {
|
||||
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
out.push(AlbumSlide::Pictures(batch));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Placed {
|
||||
pic: [f64; 4],
|
||||
caption: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) {
|
||||
let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 };
|
||||
let box_w = box_w.max(1.0);
|
||||
let box_h = box_h.max(1.0);
|
||||
if !aspect.is_finite() || aspect <= 0.0 {
|
||||
return (box_w, box_h);
|
||||
}
|
||||
if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) }
|
||||
}
|
||||
|
||||
fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
|
||||
let margin = if layout.fit { 18.0 } else { 36.0 };
|
||||
let title_band = if layout.title { 48.0 } else { 0.0 };
|
||||
let gap = 12.0;
|
||||
let cap_h = 22.0;
|
||||
let cap_gap = 4.0;
|
||||
let left = margin;
|
||||
let top = margin + title_band;
|
||||
let grid_w = (width - margin - left).max(1.0);
|
||||
let grid_h = (height - margin - top).max(1.0);
|
||||
let cols = layout.cols.max(1);
|
||||
let rows = layout.rows.max(1);
|
||||
let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0);
|
||||
let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0);
|
||||
let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h };
|
||||
let mut placed = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let col = i % cols;
|
||||
let row = i / cols;
|
||||
if row >= rows {
|
||||
break;
|
||||
}
|
||||
let cell_x = left + col as f64 * (cell_w + gap);
|
||||
let cell_y = top + row as f64 * (cell_h + gap);
|
||||
placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None });
|
||||
}
|
||||
let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None };
|
||||
(title, placed)
|
||||
}
|
||||
|
||||
fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
|
||||
let (title, slots) = place_album(width, height, layout, pics.len(), captions);
|
||||
let placed = pics
|
||||
.iter()
|
||||
.zip(slots)
|
||||
.map(|(pic, slot)| {
|
||||
let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h);
|
||||
let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0;
|
||||
let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0;
|
||||
let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None };
|
||||
Placed { pic: [x, y, dw, dh], caption }
|
||||
})
|
||||
.collect();
|
||||
(title, placed)
|
||||
}
|
||||
|
||||
fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> {
|
||||
let params = match frame {
|
||||
AlbumFrame::None => return Ok(()),
|
||||
AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}),
|
||||
AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}),
|
||||
AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}),
|
||||
};
|
||||
s.execute("shape.line", ¶ms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_album(s: &mut Session, items: Vec<AlbumItem>, layout: AlbumLayout, frame: AlbumFrame, captions: bool) -> Result<Value> {
|
||||
let (width, height, first) = {
|
||||
let st = s.doc()?;
|
||||
let first = if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) };
|
||||
(st.doc.slide_size.width, st.doc.slide_size.height, first)
|
||||
};
|
||||
let plan = album_plan(items, layout.per);
|
||||
let mut slides = 0usize;
|
||||
let mut pictures = 0usize;
|
||||
for slide in plan {
|
||||
s.execute("slide.new", &json!({"layout": "blank"}))?;
|
||||
slides += 1;
|
||||
match slide {
|
||||
AlbumSlide::Text(text) => {
|
||||
let rect = if layout.title {
|
||||
[36.0, 18.0, (width - 72.0).max(36.0), 48.0]
|
||||
} else {
|
||||
[36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)]
|
||||
};
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?;
|
||||
}
|
||||
AlbumSlide::Pictures(pics) => {
|
||||
let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default();
|
||||
let (title, placed) = place_pictures(width, height, layout, &pics, captions);
|
||||
for (pic, cell) in pics.iter().zip(placed) {
|
||||
let data = base64_encode(&pic.bytes);
|
||||
let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?;
|
||||
let Some(id) = inserted.get("id").and_then(Value::as_u64) else {
|
||||
return Err(bad("insert.photoAlbum", "the picture was not inserted"));
|
||||
};
|
||||
apply_frame(s, frame, id)?;
|
||||
pictures += 1;
|
||||
if let Some(cap) = cell.caption {
|
||||
s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?;
|
||||
}
|
||||
}
|
||||
if let Some(rect) = title {
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.execute("slide.go", &json!({"index": first}))?;
|
||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures}))
|
||||
}
|
||||
|
||||
fn table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75);
|
||||
let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75);
|
||||
@@ -798,3 +1137,161 @@ fn smart_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::ShapeKind;
|
||||
use deckcraft_model::style::LineJoin;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 8×8 red PNG.
|
||||
fn png() -> Vec<u8> {
|
||||
vec![
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73,
|
||||
68, 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174,
|
||||
66, 96, 130,
|
||||
]
|
||||
}
|
||||
|
||||
fn pic(name: &str) -> Value {
|
||||
json!({"name": name, "data": base64_encode(&png())})
|
||||
}
|
||||
|
||||
fn n_slides(s: &Session) -> usize {
|
||||
s.active().map(|d| d.doc.slides.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn pictures(s: &Session, index: usize) -> Vec<deckcraft_model::Shape> {
|
||||
s.active()
|
||||
.and_then(|d| d.doc.slides.get(index))
|
||||
.map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn box_text(s: &Session, index: usize) -> Vec<String> {
|
||||
s.active()
|
||||
.and_then(|d| d.doc.slides.get(index))
|
||||
.map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pictures_one_per_slide_are_one_undo() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album");
|
||||
assert_eq!(r["slides"], 2);
|
||||
assert_eq!(n_slides(&s), before + 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(pictures(&s, 2).len(), 1);
|
||||
assert_eq!(s.active().unwrap().selection.slide, 1);
|
||||
let undo = &s.active().unwrap().history.undo;
|
||||
assert_eq!(undo.len(), 1);
|
||||
assert_eq!(undo[0].label, "Photo Album…");
|
||||
s.execute("edit.undo", &json!({})).expect("undo");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(pictures(&s, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_up_shares_one_slide() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album");
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 2);
|
||||
assert!(box_text(&s, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captions_and_titles_use_the_file_stem() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions");
|
||||
assert_eq!(box_text(&s, 1), ["harbor"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title");
|
||||
assert_eq!(box_text(&s, 1), ["harbor"]);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y;
|
||||
assert!(pic_y > 40.0, "the picture sits below the title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_set_the_picture_line() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect");
|
||||
let line = pictures(&s, 1)[0].line.clone().expect("line");
|
||||
assert_eq!(line.width, Some(1.5));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded");
|
||||
let line = pictures(&s, 1)[0].line.clone().expect("line");
|
||||
assert_eq!(line.width, Some(2.25));
|
||||
assert_eq!(line.join, Some(LineJoin::Round));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple");
|
||||
assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_box_breaks_the_picture_row() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s
|
||||
.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"}))
|
||||
.expect("album");
|
||||
assert_eq!(r["slides"], 3);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(box_text(&s, 2), ["A day on the water"]);
|
||||
assert!(pictures(&s, 2).is_empty());
|
||||
assert_eq!(pictures(&s, 3).len(), 1);
|
||||
assert_eq!(s.active().unwrap().history.undo.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pictures_opens_the_dialog_without_editing() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let r = s.execute("insert.photoAlbum", &json!({})).expect("open");
|
||||
assert_eq!(r["dialog"], "photoAlbum");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(s.active().unwrap().history.undo.is_empty());
|
||||
assert_eq!(s.ui_requests.len(), 1);
|
||||
match &s.ui_requests[0] {
|
||||
crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"),
|
||||
other => panic!("dialog request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_adds_no_slides() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err());
|
||||
let mut many = Vec::new();
|
||||
for i in 0..49 {
|
||||
many.push(json!({"text": format!("box {i}")}));
|
||||
}
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err());
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(s.active().unwrap().history.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap();
|
||||
let one = pictures(&s, 1)[0].xfrm.unwrap().h;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap();
|
||||
let fitted = pictures(&s, 1)[0].xfrm.unwrap().h;
|
||||
assert!(fitted > one, "fit {fitted} should be taller than one-up {one}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user