mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
Merge PR #668 from NikitkaRa: UI: Date and Time dialog inserts a format in one step
# Conflicts: # crates/ui-egui/src/dialogs.rs
This commit is contained in:
@@ -174,6 +174,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | File › New starter decks: Briefing, Lesson, Pitch, Field Notes, Quarterly and Evening Talk, original text on our themes (`file.new {template}`, `file.templates`). The large template gallery is still open |
|
||||
| 2026-10-14 | Slide Sorter sections: Add Section, rename in place, collapse a section, and drag the bar to move that section and its slides |
|
||||
| 2026-10-14 | Insert Hyperlink: address, email, slide and ScreenTip open from the selection, so OK no longer replaces them with `https://` and an empty tip |
|
||||
| 2026-10-14 | Insert › Date and Time: the ribbon and Insert menu open a format list (datetime1–datetime13) with a language and Update automatically. OK inserts one field, or the shown text when Update is off. 28 dialogs; 226 commands. English has every format; en-GB, French, Japanese and Swedish list the numeric date and 24-hour time |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -219,6 +220,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | trivial | Progress log: six original starter decks |
|
||||
| 2026-10-14 | trivial | Progress log: Slide Sorter sections |
|
||||
| 2026-10-14 | trivial | Progress log: Hyperlink dialog follows the selection |
|
||||
| 2026-10-14 | trivial | Progress log: Insert › Date and Time dialog (28 dialogs, 226 commands; breadth and readiness unchanged) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
|
||||
@@ -57,7 +57,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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..}", has_slide, date_time),
|
||||
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",
|
||||
has_slide,
|
||||
date_time
|
||||
),
|
||||
cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol),
|
||||
cmd!(
|
||||
"shape.action",
|
||||
@@ -622,10 +630,198 @@ fn field_run(s: &mut Session, field: &str, text: &str) -> Result<Value> {
|
||||
fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
field_run(s, "slidenum", "‹#›")
|
||||
}
|
||||
|
||||
const DATE_FORMATS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
|
||||
struct DateOpts {
|
||||
format: String,
|
||||
update: bool,
|
||||
/// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language.
|
||||
lang: Option<String>,
|
||||
sample: String,
|
||||
}
|
||||
|
||||
/// `true` when the call carries a format, update flag or language (apply it). Empty params open
|
||||
/// the dialog instead.
|
||||
fn date_time_apply(p: &Value) -> bool {
|
||||
p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language"))
|
||||
}
|
||||
|
||||
fn canon_lang(raw: &str) -> Option<&'static str> {
|
||||
match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() {
|
||||
"en" | "en-us" => Some("en-US"),
|
||||
"en-gb" => Some("en-GB"),
|
||||
"fr" | "fr-fr" => Some("fr-FR"),
|
||||
"ja" | "ja-jp" => Some("ja-JP"),
|
||||
"sv" | "sv-se" => Some("sv-SE"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn date_opts(p: &Value) -> Result<DateOpts> {
|
||||
if p.get("format").is_some() {
|
||||
let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f));
|
||||
if f.is_none() {
|
||||
return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13"));
|
||||
}
|
||||
}
|
||||
if p.get("update").is_some() && bool_param(p, "update").is_none() {
|
||||
return Err(bad("insert.dateTime", "`update` must be true or false"));
|
||||
}
|
||||
let lang = if p.get("language").is_some() {
|
||||
let raw = str_param(p, "language").filter(|l| !l.trim().is_empty());
|
||||
let Some(raw) = raw else {
|
||||
return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"));
|
||||
};
|
||||
Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let format = str_param(p, "format").unwrap_or("datetime1").to_string();
|
||||
let sample = date_sample(&format, lang.as_deref())?;
|
||||
Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample })
|
||||
}
|
||||
|
||||
/// The text `format` shows now in `lang`. Errors when that language has no names for the format
|
||||
/// (month names and AM/PM are English only). An empty string where there is no clock (wasm).
|
||||
fn date_sample(format: &str, lang: Option<&str>) -> Result<String> {
|
||||
use deckcraft_text::datetime::{DateTime, field_text};
|
||||
let probe = DateTime::new(2026, 10, 9, 20, 5, 7);
|
||||
if field_text(format, lang, probe).is_none() {
|
||||
return Err(bad("insert.dateTime", "this format isn't available in that language"));
|
||||
}
|
||||
Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = str_param(p, "format").unwrap_or("datetime1").to_string();
|
||||
// Saved with the date it shows now (the text kept where the date can't be computed).
|
||||
field_run(s, &f, &super::design::date_field_text(&f))
|
||||
if !date_time_apply(p) {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) });
|
||||
return ok();
|
||||
}
|
||||
let opts = date_opts(p)?;
|
||||
let (doc, sel) = {
|
||||
let st = s.doc()?;
|
||||
(st.doc.clone(), st.selection.clone())
|
||||
};
|
||||
let r = apply_date(s, &opts);
|
||||
if r.is_err()
|
||||
&& let Some(st) = s.active_mut()
|
||||
{
|
||||
st.doc = doc;
|
||||
st.selection = sel;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
if o.update {
|
||||
super::text::insert_field(s, &o.format, &o.sample)?;
|
||||
if let Some(lang) = &o.lang {
|
||||
stamp_caret_lang(s, lang)?;
|
||||
}
|
||||
} else {
|
||||
insert_plain(s, &o.sample, o.lang.as_deref())?;
|
||||
}
|
||||
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
|
||||
}
|
||||
// Not editing: the slide's date placeholder (this slide only). A layout with no date
|
||||
// placeholder gets a text box instead, so the chosen format is still inserted.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
|
||||
if !write_date_placeholder(s, o)? {
|
||||
return insert_date_box(s, o);
|
||||
}
|
||||
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
|
||||
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
|
||||
let format = o.format.clone();
|
||||
let sample = o.sample.clone();
|
||||
let lang = o.lang.clone();
|
||||
let update = o.update;
|
||||
s.edit(|doc, sel| {
|
||||
let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) };
|
||||
let sl = std::sync::Arc::make_mut(sl);
|
||||
let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
if let Some(lang) = lang {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
|
||||
let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() };
|
||||
let body = sh.text.clone().unwrap_or_default();
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let y = (size.height - 48.0).max(0.0);
|
||||
s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?;
|
||||
if o.update {
|
||||
super::text::insert_field(s, &o.format, &o.sample)?;
|
||||
} else {
|
||||
super::text::insert_text(s, &o.sample, None)?;
|
||||
}
|
||||
if let Some(lang) = &o.lang {
|
||||
stamp_caret_lang(s, lang)?;
|
||||
}
|
||||
Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge,
|
||||
/// so the date stays its own run next to the surrounding text.
|
||||
fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> {
|
||||
let text = text.to_string();
|
||||
let lang = lang.map(str::to_string);
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = deckcraft_model::edit::delete(body, a, b);
|
||||
let mut props = deckcraft_model::edit::props_at(body, at);
|
||||
if let Some(lang) = lang.clone() {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let c = deckcraft_model::edit::insert(body, at, &text, Some(props));
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates.
|
||||
fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> {
|
||||
let lang = lang.to_string();
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return };
|
||||
let mut at = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let n = r.char_len();
|
||||
if n > 0 && at < t.caret.1 && t.caret.1 <= at + n {
|
||||
r.props.lang = Some(lang);
|
||||
return;
|
||||
}
|
||||
at += n;
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -834,3 +1030,139 @@ fn smart_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
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<deckcraft_model::Run> {
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Insert › Date and Time, driven like a person: the ribbon opens the dialog, a format is
|
||||
//! chosen, OK inserts it in one undo, Cancel leaves the slide alone.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.ui.tab = "Insert".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn date_run(h: &Harness<'_, SlideApp>) -> Option<deckcraft_model::Run> {
|
||||
h.state()
|
||||
.session
|
||||
.active()
|
||||
.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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_opens_the_dialog_and_ok_inserts_one_field() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Date & Time").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("dateTime"));
|
||||
let _ = h.get_by_label("Update automatically");
|
||||
let _ = h.get_by_label("English (United States)");
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().unwrap();
|
||||
let sample = deckcraft_text::datetime::field_text("datetime2", Some("en-US"), now).unwrap();
|
||||
h.get_by_label(&sample).click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let run = date_run(&h).unwrap();
|
||||
assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime2"), "{run:?}");
|
||||
assert_eq!(run.text, sample);
|
||||
let undo = &h.state().session.active().unwrap().history.undo;
|
||||
assert_eq!(undo.len(), 1);
|
||||
assert_eq!(undo[0].label, "Date & Time");
|
||||
h.state_mut().session.execute("edit.undo", &json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert!(date_run(&h).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_closes_the_dialog_without_a_date() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Date & Time").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(date_run(&h).is_none());
|
||||
assert!(h.state().session.active().unwrap().history.undo.is_empty());
|
||||
}
|
||||
@@ -49,6 +49,7 @@ fn title(id: &str) -> &'static str {
|
||||
"export" => "Export",
|
||||
"symbol" => "Symbol",
|
||||
"equation" => "Equation",
|
||||
"dateTime" => "Date and Time",
|
||||
"spelling" => "Spelling",
|
||||
"pasteSpecial" => "Paste Special",
|
||||
"accessibility" => "Accessibility Checker",
|
||||
@@ -1173,6 +1174,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let (ok, _) = buttons(ui, "Done");
|
||||
ok
|
||||
}
|
||||
"dateTime" => date_time_dialog(app, ui, d),
|
||||
_ => {
|
||||
ui.label(format!("“{}” is not available yet.", d.id));
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
@@ -1420,6 +1422,97 @@ fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool
|
||||
cancel
|
||||
}
|
||||
|
||||
const DATE_LANGS: &[(&str, &str)] = &[
|
||||
("en-US", "English (United States)"),
|
||||
("en-GB", "English (United Kingdom)"),
|
||||
("fr-FR", "French (France)"),
|
||||
("ja-JP", "Japanese"),
|
||||
("sv-SE", "Swedish"),
|
||||
];
|
||||
|
||||
const DATE_KINDS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
|
||||
/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only;
|
||||
/// other languages keep the numeric date and the 24-hour times.
|
||||
fn date_rows(lang: &str) -> Vec<(&'static str, String)> {
|
||||
let now = deckcraft_text::datetime::DateTime::now_local();
|
||||
let mut rows = Vec::new();
|
||||
for kind in DATE_KINDS {
|
||||
if let Some(now) = now
|
||||
&& let Some(sample) = deckcraft_text::datetime::field_text(kind, Some(lang), now)
|
||||
{
|
||||
rows.push((kind, sample));
|
||||
}
|
||||
}
|
||||
if rows.is_empty() {
|
||||
rows.push(("datetime1", "datetime1".into()));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.set_min_width(440.0);
|
||||
ui.label("Available formats:");
|
||||
let lang = d.get("language", "en-US");
|
||||
let rows = date_rows(&lang);
|
||||
if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1"))
|
||||
&& let Some((k, _)) = rows.first()
|
||||
{
|
||||
d.fields.insert("format".into(), (*k).to_string());
|
||||
}
|
||||
let format = d.get("format", "datetime1");
|
||||
egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(220.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for (kind, sample) in &rows {
|
||||
if ui.selectable_label(format == *kind, sample).clicked() {
|
||||
d.fields.insert("format".into(), (*kind).to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Language:");
|
||||
let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| {
|
||||
for (k, l) in DATE_LANGS {
|
||||
if ui.selectable_label(lang == *k, *l).clicked() {
|
||||
d.fields.insert("language".into(), (*k).to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, lang_label));
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.")
|
||||
.size(11.0)
|
||||
.color(Tokens::get(ui.ctx()).text_dim),
|
||||
);
|
||||
let update = check(ui, d, "Update automatically", "update", true);
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let lang = d.get("language", "en-US");
|
||||
let mut format = d.get("format", "datetime1");
|
||||
let probe = deckcraft_text::datetime::DateTime::new(2026, 10, 9, 20, 5, 7);
|
||||
if deckcraft_text::datetime::field_text(&format, Some(&lang), probe).is_none() {
|
||||
format = "datetime1".into();
|
||||
}
|
||||
return app.run("insert.dateTime", json!({"format": format, "update": update, "language": lang})).is_ok();
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
fn pretty_equation(s: &str) -> String {
|
||||
let sup = |c: char| match c {
|
||||
'0' => '⁰',
|
||||
|
||||
@@ -1237,6 +1237,10 @@ mod ime_tests;
|
||||
#[cfg(test)]
|
||||
mod ui_soak_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "datetime_tests.rs"]
|
||||
mod datetime_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
@@ -236,7 +236,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
|
||||
fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) {
|
||||
let en = enabled(app, id);
|
||||
if big_button(ui, icon, label, en).on_hover_text(tip(id, label)).clicked() {
|
||||
let resp = big_button(ui, icon, label, en);
|
||||
if id == "insert.dateTime" {
|
||||
let name = label.replace('\n', " ");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, &name));
|
||||
}
|
||||
if resp.on_hover_text(tip(id, label)).clicked() {
|
||||
run(app, id, p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Size and Position dialog, 227 commands, 28 dialogs; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (228 commands; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; 28 dialogs, 226 commands; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never
|
||||
break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what
|
||||
@@ -25,6 +26,8 @@ nothing below L6 uses egui, eframe, winit or rfd.
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (228 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
|
||||
|
||||
The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with
|
||||
@@ -83,5 +86,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs |
|
||||
| 2026-10-14 | trivial | Command registry: 228 (`comment.next`, `comment.prev`) |
|
||||
| 2026-10-14 | minor | Insert › Date and Time dialog (`insert.dateTime` with no keys). 28 dialogs; command count stays 226 |
|
||||
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
|
||||
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Icons places a Lucide stroke set; 3-D models and stock content remain; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Size and Position dialog; previous: minor, 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (six original starter decks on File › New; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Date and Time dialog; 28 dialogs; previous: 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -303,6 +304,8 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h.
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Replace Fonts, Action Settings, Print, Insert Object…). Size and Position is one of the 28. 8–14 h.
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Replace Fonts, Action Settings, Print, Insert Object…). Date and Time is in. 8–14 h.
|
||||
- **Command palette defaults to an Export entry that fails** (#79). 0.5 h.
|
||||
|
||||
## Hardware
|
||||
@@ -377,6 +380,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-14 | minor | Insert › Icons places Lucide 1.56.0 stroke icons as custom shapes. The checklist row stays missing until 3-D models and stock content exist |
|
||||
| 2026-10-14 | minor | Size and Position dialog landed (position, size, rotation, scale, lock aspect, one undo). The dialog gap remains Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object and the rest of the ~60 |
|
||||
| 2026-10-14 | minor | File › New has six original starter decks; a large template gallery is still open |
|
||||
| 2026-10-14 | minor | Insert › Date and Time dialog. Dialog count 27 → 28; Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object are still missing |
|
||||
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
|
||||
| 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
|
||||
@@ -224,7 +224,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
|
||||
text readable.
|
||||
- **Text:** in-place editing with caret, selection, keyboard and mouse; every Home-tab font and
|
||||
paragraph control; bullets and numbering; levels; autofit; columns; vertical text; date fields in
|
||||
their own format; hyperlinks drawn in the theme's hyperlink colour and followed in the show.
|
||||
their own format (Insert › Date and Time picks the format and language, or inserts the sample as
|
||||
fixed text); hyperlinks drawn in the theme's hyperlink colour and followed in the show.
|
||||
UAX #9 bidi and Arabic shaping with HarfRust, script font slots with per-cluster fallback,
|
||||
`justLow` kashida, bidi caret and selection.
|
||||
- **Editing:** select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate;
|
||||
@@ -287,6 +288,7 @@ scored done (16 open issues).
|
||||
| 2026-10-14 | trivial | Size and Position dialog. Command count 226 → 227. Breadth stays 79%, ready stays ~55%, UI stays ~60% |
|
||||
| 2026-10-14 | trivial | 228 commands; Review lists previous and next comment. Breadth stays 79%; the comments checklist row stays D |
|
||||
| 2026-10-14 | minor | Ecosystem evidence: six original starter decks. The dimension stays ~25% because PowerPoint's large gallery is still absent |
|
||||
| 2026-10-14 | trivial | Insert › Date and Time noted under what works today. The d556ff9 snapshot (226 commands, 27 dialogs) and the readiness percentages are unchanged |
|
||||
| 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |
|
||||
| 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Comments pane lists every slide; Review Previous/Next; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (View › New Window opens a second window on the same presentation; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Hyperlink dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
||||
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
||||
@@ -77,6 +79,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Design Ideas (Designer) | yes (cloud) | no |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Size and Position, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink (address, slide and ScreenTip follow the selection), Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Date and Time, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
|
||||
## Direct manipulation
|
||||
|
||||
@@ -175,6 +178,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| 2026-10-14 | minor | File › Home and New show six original starter decks; online templates are still absent |
|
||||
| 2026-10-14 | minor | Slide Sorter draws a bar per section: Add Section, rename in place, collapse, and drag the bar to move the section and its slides |
|
||||
| 2026-10-14 | trivial | Hyperlink dialog: address, email, slide and ScreenTip follow the selection |
|
||||
| 2026-10-14 | minor | Insert › Date and Time: format list, language, Update automatically. 28 dialogs. Languages are the ones the date field can compute (English, plus numeric dates for en-GB, fr-FR, ja-JP, sv-SE) |
|
||||
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
|
||||
Reference in New Issue
Block a user