//! Insert tab: shapes, text boxes, pictures, tables, charts, audio, video, WordArt, fields. use deckcraft_color::SchemeSlot; use deckcraft_geom::Xfrm; use deckcraft_model::chart::ChartType; use deckcraft_model::style::PictureFill; use deckcraft_model::text::{AutoFit, RunKind, TextBody}; use deckcraft_model::{Chart, ColorRef, Fill, Geom, Line, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table}; use serde_json::{Value, json}; use super::{ CommandSpec, NO_ID_SPACE, always, bad, base64_decode, base64_encode, bool_or, bool_param, cmd, color_value, edit_shapes, has_doc, has_selection, has_slide, has_text_or_shapes, ok, rect_param, str_param, usize_param, xfrm_of, }; use crate::{EngineError, Result, Session}; mod album; mod chart; mod links; mod picture; mod text_fields; use album::{photo_album, photo_album_edit}; pub use chart::chart_type; use chart::{chart, chart_data, chart_options, chart_type_cmd}; use links::{action_button, hyperlink, shape_action}; pub use picture::content_type; pub(crate) use picture::media_bytes; use picture::{audio, picture, video}; use text_fields::{date_time, slide_number, symbol, word_art}; pub fn specs() -> Vec { vec![ cmd!( "shape.insert", "Shapes", ["Insert", "Illustrations"], None, "{preset: rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets), rect: [x,y,w,h] pt, text?: string}", has_slide, insert_shape ), cmd!( "insert.icon", "Icons", ["Insert", "Illustrations"], None, "{name: star|arrow-right|… (see insert.icons), rect?: [x,y,w,h]}", has_slide, insert_icon ), cmd!(query "insert.icons", "Icon Gallery", [], None, "{} → [{name, label, category}]", always, list_icons), cmd!(query "shape.presets", "Shape Gallery", [], None, "{} → [{name, label, category}]", always, presets), cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{rect?: [x,y,w,h], text?: string}", has_slide, text_box), cmd!( "insert.picture", "Picture from File…", ["Insert", "Images", "Pictures"], None, "{path? | data: base64, name?, rect?: [x,y,w,h]}", has_slide, picture ), cmd!( "insert.photoAlbum", "Photo Album…", ["Insert", "Images"], None, "{pictures?: [{path?|data: base64, name?, rot?: deg, brightness?: -1..1, contrast?: -1..1} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool, grayscale?: bool, theme?: name} — omit pictures to open the dialog", has_doc, photo_album ), cmd!( "insert.photoAlbumEdit", "Edit Photo Album…", ["Insert", "Images"], None, "{album?: id, pictures?: same as insert.photoAlbum, layout?, frame?, captions?, grayscale?, theme?} — omit pictures to reopen the dialog on the current album", has_doc, photo_album_edit ), cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table), cmd!( "insert.chart", "Chart…", ["Insert", "Illustrations"], None, "{type?: column|bar|line|pie|doughnut|area|scatter|stackedColumn|…, rect?, categories?: [..], series?: [{name, values}]}", has_slide, chart ), cmd!("insert.audio", "Audio from File…", ["Insert", "Media", "Audio"], None, "{path? | data: base64, name?, rect?}", has_slide, audio), cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video), cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art), cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), cmd!( "insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog; in the Handout or Notes Master view it fills the selected date placeholder (else the master's own) and answers where: \"master\"", has_slide, date_time ), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), cmd!( "shape.action", "Action Settings", ["Insert", "Links"], None, "{on?: click|hover, action: none|next|previous|first|last|lastViewed|endShow|playMedia|slide|url|customShow|program, slide?: index, url?, show?: custom show name, path?, highlight?: bool, tooltip?, ids?}", has_selection, shape_action ), cmd!( "insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, text?: shown when nothing is selected, highlightClick?, ids?}", has_text_or_shapes, hyperlink ), cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd), cmd!( "chart.data", "Edit Data", ["Chart Design", "Data"], None, "{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], id?}", has_selection, chart_data ), cmd!( "chart.options", "Chart Elements", ["Chart Design", "Chart Layouts"], None, "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}", has_selection, chart_options ), cmd!( "insert.smartArt", "SmartArt", ["Insert", "Illustrations"], None, "{kind: list|process|cycle|hierarchy|pyramid|matrix, items?: [text] (leading tabs = sub-points), nodes?: [{text, level}]} → {id}", has_slide, smart_art ), cmd!( "insert.actionButton", "Action Buttons", ["Insert", "Illustrations"], None, "{kind: back|forward|beginning|end|home|information|return|movie|document|sound|help|custom, rect?}", has_slide, action_button ), ] } /// Default size for a click-inserted shape (1 inch square, PowerPoint style). fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm { let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) } /// The word an automatic shape name starts with ("Rectangle" in "Rectangle 3"). pub(crate) fn auto_name_base(shape: &Shape) -> String { match &shape.kind { ShapeKind::Picture { .. } => "Picture".to_string(), ShapeKind::Table(_) => "Table".to_string(), ShapeKind::Chart(_) => "Chart".to_string(), ShapeKind::Media(m) if m.video => "Video".to_string(), ShapeKind::Media(_) => "Audio".to_string(), ShapeKind::Model3d { .. } => "3D Model".to_string(), _ if shape.text_box => "TextBox".to_string(), _ => shape .geom .preset_name() .and_then(deckcraft_geom::preset::info) .map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string()) .unwrap_or_else(|| "Shape".into()), } } /// "{base} n" with the first `n` from the shape count + 1 that no shape on the slide already uses, /// so a gap left by a deleted shape never makes a second shape with the same name. `count` is the /// number of shapes, which exceeds the number of names when two shapes share one. pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet, count: usize) -> String { (count + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default() } pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result { s.edit(|doc, sel| { shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); if shape.name.is_empty() { let base = auto_name_base(&shape); let (taken, count) = super::current_names(doc, sel); shape.name = first_free_name(&base, &taken, count); } let id = shape.id; let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?; list.push(shape); if select { sel.shapes = vec![id]; sel.text = None; } Ok(id) }) } fn insert_shape(s: &mut Session, p: &Value) -> Result { let preset = str_param(p, "preset").unwrap_or("rect"); let info = deckcraft_geom::preset::info(preset).ok_or_else(|| bad("shape.insert", format!("unknown preset `{preset}`")))?; let line_like = deckcraft_geom::preset::is_line_like(preset); let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, if line_like { 0.0 } else { 72.0 })); let adj: Vec = p.get("adj").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let mut shape = Shape { xfrm: Some(rect), geom: Geom::Preset { name: info.name.into(), adj }, style: Some(if line_like { ShapeStyle::line(SchemeSlot::Accent1) } else { ShapeStyle::accent(SchemeSlot::Accent1) }), ..Default::default() }; if let Some(fl) = p.get("flip").and_then(Value::as_str) && let Some(x) = shape.xfrm.as_mut() { x.flip_h = fl.contains('h'); x.flip_v = fl.contains('v'); } if !line_like { shape.text = Some(TextBody { paragraphs: vec![], ..Default::default() }); if let Some(t) = str_param(p, "text") { shape.text = Some(TextBody::from_text(t)); } if let Some(tb) = shape.text.as_mut() { for para in &mut tb.paragraphs { para.props.align = Some(deckcraft_model::text::Align::Center); } } } else { shape.kind = if preset.contains("Connector") { ShapeKind::Connector { start: None, end: None } } else { ShapeKind::Shape }; if preset == "straightConnector1" { shape.line = Some(deckcraft_model::Line { tail: Some(deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() }), ..Default::default() }); } } if let (Some(look), false) = (&s.default_look, line_like) { super::format::apply_painted(&mut shape, look); } let id = add_shape(s, "shape.insert", shape, true)?; Ok(json!({"id": id})) } fn presets(_s: &mut Session, _p: &Value) -> Result { Ok(Value::Array( deckcraft_geom::preset::CATALOG.iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category.label()})).collect(), )) } fn list_icons(_s: &mut Session, _p: &Value) -> Result { Ok(Value::Array(crate::icons::icons().iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category})).collect())) } /// A Lucide stroke icon: custom geometry, no fill, round line scaled from the 24-unit design. fn insert_icon(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("insert.icon", "missing `name`"))?; let icon = crate::icons::find(name).ok_or_else(|| bad("insert.icon", format!("unknown icon `{name}`")))?; let d = crate::icons::path_data(icon.name).ok_or_else(|| bad("insert.icon", format!("could not build icon `{}`", icon.name)))?; let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, 72.0)); if rect.w < 1.0 || rect.h < 1.0 { return Err(bad("insert.icon", "rect needs a positive width and height")); } let shape = Shape { name: icon.label.to_string(), xfrm: Some(rect), geom: Geom::Custom { paths: vec![deckcraft_model::CustomPath { w: crate::icons::VIEW, h: crate::icons::VIEW, d, fill: deckcraft_geom::preset::FillMode::None, stroke: true, }], }, fill: Some(Fill::None), line: Some(Line { fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))), width: Some(crate::icons::stroke_width(icon, rect.w, rect.h)), cap: Some(deckcraft_model::style::LineCap::Round), join: Some(deckcraft_model::style::LineJoin::Round), ..Default::default() }), style: Some(ShapeStyle::line(SchemeSlot::Tx1)), ..Default::default() }; let id = add_shape(s, "insert.icon", shape, true)?; Ok(json!({"id": id})) } pub(crate) fn new_text_box(rect: Xfrm, text: &str) -> Shape { let mut body = TextBody::from_text(text); body.body.wrap = Some(true); body.body.autofit = Some(AutoFit::Shape); Shape { xfrm: Some(rect), text_box: true, fill: None, text: Some(body), geom: Geom::preset("rect"), ..Default::default() } } fn text_box(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or(""); let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 216.0, 36.0)); let mut shape = new_text_box(rect, text); // Click-to-create (no width) text boxes grow sideways without wrapping. if rect.w < 1.0 { shape.xfrm = Some(Xfrm { w: 100.0, ..rect }); if let Some(t) = shape.text.as_mut() { t.body.wrap = Some(false); } } let id = add_shape(s, "insert.textBox", shape, true)?; super::text::fit_text_box(s, id)?; if text.is_empty() { // Start typing immediately. s.select(|_, sel| { sel.shapes = vec![id]; sel.text = Some(crate::TextSel { shape: id, ..Default::default() }); })?; } Ok(json!({"id": id})) } fn table(s: &mut Session, p: &Value) -> Result { let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); let size = s.doc()?.doc.slide_size; let w = size.width * 0.8; let rh = 40.0; let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.1, (size.height - rh * rows as f64).max(0.0) / 2.0, w, rh * rows as f64)); let mut t = Table::new(rows, cols, rect.w, rect.h / rows as f64); if let Some(data) = p.get("data").and_then(Value::as_array) { for (r, row) in data.iter().enumerate() { for (c, v) in row.as_array().map(|a| a.as_slice()).unwrap_or(&[]).iter().enumerate() { if let (Some(cell), Some(text)) = (t.cell_mut(r, c), v.as_str()) { cell.text = TextBody::from_text(text); } } } } let id = add_shape(s, "insert.table", Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?; Ok(json!({"id": id})) } /// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with /// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead. fn smart_art(s: &mut Session, p: &Value) -> Result { let kind = str_param(p, "kind").unwrap_or("process").to_string(); let nodes = match p.get("nodes") { Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("insert.smartArt", e.to_string()))?, None => super::smartart::nodes_from_lines(p.get("items").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str)), }; super::smartart::insert(s, &kind, nodes) } #[cfg(test)] mod tests { use deckcraft_model::text::RunKind; use serde_json::json; use crate::Session; fn session() -> Session { Session::with_new() } fn undo(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } fn date_run(s: &Session) -> Option { s.doc() .unwrap() .current_slide() .unwrap() .shapes .iter() .find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) .and_then(|x| x.text.as_ref()) .and_then(|t| t.paragraphs.first()) .and_then(|p| p.runs.first()) .cloned() } fn title_field(s: &Session) -> deckcraft_model::Run { let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); title.paragraphs[0].runs.iter().find(|r| r.text.contains(':') || matches!(r.kind, RunKind::Field { .. })).cloned().unwrap() } fn edit_title(s: &mut Session) { let id = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": id})).unwrap(); s.execute("text.insert", &json!({"text": "On "})).unwrap(); } #[test] fn no_params_opens_the_dialog_and_edits_nothing() { let mut s = session(); let rev = s.doc().unwrap().revision; for p in [json!({}), serde_json::Value::Null] { s.ui_requests.clear(); s.execute("insert.dateTime", &p).unwrap(); assert!(matches!(&s.ui_requests[..], [crate::UiRequest::Dialog { id, .. }] if id == "dateTime")); assert_eq!(s.doc().unwrap().revision, rev); assert_eq!(undo(&s), 0); assert!(date_run(&s).is_none()); } } #[test] fn a_format_at_the_caret_is_one_updating_field() { let mut s = session(); edit_title(&mut s); let before = undo(&s); let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap(); assert_eq!(v["where"], "caret"); let run = title_field(&s); assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); assert!(run.text.contains(':'), "{run:?}"); assert_eq!(undo(&s), before + 1); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Date & Time"); s.execute("edit.undo", &json!({})).unwrap(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().text(); assert_eq!(title, "On "); } #[test] fn update_false_inserts_plain_text_in_the_chosen_language() { let mut s = session(); edit_title(&mut s); let before = undo(&s); let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); let expect = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now).unwrap(); s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); let run = title.paragraphs[0].runs.iter().find(|r| r.text == expect).cloned().unwrap(); assert_eq!(run.kind, RunKind::Text); assert_eq!(run.props.lang.as_deref(), Some("en-GB")); assert_eq!(undo(&s), before + 1); } #[test] fn a_long_british_date_puts_the_day_first() { let mut s = session(); edit_title(&mut s); let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-GB"})).unwrap(); let text = v["text"].as_str().unwrap().to_string(); assert!(text.chars().next().is_some_and(|c| c.is_ascii_digit()) && !text.contains(','), "{text}"); let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-US"})).unwrap(); assert!(v["text"].as_str().is_some_and(|t| t.contains(',')), "{v}"); } #[test] fn a_named_format_in_french_and_a_bad_value_change_nothing() { let mut s = session(); edit_title(&mut s); let rev = s.doc().unwrap().revision; let undos = undo(&s); let err = s.execute("insert.dateTime", &json!({"format": "datetime2", "language": "fr-FR"})).unwrap_err(); assert!(err.to_string().contains("isn't available"), "{err}"); assert_eq!(s.doc().unwrap().revision, rev); assert_eq!(undo(&s), undos); for p in [ json!({"format": "datetime14"}), json!({"format": "nope"}), json!({"update": "yes"}), json!({"language": "de-DE"}), json!({"language": ""}), ] { let err = s.execute("insert.dateTime", &p).unwrap_err(); assert!(err.to_string().contains("insert.dateTime"), "{err}"); assert_eq!(s.doc().unwrap().revision, rev); assert_eq!(undo(&s), undos); } } /// ZZ1: in the handout and notes master views the date goes into the master's date placeholder /// (not the current slide), so the exported handout and notes pages show it. #[test] fn on_a_page_master_the_date_fills_its_date_placeholder() { for view in ["view.handoutMaster", "view.notesMaster"] { let mut s = session(); s.execute(view, &json!({})).unwrap(); let (id, master) = { let st = s.doc().unwrap(); let m = crate::page_master(&st.doc, st.selection.target).unwrap().clone(); (m.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).unwrap().id.0, m) }; assert!(master.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty())); s.execute("edit.select", &json!({"ids": [id]})).unwrap(); let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap(); assert_eq!(v["where"], "master", "{view}: {v}"); assert!(date_run(&s).is_none(), "the slide keeps no date"); let st = s.doc().unwrap(); let m = crate::page_master(&st.doc, st.selection.target).unwrap(); let run = m .shapes .iter() .find(|x| x.id.0 == id) .and_then(|x| x.text.as_ref()) .and_then(|t| t.paragraphs.first()) .and_then(|p| p.runs.first()) .cloned() .unwrap(); assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{view}: {run:?}"); let placed = deckcraft_render::place_master_texts(&st.doc, m, 1); assert!(placed.iter().any(|p| p.body.text() == run.text && run.text.contains(':')), "{view}: the page shows the date"); s.execute("edit.undo", &json!({})).unwrap(); let st = s.doc().unwrap(); let m = crate::page_master(&st.doc, st.selection.target).unwrap(); assert!(m.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()), "one undo"); // A fixed date in a placeholder whose run is en-GB reads day first, and the answer // reports the text that was written, not the en-US sample. s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap(); let v = s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false})).unwrap(); let st = s.doc().unwrap(); let m = crate::page_master(&st.doc, st.selection.target).unwrap(); let run = m .shapes .iter() .find(|x| x.id.0 == id) .and_then(|x| x.text.as_ref()) .and_then(|t| t.paragraphs.first()) .and_then(|p| p.runs.first()) .cloned() .unwrap(); assert_eq!(run.props.lang.as_deref(), Some("en-GB"), "{view}: {run:?}"); assert_eq!(v["text"].as_str(), Some(run.text.as_str()), "{view}: the answer reports the written text: {v}"); let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); let gb = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now); assert_eq!(Some(run.text.as_str()), gb.as_deref(), "{view}: the date in en-GB order (dd/mm/yyyy)"); // No date placeholder: refused, nothing changed. let key = if view == "view.handoutMaster" { "handout.placeholders" } else { "notes.placeholders" }; s.execute(key, &json!({"date": false})).unwrap(); let rev = s.doc().unwrap().revision; assert!(s.execute("insert.dateTime", &json!({"format": "datetime1"})).is_err()); assert_eq!(s.doc().unwrap().revision, rev); } } #[test] fn without_a_caret_the_date_lands_on_this_slide_only() { let mut s = session(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); s.execute("slide.go", &json!({"index": 0})).unwrap(); let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); let clock = deckcraft_text::datetime::field_text("datetime10", Some("en-US"), now).unwrap(); let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "language": "en_US"})).unwrap(); assert_eq!(v["where"], "footer"); let run = date_run(&s).unwrap(); assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{run:?}"); assert_eq!(run.text, clock); assert_eq!(run.props.lang.as_deref(), Some("en-US")); s.execute("slide.go", &json!({"index": 1})).unwrap(); assert!(date_run(&s).is_none(), "the other slide keeps its own footer"); s.execute("slide.go", &json!({"index": 0})).unwrap(); let fixed = deckcraft_text::datetime::field_text("datetime12", None, now).unwrap(); s.execute("insert.dateTime", &json!({"format": "datetime12", "update": false})).unwrap(); let run = date_run(&s).unwrap(); assert_eq!((run.kind, run.text), (RunKind::Text, fixed)); s.execute("edit.undo", &json!({})).unwrap(); let run = date_run(&s).unwrap(); assert!(matches!(run.kind, RunKind::Field { .. }), "one undo restores the field"); } } #[cfg(test)] mod icon_and_picture_tests;