From 1ce5f686e80cb496751d6d047adef4fd70e806c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=BA=C5=A1=20Laco?= Date: Sun, 11 Oct 2026 16:51:21 +0200 Subject: [PATCH 01/69] Notes: speaker notes show their formatting and keep it when edited Notes from a .pptx were read and written with their formatting, but the notes pane and Notes Page drew them as plain text (Slide::notes_text in a plain TextEdit), and any edit there saved the text back through slide.notes as TextBody::from_text, dropping every bold run, level, indent and bullet of the slide's notes. - ui-egui: a notes editor (notes.rs) shared by the notes pane and Notes Page. It draws bold, italic, underline, strikethrough and size from the runs, and each paragraph's margin, hanging indent and bullet or number (Wingdings/Symbol bullets mapped as on slides); paragraphs are laid out one by one so wrapped lines align with the text, not the bullet. Each edit is applied to a formatted draft, so typed text takes the formatting where it lands (Enter in a bulleted point starts another), and the draft is saved once the field loses focus or the slide changes. - engine: slide.notes takes `body` (formatted notes, as slide.inspect's new `notesBody`), and `text` now changes only what was retyped (model edit::replace_text); text sharing nothing with the old notes is still plain. - text: map_symbol_bullet is public for the notes editor. PDF notes pages still print the notes as plain text. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/inspect.rs | 3 +- crates/engine/src/cmd/slide.rs | 22 ++- crates/engine/src/tests.rs | 43 ++++ crates/model/src/edit.rs | 87 ++++++++ crates/pptx/tests/notes.rs | 82 ++++++++ crates/text/src/lib.rs | 3 +- crates/ui-egui/src/lib.rs | 91 ++++++++- crates/ui-egui/src/notes.rs | 327 +++++++++++++++++++++++++++++++ crates/ui-egui/src/sorter.rs | 33 ++-- crates/ui-egui/src/status.rs | 48 ++--- docs/file-format-parity.md | 5 +- 11 files changed, 698 insertions(+), 46 deletions(-) create mode 100644 crates/pptx/tests/notes.rs create mode 100644 crates/ui-egui/src/notes.rs diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index db162a38..62f9abd1 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -10,7 +10,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document), - cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide), + cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide), cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape), cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands), ] @@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result { "transition": serde_json::to_value(&sl.transition).unwrap_or_default(), "animations": serde_json::to_value(&sl.animations).unwrap_or_default(), "notes": sl.notes_text(), + "notesBody": sl.notes, "comments": sl.comments.len(), "hidden": sl.hidden, })) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..24dcda2e 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -32,7 +32,15 @@ pub fn specs() -> Vec { 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, index?}", has_slide, notes), + 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), @@ -463,10 +471,20 @@ fn reset(s: &mut Session, p: &Value) -> Result { fn notes(s: &mut Session, p: &Value) -> Result { 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::(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) { - Arc::make_mut(sl).notes = TextBody::from_text(&text); + let notes = &mut Arc::make_mut(sl).notes; + match body { + Some(b) => *notes = b, + None => deckcraft_model::edit::replace_text(notes, &text), + } } Ok(()) })?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b7..dc879641 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -543,3 +543,46 @@ fn inserted_hyperlinks_look_like_links() { s.execute("format.underline", &json!({})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); } + +#[test] +fn editing_notes_keeps_their_formatting_through_pptx() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + // A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint. + let mut p = deckcraft_model::defaults::new_presentation(None); + let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() }; + let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() }, + point("First point"), + point("Second point"), + ], + ..Default::default() + }; + let bytes = deckcraft_pptx::export(&p).unwrap(); + let mut s = session(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap(); + // Type a word into the second bullet, as the notes pane does. + s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap(); + let check = |notes: &TextBody| { + assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point"); + assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true)); + for para in ¬es.paragraphs[1..] { + assert_eq!(para.level, 1); + assert_eq!(para.props.margin_left, Some(22.5)); + assert_eq!(para.props.indent, Some(-22.5)); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() })); + } + }; + check(&s.doc().unwrap().doc.slides[0].notes); + let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap(); + check(&reopened.slides[0].notes); + // slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are. + let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone(); + s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain"); + s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap(); + check(&s.doc().unwrap().doc.slides[0].notes); + assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err()); +} diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index dcdc5089..644fdc4b 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -355,6 +355,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { } } +/// The position of the `n`th character of the body's plain text (`\n` between paragraphs). +fn pos_of_char(body: &TextBody, mut n: usize) -> Pos { + for (pi, p) in body.paragraphs.iter().enumerate() { + let len = p.char_len(); + if n <= len { + return (pi, n); + } + n -= len + 1; + } + end(body) +} + +/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in +/// place: only the span between the longest common prefix and suffix changes, so untouched text +/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text +/// that shares nothing with the old text replaces it as plain text. +pub fn replace_text(body: &mut TextBody, text: &str) { + let old: Vec = body.text().chars().collect(); + let new: Vec = text.chars().collect(); + if old == new { + return; + } + let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let room = old.len().min(new.len()) - pre; + let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count(); + if pre == 0 && suf == 0 { + *body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) }; + return; + } + let a = pos_of_char(body, pre); + let b = pos_of_char(body, old.len() - suf); + let at = delete(body, a, b); + let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect(); + if !mid.is_empty() { + insert(body, at, &mid, None); + } +} + /// End of the body. pub fn end(body: &TextBody) -> Pos { let pi = body.paragraphs.len().saturating_sub(1); @@ -472,4 +510,53 @@ mod tests { assert_eq!(b.paragraphs[0].char_len(), 3); assert_eq!(b.paragraphs.len(), 1); } + + /// Speaker notes: a bold heading over indented bullet points. + fn notes() -> TextBody { + use crate::text::{Bullet, ParaProps}; + let mut b = body("Overview\nFirst point\nSecond point"); + format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true)); + for p in b.paragraphs.iter_mut().skip(1) { + p.level = 1; + p.props = + ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() }; + } + b + } + + #[test] + fn replace_text_keeps_formatting() { + let before = notes(); + // Same text: nothing changes. + let mut b = before.clone(); + replace_text(&mut b, "Overview\nFirst point\nSecond point"); + assert_eq!(b, before); + // A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet. + replace_text(&mut b, "Overview\nFirst main point\nSecond point"); + assert_eq!(b.text(), "Overview\nFirst main point\nSecond point"); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props); + assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1)); + // Typing at the end of the heading continues it in bold. + replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point"); + assert_eq!(b.paragraphs[0].runs.len(), 1); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + // Enter after a bullet starts another bullet; deleting one joins nothing else. + replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point"); + assert_eq!(b.paragraphs.len(), 4); + assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props)); + replace_text(&mut b, "Overview (5 min)\nSecond point"); + assert_eq!(b.text(), "Overview (5 min)\nSecond point"); + assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(b.paragraphs[1].level, 1); + // Text with nothing in common is new plain text. + replace_text(&mut b, "Plain"); + assert_eq!(b, body("Plain")); + replace_text(&mut b, ""); + assert!(b.is_empty()); + // Into an empty body. + let mut e = TextBody::default(); + replace_text(&mut e, "a\nb"); + assert_eq!(e.text(), "a\nb"); + } } diff --git a/crates/pptx/tests/notes.rs b/crates/pptx/tests/notes.rs new file mode 100644 index 00000000..b5863d51 --- /dev/null +++ b/crates/pptx/tests/notes.rs @@ -0,0 +1,82 @@ +//! Speaker notes keep their run and paragraph formatting: a bold heading, indented points with +//! custom bullets, plain lines. + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::defaults; +use deckcraft_model::text::{Bullet, ParaProps, TextBody}; + +const NOTES: &str = "ppt/notesSlides/notesSlide1.xml"; + +/// A notes slide written the way PowerPoint writes one (Slovak text): a bold heading, a plain +/// line, two level-2 points with a hanging indent and a Wingdings square bullet (one mixing a bold +/// label and plain text), a level-2 paragraph with buNone, and a closing plain line. +const NOTES_XML: &str = r#" +Metodický prehľadŽiaci pracujú vo dvojiciach.Cieľ: vysvetliť cyklusReflexiaBez odrážkyPomôcky: počítač"#; + +/// A deck with one notes slide, its notes replaced by [`NOTES_XML`]. +fn deck() -> Vec { + let mut p = defaults::new_presentation(None); + Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder"); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut found = false; + for i in 0..src.len() { + let mut f = src.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + if name == NOTES { + data = NOTES_XML.as_bytes().to_vec(); + found = true; + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + assert!(found, "the export has no {NOTES}"); + out.finish().expect("finish").into_inner() +} + +fn assert_formatted(notes: &TextBody) { + assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač"); + let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") }; + assert_eq!(head.runs[0].props.bold, Some(true), "bold heading"); + for para in [head, plain, closing] { + assert_eq!((para.level, ¶.props), (0, &ParaProps::default()), "{:?}", para.text()); + } + assert_eq!(plain.runs[0].props.bold, None); + for para in [goal, reflect] { + assert_eq!(para.level, 1, "level 2"); + assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU"); + assert_eq!(para.props.indent, Some(-22.5), "hanging indent"); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() })); + assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings")); + } + assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label"); + assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain"); + assert_eq!(reflect.runs[0].props.italic, Some(true)); + assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5))); +} + +#[test] +fn notes_import_keeps_bold_levels_and_indents() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + assert_formatted(&p.slides[0].notes); +} + +#[test] +fn notes_formatting_survives_a_round_trip() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let again = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport"); + assert_formatted(&again.slides[0].notes); + // And the written XML says so, for PowerPoint. + let bytes = deckcraft_pptx::export(&again).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + z.by_name(NOTES).expect("notes part").read_to_string(&mut xml).expect("utf-8"); + assert!(xml.contains(r#"b="1""#), "{xml}"); + assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}"); + assert!(xml.contains(r#""), "{xml}"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 225f4afb..07f4f3ec 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,8 @@ fn shape_item( } } -fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { +/// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character. +pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase(); let first = c.chars().next().unwrap_or('•'); if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..f4292eb0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod fillui; pub mod icons; pub mod media; pub mod menus; +pub mod notes; pub mod panes; pub mod ribbon; pub mod show; @@ -206,7 +207,7 @@ pub struct SlideApp { pub last_recovery: f64, /// Quit was confirmed (changes saved or discarded): let the window close. pub quit_confirmed: bool, - pub(crate) notes_buf: (u64, usize, String), + pub(crate) notes_buf: (u64, usize, notes::Draft), /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, } @@ -242,7 +243,7 @@ impl SlideApp { last_time: 0.0, last_recovery: 0.0, quit_confirmed: false, - notes_buf: (0, usize::MAX, String::new()), + notes_buf: (0, usize::MAX, notes::Draft::default()), thumb_drag: None, } } @@ -938,4 +939,90 @@ mod tests { app.run("app.home", json!({})).unwrap(); assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway"); } + + /// Speaker notes keep their formatting when edited in the notes pane: typing into a bold + /// heading, Enter in a bulleted point, then saving as .pptx and reopening. + #[test] + fn editing_notes_in_the_pane_keeps_their_formatting() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + let point = |runs: Vec| Paragraph { + level: 1, + props: ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(Bullet::Char { char: "§".into() }), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }, + runs, + ..Default::default() + }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + let notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]), + point(vec![Run::new("Reflexia")]), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + }; + let mut session = Session::with_new(); + session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + harness.run_steps(2); + let id = egui::Id::new("notes_edit"); + let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { + let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); + egui::TextEdit::store_state(&h.ctx, id, st); + }; + let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap(); + let text = notes.text(); + harness.ctx.memory_mut(|m| m.request_focus(id)); + harness.run_steps(1); + // Type into the bold heading. + caret(&mut harness, chars(&text, "prehľad")); + harness.run_steps(1); + harness.event(egui::Event::Text(" hodiny".into())); + harness.run_steps(1); + // Enter after a bulleted point, and type the next one. + let text = text.replacen("prehľad", "prehľad hodiny", 1); + caret(&mut harness, chars(&text, "cyklus")); + harness.run_steps(1); + harness.key_press(egui::Key::Enter); + harness.event(egui::Event::Text("Nový bod".into())); + harness.run_steps(1); + // Leave the field: the notes are saved. + harness.ctx.memory_mut(|m| m.surrender_focus(id)); + harness.run_steps(2); + let check = |n: &TextBody| { + assert_eq!( + n.text(), + "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" + ); + assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); + assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(n.paragraphs[1].props, ParaProps::default()); + assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label"); + assert_eq!(n.paragraphs[2].runs[1].props.bold, None); + for i in [2, 3, 4] { + assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}"); + } + assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default())); + }; + check(&harness.state().session.active().unwrap().doc.slides[0].notes); + // Saved as .pptx and reopened. + let s = &mut harness.state_mut().session; + let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap(); + check(&s.active().unwrap().doc.slides[0].notes); + } } diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs new file mode 100644 index 00000000..20533735 --- /dev/null +++ b/crates/ui-egui/src/notes.rs @@ -0,0 +1,327 @@ +//! Speaker notes editing (the notes pane and Notes Page view). The notes are edited as text and +//! drawn with their formatting: bold, italic, underline, strikethrough and size from the runs, +//! indents (wrapped lines included) and bullets or numbers from the paragraphs. Each edit is +//! applied to a formatted draft, so typed text takes the formatting where it lands and the rest +//! keeps its own; the draft is saved with `slide.notes {body}` once the editor loses focus. + +use std::sync::Arc; + +use deckcraft_model::edit; +use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody}; +use egui::text::{CCursor, Galley, LayoutJob, TextFormat}; +use egui::{Align2, Color32, Stroke, Ui, vec2}; + +use crate::theme; + +/// Size and colour of a notes editor's text. +pub struct Look { + /// Pixels for 12 pt text, the notes' default size; indents scale with it. + pub size: f32, + /// `None` = the widget text colour. + pub color: Option, +} + +/// Notes being edited: the text in the editor and the formatted notes it stands for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Draft { + pub text: String, + pub body: TextBody, + /// Edited since it was saved. + pub edited: bool, +} + +impl Draft { + pub fn new(body: &TextBody) -> Self { + Draft { text: body.text(), body: body.clone(), edited: false } + } +} + +/// The draft as it reads with `text` typed in (one edit since the draft): `None` when that can't +/// be mapped onto it. +fn typed(body: &TextBody, text: &str) -> Option { + let mut b = body.clone(); + edit::replace_text(&mut b, text); + (b.text() == text).then_some(b) +} + +/// A length in points from the file as pixels, bounded. +fn px(pt: f64, k: f32) -> f32 { + if pt.is_finite() { pt.clamp(0.0, 400.0) as f32 * k } else { 0.0 } +} + +/// Where a paragraph's first line starts and its other lines start (points): a level without its +/// own margin is indented 36 pt per level, as in the default notes master. +fn indents(p: &Paragraph) -> (f64, f64) { + let margin = p.props.margin_left.unwrap_or(36.0 * f64::from(p.level.min(8))); + let first = margin + p.props.indent.unwrap_or(0.0); + (first, margin) +} + +/// Each paragraph's bullet or number, numbered as on a slide (per level, restarting after an +/// unnumbered paragraph at the same or a lower level). +fn bullets(body: &TextBody) -> Vec> { + let mut numbering = [0u32; 9]; + body.paragraphs + .iter() + .map(|p| { + let lvl = usize::from(p.level.min(8)); + if p.is_empty() { + return None; + } + let auto = matches!(p.props.bullet, Some(Bullet::AutoNum { .. })); + for (i, n) in numbering.iter_mut().enumerate().skip(lvl) { + if i > lvl || !auto { + *n = 0; + } + } + match p.props.bullet.as_ref()? { + Bullet::None => None, + Bullet::Char { char } => Some(deckcraft_text::map_symbol_bullet(char, p.props.bullet_font.as_deref())), + Bullet::AutoNum { scheme, start_at } => { + let n = numbering.get_mut(lvl)?; + *n = n.saturating_add(1); + Some(deckcraft_text::autonum(scheme, start_at.saturating_sub(1).saturating_add(*n))) + } + Bullet::Picture { .. } => Some("•".into()), + } + }) + .collect() +} + +fn format(props: &RunProps, size: f32, color: Color32) -> TextFormat { + let size = match props.size { + Some(pt) if pt.is_finite() && pt > 0.0 => (size * pt as f32 / 12.0).clamp(size * 0.75, size * 2.0), + _ => size, + }; + let font = if props.bold == Some(true) { theme::bold(size) } else { theme::font(size) }; + let mut f = TextFormat::simple(font, color); + f.italics = props.italic == Some(true); + if props.underline.as_deref().is_some_and(|u| u != "none") { + f.underline = Stroke::new(1.0, color); + } + if matches!(props.strike, Some(Strike::Single | Strike::Double)) { + f.strikethrough = Stroke::new(1.0, color); + } + f +} + +/// One paragraph to lay out: wrapped lines start at the margin (pixels), the first line's text at +/// `text`, clear of a bullet. +struct ParaLayout { + job: LayoutJob, + margin: f32, + /// x of the first line's text. + text: f32, +} + +fn para_layout(p: &Paragraph, bullet: Option<&str>, size: f32, color: Color32, wrap: f32) -> ParaLayout { + let k = size / 12.0; + let (first, margin) = indents(p); + let (first, margin) = (px(first, k), px(margin, k)); + let text = match bullet { + // The text starts at the margin, after the bullet if it's wider than the hanging indent. + Some(b) => margin.max(first + size * (0.4 + 0.6 * b.chars().count() as f32)), + None => first, + }; + let mut job = LayoutJob::default(); + job.wrap.max_width = (wrap - margin).max(size * 4.0); + let mut lead = (text - margin).max(0.0); + for r in &p.runs { + let t = r.content(); + if !t.is_empty() { + job.append(t, lead, format(&r.props, size, color)); + lead = 0.0; + } + } + if job.text.is_empty() { + job.append("", 0.0, format(&p.end_props, size, color)); + } + ParaLayout { job, margin, text } +} + +/// The whole notes as one job (the galley's text and formats). +fn job(body: &TextBody, size: f32, color: Color32, wrap: f32) -> LayoutJob { + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap; + for (i, (p, bullet)) in body.paragraphs.iter().zip(bullets(body)).enumerate() { + if i > 0 { + job.append("\n", 0.0, TextFormat::simple(theme::font(size), color)); + } + let pl = para_layout(p, bullet.as_deref(), size, color, wrap); + for s in pl.job.sections { + if let Some(t) = pl.job.text.get(s.byte_range.start.0..s.byte_range.end.0) { + job.append(t, s.leading_space, s.format); + } + } + } + job +} + +/// Lay the notes out paragraph by paragraph, each moved to its indent, so wrapped lines line up +/// under the text and not under the bullet. +fn galley(ui: &Ui, body: &TextBody, size: f32, color: Color32, wrap: f32) -> Arc { + let whole = job(body, size, color, wrap); + if body.paragraphs.is_empty() { + return ui.fonts_mut(|f| f.layout_job(whole)); + } + let parts: Vec> = ui.fonts_mut(|f| { + body.paragraphs + .iter() + .zip(bullets(body)) + .map(|(p, bullet)| { + let pl = para_layout(p, bullet.as_deref(), size, color, wrap); + let mut g = (*f.layout_job(pl.job)).clone(); + for (i, row) in g.rows.iter_mut().enumerate() { + // The first line already holds the gap after a bullet; a first line hanging + // left of the margin without one starts there. + row.pos.x += if i == 0 { pl.margin.min(pl.text) } else { pl.margin }; + } + Arc::new(g) + }) + .collect() + }); + Arc::new(Galley::concat(Arc::new(whole), &parts, ui.ctx().pixels_per_point())) +} + +/// A multiline editor for the draft that draws its formatting; `configure` sets the id, hint, +/// size and frame. Edits update the draft's text and formatted body. +pub fn editor( + ui: &mut Ui, + draft: &mut Draft, + look: &Look, + configure: impl for<'a> FnOnce(egui::TextEdit<'a>) -> egui::TextEdit<'a>, +) -> egui::Response { + let color = look.color.unwrap_or_else(|| ui.visuals().override_text_color.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color())); + let size = look.size; + let before = draft.body.clone(); + let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| match typed(&before, text.as_str()) { + Some(b) => galley(ui, &b, size, color, wrap), + None => ui.fonts_mut(|f| f.layout_job(LayoutJob::simple(text.as_str().to_string(), theme::font(size), color, wrap))), + }; + let out = configure(egui::TextEdit::multiline(&mut draft.text)).layouter(&mut layouter).show(ui); + if draft.body.text() != draft.text { + edit::replace_text(&mut draft.body, &draft.text); + draft.edited = true; + } + // Bullets and numbers sit at the start of each paragraph's first line. + if draft.body.text() == draft.text { + let painter = ui.painter().with_clip_rect(out.text_clip_rect); + let k = size / 12.0; + let mut at = 0usize; + for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) { + if let Some(b) = bullet { + let row = out.galley.pos_from_cursor(CCursor::new(at)); + let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y); + let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8); + painter.text(pos, Align2::LEFT_CENTER, b, font, color); + } + at += p.char_len() + 1; + } + } + out.response.response +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_model::text::{ParaProps, Run}; + + use super::*; + + /// Notes as PowerPoint users write them: a bold heading, a plain line, points with a + /// Wingdings square bullet and a hanging indent (one long enough to wrap), numbered points, + /// a point with an explicit buNone, closing plain lines; Slovak text throughout. + fn notes() -> TextBody { + let props = |bullet: Bullet| ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(bullet), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }; + let point = |runs: Vec, bullet: Bullet| Paragraph { level: 1, props: props(bullet), runs, ..Default::default() }; + let square = || Bullet::Char { char: "§".into() }; + let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold), Run::new("vysvetliť cyklus")], square()), + point(vec![Run::new("Dlhý bod, ktorý sa zalomí na viac riadkov, lebo je naozaj veľmi dlhý a pokračuje ďalej.")], square()), + point(vec![Run::new("Jeden")], num()), + point(vec![Run::new("Dva")], num()), + point(vec![Run::new("Bez odrážky")], Bullet::None), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + } + } + + #[test] + fn bullets_follow_the_paragraphs() { + let b = bullets(¬es()); + assert_eq!(b, vec![None, None, Some("▪".into()), Some("▪".into()), Some("1.".into()), Some("2.".into()), None, None]); + } + + #[test] + fn notes_show_bold_runs_indents_and_wrap_under_the_text() { + let body = notes(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut laid = None; + let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0))); + out.textures_delta.clear(); + let g = laid.unwrap(); + assert_eq!(g.job.text, body.text(), "the galley reads the notes"); + assert_eq!(g.rows.iter().map(|r| r.char_count_including_newline().0).sum::(), body.text().chars().count()); + // Bold where the runs are bold, regular elsewhere. + let font_of = |s: &str| { + let b = g.job.text.find(s).unwrap(); + g.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone() + }; + assert_eq!(font_of("Metodický"), theme::bold(12.0)); + assert_eq!(font_of("Cieľ"), theme::bold(12.0)); + assert_eq!(font_of("vysvetliť"), theme::font(12.0)); + assert_eq!(font_of("Pomôcky"), theme::font(12.0)); + // Rows: x where each paragraph's text starts. + let row_x = |s: &str| { + let at = body.text().find(s).map(|b| body.text()[..b].chars().count()).unwrap(); + g.pos_from_cursor(CCursor::new(at)).min.x + }; + assert!(row_x("Metodický") < 1.0 && row_x("Žiaci") < 1.0 && row_x("Pomôcky") < 1.0, "plain paragraphs aren't indented"); + assert!((row_x("Cieľ") - 22.5).abs() < 1.0, "bulleted text at the 22.5 pt margin: {}", row_x("Cieľ")); + assert!((row_x("Bez odrážky") - 0.0).abs() < 1.0, "buNone: no bullet, the first line hangs to 0"); + // The long point wraps, and its wrapped lines start at the margin, not under the bullet. + let long = body.text().find("Dlhý").map(|b| body.text()[..b].chars().count()).unwrap(); + let first_row = g.pos_from_cursor(CCursor::new(long)); + let wrapped: Vec<_> = + g.rows.iter().filter(|r| r.pos.y > first_row.min.y + 1.0 && r.pos.y < g.pos_from_cursor(CCursor::new(long + 80)).max.y).collect(); + assert!(!wrapped.is_empty(), "the long point wraps"); + assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text"); + } + + #[test] + fn typing_keeps_the_formatting_around_it() { + let mut d = Draft::new(¬es()); + // Typing into the bold heading continues it in bold. + let t = d.text.replacen("prehľad", "prehľad hodiny", 1); + d.body = typed(&d.body, &t).unwrap(); + assert_eq!(d.body.paragraphs[0].runs.len(), 1); + assert_eq!(d.body.paragraphs[0].runs[0].props.bold, Some(true)); + // Enter at the end of a bulleted point starts another one with the same bullet and indent. + let t = d.body.text().replacen("cyklus", "cyklus\nnový bod", 1); + d.body = typed(&d.body, &t).unwrap(); + let new = &d.body.paragraphs[3]; + assert_eq!(new.text(), "nový bod"); + assert_eq!((new.level, &new.props), (1, ¬es().paragraphs[2].props)); + assert_eq!(d.body.paragraphs[2].runs[0].props.bold, Some(true), "the bold label stays bold"); + // Hostile sizes and indents stay bounded. + let mut wild = notes(); + wild.paragraphs[2].props.margin_left = Some(f64::NAN); + wild.paragraphs[0].runs[0].props.size = Some(f64::INFINITY); + let j = job(&wild, 12.0, Color32::BLACK, 300.0); + assert!(j.sections.iter().all(|s| s.leading_space.is_finite() && s.format.font_id.size <= 24.0)); + } +} diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd85..c67b9e6f 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -132,22 +132,27 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside); let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); - let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); let id = egui::Id::new(("notespage", idx)); - let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone())); - let r = child.add( - egui::TextEdit::multiline(&mut buf) - .hint_text("Click to add text") - .desired_width(notes_rect.width()) - .desired_rows(12) - .frame(egui::Frame::NONE) - .text_color(Color32::from_gray(30)) - .font(theme::font(14.0)), - ); - if r.lost_focus() && buf != current { - let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + let edit_id = egui::Id::new(("notespage-edit", idx)); + let focused = child.ctx().memory(|m| m.has_focus(edit_id)); + let mut draft: Option = child.data_mut(|d| d.get_temp(id)); + // Save an edited draft once the field has lost focus, before anything reloads it. + if !focused && let Some(d) = draft.as_mut().filter(|d| d.edited) { + d.edited = false; + let _ = app.run("slide.notes", json!({"body": d.body, "index": idx})); } - child.data_mut(|d| d.insert_temp(id, buf)); + let notes = app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default(); + let mut draft = draft.filter(|d| focused || d.body == notes).unwrap_or_else(|| crate::notes::Draft::new(¬es)); + let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) }; + let width = notes_rect.width(); + let r = crate::notes::editor(&mut child, &mut draft, &look, |e| { + e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE) + }); + if r.lost_focus() && draft.edited { + draft.edited = false; + let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx})); + } + child.data_mut(|d| d.insert_temp(id, draft)); let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp))); if nav.0 { let _ = app.run("slide.next", json!({})); diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index a6f4eb11..fa272642 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -115,9 +115,18 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { if st.selection.target != deckcraft_engine::Target::Slides { return; } - let idx = st.selection.slide; - let uid = st.uid; - let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); + let (idx, uid) = (st.selection.slide, st.uid); + // Save an edited draft once the field has lost focus or another slide is shown (before + // anything reloads it), to the slide it was written on. + let id = egui::Id::new("notes_edit"); + let focused = ui.ctx().memory(|m| m.has_focus(id)); + if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) { + app.notes_buf.2.edited = false; + if app.notes_buf.0 == uid { + let _ = app.run("slide.notes", json!({"body": app.notes_buf.2.body, "index": app.notes_buf.1})); + } + } + let notes = app.session.active().and_then(|st| st.current_slide()).map(|s| s.notes.clone()).unwrap_or_default(); egui::Panel::bottom("notes") .resizable(true) .default_size(app.ui.notes_height) @@ -128,28 +137,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { let rect = ui.max_rect(); ui.painter() .line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator)); - // Keep a local buffer per slide; commit when it changes and the field loses focus. - if app.notes_buf.0 != uid || app.notes_buf.1 != idx { - app.notes_buf = (uid, idx, current.clone()); + // Keep a local draft per slide, following the notes while the field isn't focused. + if app.notes_buf.0 != uid || app.notes_buf.1 != idx || (!focused && app.notes_buf.2.body != notes) { + app.notes_buf = (uid, idx, crate::notes::Draft::new(¬es)); } - let id = egui::Id::new("notes_edit"); - let focused = ui.ctx().memory(|m| m.has_focus(id)); - if !focused && app.notes_buf.2 != current { - app.notes_buf.2 = current.clone(); - } - let mut buf = app.notes_buf.2.clone(); - let r = ui.add( - egui::TextEdit::multiline(&mut buf) - .id(id) - .hint_text("Click to add notes") - .frame(egui::Frame::NONE) - .desired_width(f32::INFINITY) - .desired_rows(2) - .font(theme::font(13.0)), - ); - app.notes_buf.2 = buf.clone(); - if r.lost_focus() && buf != current { - let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + let mut draft = app.notes_buf.2.clone(); + let look = crate::notes::Look { size: 13.0, color: None }; + let r = crate::notes::editor(ui, &mut draft, &look, |e| { + e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2) + }); + if r.lost_focus() && draft.edited { + draft.edited = false; + let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx})); } + app.notes_buf.2 = draft; }); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1d87c9ac..cffee35c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (formatted speaker notes) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -61,7 +61,7 @@ the round trip even though DeckCraft doesn't model it. | Presentation, slide size, sections (p14), custom shows | yes | yes | | | Slide masters, layouts, placeholders and inheritance | yes | yes | | | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | -| Notes slides and notes master | yes | yes | No notes-master editing view | +| Notes slides and notes master | yes | yes | Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown; no notes-master editing view | | Handout master | no | no | | | Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | @@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | From c871c5f634fdedfa6e055c1a6d832d1e808d1913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=BA=C5=A1=20Laco?= Date: Sun, 11 Oct 2026 21:44:50 +0200 Subject: [PATCH 02/69] =?UTF-8?q?Export:=20animated=20GIF=20of=20the=20dec?= =?UTF-8?q?k=20(Export=E2=80=A6=20dialog,=20CLI,=20MCP)=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to turn a presentation into an animated GIF. file.export now writes one: format `gif` (or a .gif path), one frame per slide drawn by the slide renderer and shown for secondsPerSlide, encoded with the `image` crate's GIF encoder (already a dependency, no new crates) with a 256-colour palette per frame: exact when a slide has 256 colours or fewer, NeuQuant otherwise. Slides are composited onto white. Options: width (small 640, medium 960 (default), large 1280, extraLarge 1920, or 16-3840 px; the height follows the slide's aspect ratio, no side over 3840), secondsPerSlide (0.1-600, default 5, written in GIF hundredths), loop (forever by default, or once: no NETSCAPE2.0 block), slides (a list of indices, in play order) and includeHidden (hidden slides are skipped by default). Out-of-range or wrongly typed values, NaN and infinities are BadParams errors, never panics; at most 500 frames and 200 million pixels in total, checked before anything is rendered. - Engine: crates/engine/src/gif_export.rs (options, frame list, size, encoder); cmd::file::gif_bytes, save_bytes("gif"), format_for_path(".gif"). - UI: the Export… dialog (Command palette; the app has no File menu yet) gets "Animated GIF (.gif)" with Size, Seconds per slide, From/To slide, Loop forever and Include hidden slides; an invalid range or value keeps the dialog open with a message. The web build downloads the bytes (image/gif). - CLI: convert IN OUT.gif, run --export OUT.gif --export-params JSON; usage lists the format and options. - MCP: the export tool lists gif and width/secondsPerSlide/loop (slides and includeHidden now apply to PDF and GIF). - Docs: file-format parity GIF row, gaps (video export remains), parity checklist row M -> P (P2 breadth 36% -> 37%, overall 79%), control protocol, MCP, README, ROADMAP progress log. Transitions and build animations are not animated in the GIF (tracked with video export). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 6 +- apps/deckcraft-cli/src/main.rs | 67 ++++- apps/deckcraft-web/src/web.rs | 1 + crates/engine/src/cmd/file.rs | 24 +- crates/engine/src/gif_export.rs | 421 ++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/mcp/src/tests.rs | 26 ++ crates/mcp/src/tools.rs | 19 +- crates/ui-egui/src/dialogs.rs | 144 ++++++++++- docs/control-protocol.md | 2 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 13 +- docs/mcp.md | 4 +- docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 5 +- 16 files changed, 714 insertions(+), 34 deletions(-) create mode 100644 crates/engine/src/gif_export.rs diff --git a/README.md b/README.md index f25aef2d..d6bd749d 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Today it can: - **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations, by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback. - **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts - with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover. + with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover. - **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an MCP server for AI agents. diff --git a/ROADMAP.md b/ROADMAP.md index 801ddd84..7f5de626 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export in the progress log, #171; P2 breadth 36% → 37%; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -14,7 +14,7 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av | | Value | Kind | |---|---|---| -| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | +| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 37%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | | Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands | | **Ready for real work** | **~55%** | estimated: weighted dimensions below | | Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) | @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | @@ -139,6 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | | 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% | | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha | diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index c450393f..0b99dbad 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -3,8 +3,8 @@ //! ```text //! deckcraft-cli info FILE # slides, titles, layouts (JSON) //! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR -//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide) -//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] +//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .pdf, .png (first slide), .gif (animated) +//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT [--export-params JSON]] [--print] //! deckcraft-cli commands [FILTER] # list commands (JSON) //! deckcraft-cli describe ID # one command //! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app @@ -35,12 +35,17 @@ macro_rules! outln { const USAGE: &str = "usage: deckcraft-cli info FILE deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT deckcraft-cli convert IN OUT - deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] + deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT [--export-params JSON]] [--print] deckcraft-cli commands [FILTER] deckcraft-cli describe COMMAND deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] deckcraft-cli mcp [--connect PORT] [--sample] - deckcraft-cli links | --version"; + deckcraft-cli links | --version + +Export formats follow OUT's extension: .png .jpg .pdf .pptx .txt .deckcraft .gif (animated, one +frame per slide). --export-params passes file.export options, e.g. for a GIF +'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' +(width: px 16-3840 or small|medium|large|extraLarge; see `deckcraft-cli describe file.export`)."; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -168,6 +173,8 @@ fn convert(args: &[String]) -> Result<(), String> { let fmt = deckcraft_engine::cmd::file::format_for_path(out); let r = if matches!(fmt, "png" | "jpeg") { s.execute("file.export", &json!({"path": out, "slide": 0})) + } else if fmt == "gif" { + s.execute("file.export", &json!({"path": out})) } else { s.execute("file.saveAs", &json!({"path": out})) }; @@ -182,6 +189,7 @@ fn run(args: &[String]) -> Result<(), String> { let mut cmds = vec![]; let mut save = None; let mut export = None; + let mut export_params = json!({}); let mut print = false; let mut it = args.iter(); while let Some(a) = it.next() { @@ -191,6 +199,13 @@ fn run(args: &[String]) -> Result<(), String> { "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?), "--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?), "--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?), + "--export-params" => { + let j = it.next().ok_or("--export-params needs a JSON object")?; + export_params = serde_json::from_str::(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?; + if !export_params.is_object() { + return Err("--export-params must be a JSON object".into()); + } + } "--print" => print = true, other => return Err(format!("unknown option `{other}`")), } @@ -211,7 +226,10 @@ fn run(args: &[String]) -> Result<(), String> { s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?; } if let Some(p) = export { - s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?; + export_params["path"] = json!(p); + s.execute("file.export", &export_params).map_err(|e| e.to_string())?; + } else if export_params != json!({}) { + return Err("--export-params needs --export OUT".into()); } Ok(()) } @@ -278,3 +296,42 @@ fn mcp(args: &[String]) -> Result<(), String> { let stdin = std::io::stdin(); server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + fn args(a: &[&str]) -> Vec { + a.iter().map(|s| s.to_string()).collect() + } + + /// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs. + #[test] + fn exports_an_animated_gif() { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let gif = dir.join("sample.gif").to_string_lossy().to_string(); + let deck = dir.join("sample.deckcraft").to_string_lossy().to_string(); + let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#; + run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap(); + let bytes = std::fs::read(&gif).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide"); + assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block"); + + let gif2 = dir.join("converted.gif").to_string_lossy().to_string(); + convert(&args(&[&deck, &gif2])).unwrap(); + let bytes = std::fs::read(&gif2).unwrap(); + assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default"); + + // Bad options are errors, not panics. + let bad = dir.join("bad.gif").to_string_lossy().to_string(); + assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err()); + assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err()); + assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok()); + assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f0199a55..ccb62bff 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -168,6 +168,7 @@ fn mime_for(name: &str) -> &'static str { Some("png") => "image/png", Some("jpg" | "jpeg") => "image/jpeg", Some("pdf") => "application/pdf", + Some("gif") => "image/gif", Some("txt") => "text/plain", Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation", Some("deckcraft") => "application/zip", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 55aac8bd..44b637e2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf|gif, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?; gif: width?: px 16-3840 | small|medium|large|extraLarge, secondsPerSlide?: 0.1-600 (5), loop?: bool (true: forever), slides?: [index], includeHidden?} → {path, bytes} (gif: + frames, width, height, secondsPerSlide)", has_doc, export), cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close), cmd!( @@ -104,12 +104,13 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result { Ok(json!({"document": i})) } -/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`). +/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`, `pdf`, `gif`). pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { match format { "pptx" | "potx" | "ppsx" => deckcraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())), "outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()), "pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())), + "gif" => gif_bytes(doc, &Value::Null), _ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())), } } @@ -136,6 +137,11 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result> { deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string())) } +/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]). +pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result> { + Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes) +} + pub fn format_for_path(path: &str) -> &'static str { let l = path.to_ascii_lowercase(); if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") { @@ -146,6 +152,8 @@ pub fn format_for_path(path: &str) -> &'static str { "jpeg" } else if l.ends_with(".pdf") { "pdf" + } else if l.ends_with(".gif") { + "gif" } else if l.ends_with(".txt") || l.ends_with(".md") { "outline" } else { @@ -264,6 +272,18 @@ fn export(s: &mut Session, p: &Value) -> Result { write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } + "gif" => { + let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(p)?)?; + write_file(&path, &out.bytes)?; + Ok(json!({ + "path": path, + "bytes": out.bytes.len(), + "frames": out.frames, + "width": out.width, + "height": out.height, + "secondsPerSlide": f64::from(out.delay_cs) / 100.0, + })) + } other => { let bytes = save_bytes(&st.doc, other)?; write_file(&path, &bytes)?; diff --git a/crates/engine/src/gif_export.rs b/crates/engine/src/gif_export.rs new file mode 100644 index 00000000..da4f044f --- /dev/null +++ b/crates/engine/src/gif_export.rs @@ -0,0 +1,421 @@ +//! Animated GIF of the deck (the Export… dialog; `file.export` with format `gif`). +//! +//! One frame per slide, drawn by the slide renderer and shown for `secondsPerSlide`. Every frame +//! gets its own 256-colour palette (exact when the slide has 256 colours or fewer, NeuQuant +//! otherwise), so a photo on one slide doesn't steal colours from the next. Transitions and +//! build animations are not animated: a slide appears as it looks when its builds have finished. +//! +//! All parameters are hostile: sizes, delays and slide lists are checked against fixed bounds, and +//! the total number of pixels encoded is capped, so a request can't exhaust memory or run for +//! hours. + +use deckcraft_model::Presentation; +use serde_json::Value; + +use crate::cmd::bad; +use crate::{EngineError, Result}; + +const CMD: &str = "file.export"; + +/// Size presets, by frame width in pixels (16:9 slides: 640×360, 960×540, 1280×720, 1920×1080). +pub const SIZE_PRESETS: [(&str, u32); 4] = [("small", 640), ("medium", 960), ("large", 1280), ("extraLarge", 1920)]; +/// Frame width when none is given (the `medium` preset). +pub const DEFAULT_WIDTH: u32 = 960; +pub const MIN_WIDTH: u32 = 16; +pub const MAX_WIDTH: u32 = 3840; +/// Longest frame side: a very tall slide is scaled down to fit. +pub const MAX_SIDE: u32 = 3840; +pub const DEFAULT_SECONDS: f64 = 5.0; +/// GIF delays are in hundredths of a second; viewers treat delays under 0.02 s as 0.1 s anyway. +pub const MIN_SECONDS: f64 = 0.1; +pub const MAX_SECONDS: f64 = 600.0; +pub const MAX_FRAMES: usize = 500; +/// Cap on frames × width × height (≈ 96 Full HD frames or 480 frames at 960×540). +pub const MAX_TOTAL_PIXELS: u64 = 200_000_000; +/// NeuQuant sampling: 1 is slowest and best, 30 fastest; 10 is the usual balance. +const QUANTIZER_SPEED: i32 = 10; + +/// What to export. +#[derive(Clone, Debug, PartialEq)] +pub struct GifOptions { + /// Frame width in pixels; the height follows the slide's aspect ratio. + pub width: u32, + pub seconds_per_slide: f64, + /// Loop forever (`true`) or play once. + pub loop_forever: bool, + /// Slide indices in play order (default: all). + pub slides: Option>, + /// Hidden slides are left out unless this is set. + pub include_hidden: bool, +} + +impl Default for GifOptions { + fn default() -> Self { + GifOptions { width: DEFAULT_WIDTH, seconds_per_slide: DEFAULT_SECONDS, loop_forever: true, slides: None, include_hidden: false } + } +} + +/// An encoded GIF and what is in it. +#[derive(Clone, Debug)] +pub struct GifOutput { + pub bytes: Vec, + pub frames: usize, + pub width: u32, + pub height: u32, + /// Per-frame delay in hundredths of a second, as written. + pub delay_cs: u16, +} + +fn width_value(v: &Value) -> Result { + let err = + || bad(CMD, format!("`width` must be a whole number of pixels from {MIN_WIDTH} to {MAX_WIDTH}, or one of small, medium, large, extraLarge")); + match v { + Value::String(s) => SIZE_PRESETS.iter().find(|(k, _)| k.eq_ignore_ascii_case(s.trim())).map(|(_, w)| *w).ok_or_else(err), + Value::Number(n) => n.as_u64().and_then(|w| u32::try_from(w).ok()).filter(|w| (MIN_WIDTH..=MAX_WIDTH).contains(w)).ok_or_else(err), + _ => Err(err()), + } +} + +/// Check a per-slide duration (NaN, infinities and out-of-range values are errors). +pub fn check_seconds(s: f64) -> Result { + if (MIN_SECONDS..=MAX_SECONDS).contains(&s) { + Ok(s) + } else { + Err(bad(CMD, format!("`secondsPerSlide` must be from {MIN_SECONDS} to {MAX_SECONDS}"))) + } +} + +/// Present and not `null`. +fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> { + p.get(key).filter(|v| !v.is_null()) +} + +impl GifOptions { + /// Read `{width?: px | small|medium|large|extraLarge, secondsPerSlide?, loop?: bool | + /// forever|once, slides?: [index], includeHidden?}`. Absent or `null` values take their + /// defaults; anything else that is out of range or of the wrong type is an error. + pub fn from_params(p: &Value) -> Result { + let mut o = GifOptions::default(); + if let Some(v) = given(p, "width") { + o.width = width_value(v)?; + } + if let Some(v) = given(p, "secondsPerSlide") { + let s = v.as_f64().ok_or_else(|| bad(CMD, "`secondsPerSlide` must be a number"))?; + o.seconds_per_slide = check_seconds(s)?; + } + if let Some(v) = given(p, "loop") { + o.loop_forever = match v { + Value::Bool(b) => *b, + Value::String(s) if s.eq_ignore_ascii_case("forever") => true, + Value::String(s) if s.eq_ignore_ascii_case("once") => false, + _ => return Err(bad(CMD, "`loop` must be true (forever), false (once), \"forever\" or \"once\"")), + }; + } + if let Some(v) = given(p, "slides") { + let a = v.as_array().ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices"))?; + if a.is_empty() { + return Err(bad(CMD, "`slides` is empty")); + } + if a.len() > MAX_FRAMES { + return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides"))); + } + let list = a + .iter() + .map(|x| x.as_u64().and_then(|i| usize::try_from(i).ok())) + .collect::>>() + .ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices (0, 1, 2…)"))?; + o.slides = Some(list); + } + if let Some(v) = given(p, "includeHidden") { + o.include_hidden = v.as_bool().ok_or_else(|| bad(CMD, "`includeHidden` must be true or false"))?; + } + Ok(o) + } + + /// The slides that become frames, in order: the chosen ones (or all), without hidden slides + /// unless `include_hidden`. + pub fn frames(&self, doc: &Presentation) -> Result> { + let n = doc.slides.len(); + let chosen: Vec = match &self.slides { + Some(list) => { + if let Some(i) = list.iter().find(|i| **i >= n) { + return Err(bad(CMD, format!("no slide {i} (the presentation has {n})"))); + } + list.clone() + } + None => (0..n).collect(), + }; + let frames: Vec = chosen.into_iter().filter(|i| self.include_hidden || doc.slides.get(*i).is_some_and(|s| !s.hidden)).collect(); + if frames.is_empty() { + return Err(bad(CMD, "no slides to export (they are all hidden, or the presentation is empty)")); + } + if frames.len() > MAX_FRAMES { + return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides; choose a range"))); + } + Ok(frames) + } + + /// Delay per frame in hundredths of a second. + pub fn delay_cs(&self) -> Result { + let s = check_seconds(self.seconds_per_slide)?; + // In range by `check_seconds`: 10..=60_000. + Ok((s * 100.0).round().clamp(1.0, f64::from(u16::MAX)) as u16) + } +} + +/// Frame size for a slide of `doc`'s size at `width` px: the height follows the aspect ratio and +/// no side exceeds [`MAX_SIDE`]. +pub fn frame_size(doc: &Presentation, width: u32) -> (u32, u32) { + let (sw, sh) = (doc.slide_size.width, doc.slide_size.height); + let aspect = if sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0 { sh / sw } else { 9.0 / 16.0 }; + let w = f64::from(width.clamp(MIN_WIDTH, MAX_WIDTH)); + let mut h = w * aspect; + let mut w2 = w; + if h > f64::from(MAX_SIDE) { + w2 = w * f64::from(MAX_SIDE) / h; + h = f64::from(MAX_SIDE); + } + let side = |v: f64| if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIDE)) as u32 } else { 1 }; + (side(w2), side(h)) +} + +/// Straight RGBA of a premultiplied render composited onto white (GIF has no partial alpha, and +/// fully transparent pixels would otherwise become see-through). +fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec { + let len = (w as usize).saturating_mul(h as usize).saturating_mul(4); + if img.width != w || img.height != h || img.pixels.len() != len { + log::error!("GIF export: a slide rendered at {}×{} instead of {w}×{h}; writing a blank frame", img.width, img.height); + return vec![255; len]; + } + let mut px = img.pixels.clone(); + for p in px.as_chunks_mut::<4>().0 { + let back = 255 - p[3]; + p[0] = p[0].saturating_add(back); + p[1] = p[1].saturating_add(back); + p[2] = p[2].saturating_add(back); + p[3] = 255; + } + px +} + +/// Render and encode the GIF. +pub fn encode(doc: &Presentation, opts: &GifOptions) -> Result { + use image::codecs::gif::{GifEncoder, Repeat}; + + let frames = opts.frames(doc)?; + let delay_cs = opts.delay_cs()?; + let (w, h) = frame_size(doc, opts.width); + let total = (frames.len() as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)); + if total > MAX_TOTAL_PIXELS { + return Err(bad(CMD, format!("{} slides at {w}×{h} is too large for a GIF; choose a smaller size or fewer slides", frames.len()))); + } + let scale = f64::from(w) / doc.slide_size.width; + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let enc_err = |e: image::ImageError| EngineError::Other(format!("GIF encoding failed: {e}")); + let mut bytes = Vec::new(); + { + let mut enc = GifEncoder::new_with_speed(&mut bytes, QUANTIZER_SPEED); + // No repeat block at all means "play once"; a repeat count of 0 means "forever". + if opts.loop_forever { + enc.set_repeat(Repeat::Infinite).map_err(enc_err)?; + } + for i in &frames { + let img = deckcraft_render::render_slide(doc, *i, &deckcraft_render::RenderOpts { scale, size: Some((w, h)), ..Default::default() }); + let buffer = image::RgbaImage::from_raw(w, h, on_white(&img, w, h)) + .ok_or_else(|| EngineError::Other(format!("GIF export: slide {i} has no pixels")))?; + let delay = image::Delay::from_numer_denom_ms(u32::from(delay_cs) * 10, 1); + enc.encode_frame(image::Frame::from_parts(buffer, 0, 0, delay)).map_err(enc_err)?; + } + } + Ok(GifOutput { bytes, frames: frames.len(), width: w, height: h, delay_cs }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use image::AnimationDecoder; + use serde_json::json; + + use super::*; + use crate::Session; + + const COLOURS: [(&str, [u8; 3]); 4] = + [("#E02020", [0xE0, 0x20, 0x20]), ("#20A040", [0x20, 0xA0, 0x40]), ("#2040E0", [0x20, 0x40, 0xE0]), ("#F0C000", [0xF0, 0xC0, 0x00])]; + + /// Four blank slides with solid backgrounds (red, green, blue, yellow); slide 2 (blue) hidden. + fn deck() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"blank": true})).unwrap(); + while s.doc().unwrap().doc.slides.len() < COLOURS.len() { + s.execute("slide.new", &json!({})).unwrap(); + } + for (i, (hex, _)) in COLOURS.iter().enumerate() { + s.execute("design.background", &json!({"color": hex, "index": i})).unwrap(); + } + s.execute("slide.hide", &json!({"index": 2, "hidden": true})).unwrap(); + s + } + + fn temp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + fn decode(bytes: &[u8]) -> (image::metadata::LoopCount, Vec) { + let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap(); + let count = dec.loop_count(); + (count, dec.into_frames().collect_frames().unwrap()) + } + + /// The NETSCAPE2.0 application block's loop count, if the GIF has one. + fn netscape_loops(bytes: &[u8]) -> Option { + let tag = b"NETSCAPE2.0"; + let at = bytes.windows(tag.len()).position(|w| w == tag)?; + // NETSCAPE2.0, sub-block size 3, id 1, loop count (little endian). + let b = bytes.get(at + tag.len()..at + tag.len() + 4)?; + Some(u16::from_le_bytes([b[2], b[3]])) + } + + fn close(a: [u8; 4], b: [u8; 3]) -> bool { + (0..3).all(|k| (i32::from(a[k]) - i32::from(b[k])).abs() <= 12) && a[3] == 255 + } + + #[test] + fn exports_one_frame_per_visible_slide() { + let mut s = deck(); + let path = temp("deck.gif"); + let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "width": 320, "secondsPerSlide": 1.5})).unwrap(); + assert_eq!(r["frames"], 3, "{r}"); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(netscape_loops(&bytes), Some(0), "loops forever by default"); + let (count, frames) = decode(&bytes); + assert!(matches!(count, image::metadata::LoopCount::Infinite)); + assert_eq!(frames.len(), 3, "the hidden slide is skipped"); + // Slides are 16:9: 320×180. + let expected = [COLOURS[0].1, COLOURS[1].1, COLOURS[3].1]; + for (f, want) in frames.iter().zip(expected) { + assert_eq!((f.buffer().width(), f.buffer().height()), (320, 180)); + assert_eq!(f.delay().numer_denom_ms(), (1500, 1)); + let px = f.buffer().get_pixel(160, 90).0; + assert!(close(px, want), "frame colour {px:?}, expected {want:?}"); + } + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(320), Some(180))); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn presets_ranges_hidden_slides_and_play_once() { + let s = deck(); + let doc = &s.doc().unwrap().doc; + let o = GifOptions::from_params(&json!({"width": "small", "loop": false, "slides": [3, 2, 1], "includeHidden": true})).unwrap(); + let out = encode(doc, &o).unwrap(); + assert_eq!((out.frames, out.width, out.height, out.delay_cs), (3, 640, 360, 500)); + assert_eq!(netscape_loops(&out.bytes), None, "play once: no repeat block"); + let (_, frames) = decode(&out.bytes); + let want = [COLOURS[3].1, COLOURS[2].1, COLOURS[1].1]; + for (f, w) in frames.iter().zip(want) { + assert!(close(f.buffer().get_pixel(320, 180).0, w)); + assert_eq!(f.delay().numer_denom_ms(), (5000, 1), "5 seconds by default"); + } + // A range that holds only the hidden slide has nothing to export. + let o = GifOptions::from_params(&json!({"slides": [2]})).unwrap(); + assert!(encode(doc, &o).is_err()); + for (preset, w) in SIZE_PRESETS { + assert_eq!(GifOptions::from_params(&json!({"width": preset})).unwrap().width, w); + } + assert_eq!( + GifOptions::from_params(&json!({"width": "Large", "loop": "once"})).unwrap(), + GifOptions { width: 1280, loop_forever: false, ..Default::default() } + ); + assert_eq!(GifOptions::from_params(&json!({"width": null, "secondsPerSlide": null})).unwrap(), GifOptions::default()); + } + + #[test] + fn hostile_params_are_errors_not_panics() { + for p in [ + json!({"width": 0}), + json!({"width": 15}), + json!({"width": 3841}), + json!({"width": -5}), + json!({"width": 1e308}), + json!({"width": 640.5}), + json!({"width": "huge"}), + json!({"width": [640]}), + json!({"secondsPerSlide": 0}), + json!({"secondsPerSlide": 0.05}), + json!({"secondsPerSlide": -1}), + json!({"secondsPerSlide": 601}), + json!({"secondsPerSlide": 1e308}), + json!({"secondsPerSlide": "5"}), + json!({"loop": 1}), + json!({"loop": "twice"}), + json!({"slides": []}), + json!({"slides": [-1]}), + json!({"slides": [0.5]}), + json!({"slides": "1-3"}), + json!({"slides": vec![0; MAX_FRAMES + 1]}), + json!({"includeHidden": "yes"}), + ] { + assert!(GifOptions::from_params(&p).is_err(), "{p}"); + } + for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MIN_POSITIVE, 1e-300] { + assert!(check_seconds(v).is_err(), "{v}"); + let o = GifOptions { seconds_per_slide: v, ..Default::default() }; + assert!(o.delay_cs().is_err(), "{v}"); + } + assert_eq!(GifOptions { seconds_per_slide: 600.0, ..Default::default() }.delay_cs().unwrap(), 60_000); + assert_eq!(GifOptions { seconds_per_slide: 0.1, ..Default::default() }.delay_cs().unwrap(), 10); + + // Through the command too: errors, the presentation untouched, no file written. + let mut s = deck(); + let path = temp("hostile.gif"); + for p in [ + json!({"width": 99999}), + json!({"secondsPerSlide": -3}), + json!({"slides": [0, 99]}), + json!({"slides": [18446744073709551615u64]}), + json!({"slides": [2]}), + ] { + let mut p = p; + p["path"] = json!(path.to_string_lossy()); + assert!(s.execute("file.export", &p).is_err(), "{p}"); + } + assert!(!path.exists()); + assert_eq!(s.doc().unwrap().doc.slides.len(), 4); + } + + #[test] + fn sizes_are_bounded() { + let mut s = deck(); + // A very tall slide is scaled down so no side exceeds MAX_SIDE. + s.execute("design.slideSize", &json!({"w": 100.0, "h": 4000.0, "scale": "none"})).unwrap(); + let doc = &s.doc().unwrap().doc; + let (w, h) = frame_size(doc, MAX_WIDTH); + assert!(w >= 1 && h <= MAX_SIDE && w <= MAX_WIDTH, "{w}×{h}"); + // The total pixel budget stops huge requests before anything is rendered. + let mut big = Presentation::clone(doc); + big.slide_size = deckcraft_geom::Size::new(960.0, 540.0); + let slide = big.slides[0].clone(); + big.slides = vec![slide; 200]; + let o = GifOptions { width: MAX_WIDTH, ..Default::default() }; + let e = encode(&big, &o).unwrap_err().to_string(); + assert!(e.contains("too large"), "{e}"); + // A degenerate slide size falls back to 16:9 rather than dividing by zero. + let mut odd = Presentation::clone(doc); + odd.slide_size = deckcraft_geom::Size::new(0.0, f64::NAN); + assert_eq!(frame_size(&odd, 640), (640, 360)); + } + + #[test] + fn gif_is_also_a_save_format_and_inferred_from_the_path() { + let s = deck(); + let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "gif").unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(crate::cmd::file::format_for_path("Talk.GIF"), "gif"); + let bytes = crate::cmd::file::gif_bytes(&s.doc().unwrap().doc, &json!({"width": 64, "slides": [0]})).unwrap(); + assert_eq!(decode(&bytes).1.len(), 1); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2d2d2ac..d90942cb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod cmd; pub mod connect; +pub mod gif_export; pub mod guard; pub mod keys; pub mod links; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index b1521521..fd42692b 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -117,3 +117,29 @@ fn export_tool_lists_pdf_and_its_options() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn export_tool_lists_gif_and_its_options() { + let mut s = Server::new(Box::new(Headless::new())); + let tools = call(&mut s, 1, "tools/list", json!({})); + let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone(); + assert!(export["description"].as_str().unwrap().contains("gif"), "{export}"); + let props = &export["inputSchema"]["properties"]; + assert!(props["format"]["enum"].as_array().unwrap().iter().any(|x| x == "gif"), "{export}"); + assert_eq!(props["width"]["oneOf"][1]["enum"], json!(["small", "medium", "large", "extraLarge"])); + for p in ["secondsPerSlide", "loop", "slides", "includeHidden"] { + assert!(props.get(p).is_some(), "{p}: {export}"); + } + // What the schema describes is accepted, and bad values are tool errors, not crashes. + let dir = std::env::temp_dir().join(format!("deckcraft-mcp-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + tool(&mut s, "new_presentation", json!({})); + for width in [json!("small"), json!(200)] { + let path = dir.join("deck.gif"); + tool(&mut s, "export", json!({"path": path.to_string_lossy(), "width": width, "secondsPerSlide": 2, "loop": false, "slides": [0]})); + assert!(std::fs::read(&path).unwrap().starts_with(b"GIF89a"), "{width}"); + } + let r = call(&mut s, 9, "tools/call", json!({"name": "export", "arguments": {"path": dir.join("bad.gif").to_string_lossy(), "width": 0}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1a457dda..44cf8897 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -115,21 +115,30 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).", + "Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, pdf (slides, notes pages or handouts), or an animated gif of the deck (one frame per slide).", obj( json!({ "path": string("Output path"), - "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"}, + "format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"}, "slide": int("Slide index for images"), "all": boolean("All slides (images)"), "scale": num("Pixels per point for images (default 2)"), "layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"}, "perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"}, "dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"), - "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"}, - "includeHidden": boolean("PDF: include hidden slides (default false)"), + "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF and GIF: slide indices to export, in order (default all)"}, + "includeHidden": boolean("PDF and GIF: include hidden slides (default false)"), "textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"), - "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)") + "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"), + "width": { + "oneOf": [ + {"type": "integer", "minimum": 16, "maximum": 3840}, + {"type": "string", "enum": ["small", "medium", "large", "extraLarge"]} + ], + "description": "GIF: frame width in pixels, or a size: small 640, medium 960 (default), large 1280, extraLarge 1920; the height follows the slide's shape" + }, + "secondsPerSlide": {"type": "number", "minimum": 0.1, "maximum": 600, "description": "GIF: seconds each slide shows (default 5)"}, + "loop": boolean("GIF: loop forever (true, default) or play once (false)") }), &["path"], ), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73ce180b..a05c77da 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -377,6 +377,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("JPEG image (current slide)", "jpeg"), ("PowerPoint Presentation (.pptx)", "pptx"), ("Outline (.txt)", "outline"), + ("Animated GIF (.gif)", "gif"), ] { if ui.radio(fmt == f, l).clicked() { d.fields.insert("fmt".into(), f.into()); @@ -420,6 +421,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } + let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; + if let Err(e) = &gif_params { + ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); + } let pdf_params = { let (layout, per) = match pdf_layout.as_str() { "notes" => ("notes", 0), @@ -437,15 +442,27 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "jpeg" => ("jpg", false), "pptx" => ("pptx", false), "outline" => ("txt", false), + "gif" => ("gif", false), _ => ("png", false), }; let suggested = format!("{name}.{ext}"); + let gif_params = match gif_params { + Ok(v) => v, + // Keep the dialog open so the range or width can be corrected. + Err(e) => { + app.set_status(e); + return false; + } + }; + let extra = match ext { + "pdf" => pdf_params.as_object(), + "gif" => gif_params.as_object(), + _ => None, + }; if let Some(pick) = app.services.pick_save.as_mut() { if let Some(path) = pick(&suggested) { let mut params = json!({"path": path, "all": all}); - if ext == "pdf" - && let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object()) - { + if let (Some(o), Some(extra)) = (params.as_object_mut(), extra) { o.extend(extra.clone()); } let r = app.session.execute("file.export", ¶ms); @@ -465,6 +482,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if ext == "png" { img.to_png() } else { img.to_jpeg(92) } } "pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params), + "gif" => match deckcraft_engine::cmd::file::gif_bytes(&s.doc, &gif_params) { + Ok(b) => b, + Err(e) => { + app.set_status(e.to_string()); + return true; + } + }, other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(), }; dl(&suggested, &bytes); @@ -923,6 +947,64 @@ fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) { }); } +/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden +/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message. +fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result { + use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS}; + let n = app.session.active().map_or(0, |s| s.doc.slides.len()); + let size = d.get("gifSize", "medium"); + let size_label = |k: &str| match k { + "small" => "Small (640 px wide)", + "large" => "Large (1280 px wide)", + "extraLarge" => "Extra Large (1920 px wide)", + "custom" => "Custom width", + _ => "Medium (960 px wide)", + }; + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:")); + egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { + for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) { + if ui.selectable_label(size == k, size_label(k)).clicked() { + d.fields.insert("gifSize".into(), k.into()); + } + } + }); + }); + if size == "custom" { + field(ui, d, "Width (pixels):", "gifWidth", "960"); + } + field(ui, d, "Seconds per slide:", "gifSeconds", "5"); + let n_text = n.max(1).to_string(); + field(ui, d, "From slide:", "gifFrom", "1"); + field(ui, d, "To slide:", "gifTo", &n_text); + let forever = check(ui, d, "Loop forever", "gifLoop", true); + let hidden = check(ui, d, "Include hidden slides", "gifHidden", false); + + let width = if size == "custom" { + let w = num(d, "gifWidth", 960.0).round(); + if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) { + return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels")); + } + json!(w as u32) + } else { + json!(size) + }; + let seconds = num(d, "gifSeconds", f64::NAN); + if deckcraft_engine::gif_export::check_seconds(seconds).is_err() { + return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}")); + } + let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden}); + let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round()); + if !(from >= 1.0 && from <= to && to <= n as f64) { + return Err(format!("Slides: choose a range from 1 to {n}")); + } + if from > 1.0 || to < n as f64 { + p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::>()); + } + Ok(p) +} + /// PDF bytes with the export dialog's options (web: downloaded instead of written). fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec { deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default() @@ -933,6 +1015,62 @@ mod tests { use super::*; use std::cell::{Cell, RefCell}; + /// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171). + #[test] + fn export_dialog_writes_an_animated_gif() { + use egui_kittest::kittest::Queryable as _; + let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.gif"); + let picked = path.to_string_lossy().to_string(); + let suggested = std::rc::Rc::new(RefCell::new(String::new())); + let seen = suggested.clone(); + let services = crate::Services { + pick_save: Some(Box::new(move |s: &str| { + *seen.borrow_mut() = s.to_string(); + Some(picked.clone()) + })), + ..Default::default() + }; + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + harness.state_mut().run("app.exportDialog", json!({})).unwrap(); + harness.run_steps(2); + harness.get_by_label("Animated GIF (.gif)").click_accesskit(); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif")); + harness.get_by_label("Seconds per slide:"); + harness.get_by_label("Loop forever").click_accesskit(); + harness.run_steps(2); + // A range outside the deck keeps the dialog open with a message, and nothing is written. + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status); + assert!(!path.exists()); + harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into()); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); + assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow()); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default"); + assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17). #[test] fn inset_wrapped_indents_every_row() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 449b7fc6..0995dd5e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,7 +26,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | | `show.start` / `show.end` | `{from?: slide index, presenter?, reading?}` | Slide show | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1d87c9ac..0d4a622b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171; previous: major, created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -28,7 +28,7 @@ PowerPoint opens and saves; Viewer = opens only). | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | | PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer | render tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | -| Animated GIF of the deck | export | n/a | no | | none | +| Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests | | MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none | | Print | yes | n/a | no | PR #66 (browser) open | none | @@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Animated GIF of the deck: export yes (#171) | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | diff --git a/docs/gaps.md b/docs/gaps.md index 6ea4130a..a233c430 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export landed, #171; the video gap stays; previous: minor, core-workflow alpha gate added; stage stays alpha) · **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 @@ -115,11 +115,13 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** handouts and speaker notes are printed in many workplaces. - **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.** -### No video or animated GIF export -- **Evidence:** checklist row missing; PowerPoint exports MP4/MOV and animated GIF. +### No video export; the animated GIF has no transitions or builds +- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export` writes + an animated GIF with one frame per slide (#171), but there is no MP4/MOV export, and the GIF + doesn't animate transitions or build animations. - **Impact:** sharing a talk as a video is a common request. -- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part, - FilmCraft has one to copy). +- **Estimate:** 10–16 h (render the timeline frame by frame, which the GIF can then reuse for + transitions and builds; an H.264 encoder is the hard part, FilmCraft has one to copy). ### Embedded fonts aren't read or written - **Evidence:** no `p:embeddedFontLst` handling in `crates/pptx`. @@ -247,5 +249,6 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Animated GIF export landed (#171); the entry now covers video export and animating transitions in the GIF | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/mcp.md b/docs/mcp.md index f7e24c7e..5de38172 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -20,7 +20,7 @@ deck. | `list_commands` | Every command (id, label, menu, shortcut, params doc), optionally filtered | | `run_command` | Run one command: `{command, params}` — every menu item, ribbon button and gesture is a command | | `batch` | Run several commands in order and stop at the first error | -| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline | +| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline, animated GIF (size, seconds per slide, loop, slides) | | `inspect_document`, `inspect_slide` | Structure of the deck / one slide (shapes, text, placeholders, animations) | | `render_slide` | A slide as an image the model can look at | | `add_slide`, `go_to_slide` | Slides by layout | @@ -33,5 +33,5 @@ deck. Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any +`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` (GIF). `deckcraft-cli describe ID` prints any command's parameters. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8c7337fa..86d17e72 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # PowerPoint feature checklist -> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Generated:** 2026-10-11 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` **Weighted breadth parity: 79%** over 191 features. @@ -9,12 +9,12 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 60 | 12 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 13 | 28 | 36% | +| P2 | 54 | 13 | 14 | 27 | 37% | | Area | Parity | |---|---| | Application shell | 71% | -| Files | 71% | +| Files | 72% | | Slides | 90% | | Views | 90% | | Masters & themes | 85% | @@ -65,7 +65,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P | | Files | Export slides as PNG/JPEG | P0 | D | | Files | Export outline / RTF | P2 | P | -| Files | Export video (MP4) / animated GIF | P2 | M | +| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | | Files | Reuse slides from another file | P1 | M | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c27d893..82f80ee8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171: P2 breadth 36% → 37%, overall unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -11,7 +11,7 @@ number. | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 | +| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 37%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 | | Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists | | **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) | | **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Animated GIF export (#171) moves the checklist's video/GIF row from missing to partial: P2 breadth 36% → 37%; overall breadth (79%), dimension scores and stage 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 | From 2ff4285be6c2cce69a7ed0fe9d16d83d1a3b1415 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Mon, 12 Oct 2026 16:12:55 +0530 Subject: [PATCH 03/69] Pictures: Change Picture replaces the selected picture (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Picture Format ▸ Change Picture button ran the insert-picture dialog, so it added a second picture instead of replacing the selected one. A new app.changePictureDialog picks a file and runs picture.change, which keeps the picture's position, size and formatting; the canvas right-click menu offers it on pictures too. On the web, where the file arrives later through the inbox, the pending change is remembered for that pick. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 8 +++++ crates/ui-egui/src/lib.rs | 61 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 2 +- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d267f76c..1b0950e5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -563,6 +563,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); }); + let picture = app + .session + .active() + .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); + if picture && ui.button("Change Picture…").clicked() { + let _ = app.run("app.changePictureDialog", json!({})); + ui.close(); + } if ui.button("Link…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..f402a1a9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), + ("app.changePictureDialog", "Change Picture…", None, "{}"), ("app.insertAudioDialog", "Audio from File…", None, "{}"), ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), @@ -209,6 +210,8 @@ pub struct SlideApp { pub(crate) notes_buf: (u64, usize, String), /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, + /// The web picker's next picture replaces the selected one (`app.changePictureDialog`). + change_picture: bool, } impl SlideApp { @@ -244,6 +247,7 @@ impl SlideApp { quit_confirmed: false, notes_buf: (0, usize::MAX, String::new()), thumb_drag: None, + change_picture: false, } } @@ -348,6 +352,7 @@ impl SlideApp { "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), @@ -406,6 +411,7 @@ impl SlideApp { } } } else if let Some(f) = self.services.open_async.as_mut() { + self.change_picture = cmd == "picture.change"; f(purpose); } } @@ -491,6 +497,13 @@ impl SlideApp { let Some(inbox) = self.services.inbox.clone() else { return }; let files: Vec<(String, Vec)> = inbox.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default(); for (name, bytes) in files { + if std::mem::take(&mut self.change_picture) && deckcraft_engine::cmd::insert::content_type(&name, &bytes).starts_with("image/") { + let p = json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&bytes)}); + if let Err(e) = self.session.execute("picture.change", &p) { + self.set_status(e.to_string()); + } + continue; + } self.open_or_insert_bytes(&name, &bytes); } } @@ -889,6 +902,54 @@ mod tests { sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() } + /// #115: Change Picture replaced nothing — it inserted a second picture. + #[test] + fn change_picture_replaces_the_selected_picture() { + let svg = |c: &str| { + format!("").into_bytes() + }; + let media = |app: &SlideApp| { + let d = app.session.active().unwrap(); + let pics: Vec<_> = d.doc.slides[0] + .shapes + .iter() + .filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None }) + .collect(); + assert_eq!(pics.len(), 1, "one picture, replaced in place"); + pics[0] + }; + let insert = |app: &mut SlideApp| { + let id = + app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() + ["id"] + .clone(); + app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + media(app) + }; + + // Desktop: the file dialog returns a path. + let blue = svg("blue"); + let services = Services { + pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))), + read: Some(Box::new(move |_: &str| Ok(blue.clone()))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + assert_ne!(media(&app), before); + + // Web: the browser picker delivers the file through the inbox later. + let inbox = Inbox::default(); + let services = Services { open_async: Some(Box::new(|_: &str| {})), inbox: Some(inbox.clone()), ..Default::default() }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + inbox.lock().unwrap().push(("b.svg".into(), svg("blue"))); + app.drain_inbox(); + assert_ne!(media(&app), before); + } + /// #17: Home created a new blank presentation instead of going to the start screen. #[test] fn home_shows_the_start_screen_over_the_open_presentation() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c7c251ef..d3ef6e9c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2070,7 +2070,7 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { } }); rows(ui, |ui| { - small(app, ui, Icon::ChangePicture, "Change Picture", "app.insertPictureDialog", json!({})); + small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); }); }); From 30a5ee202dc104d177d9762924e46ca65f21cf6e Mon Sep 17 00:00:00 2001 From: "Ryan C. Shoemaker" <45581085+RCShoemaker@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:42:53 -0600 Subject: [PATCH 04/69] fix(cli): render refuses an unknown option instead of writing it as a file (#235) `deckcraft-cli render` took any argument it didn't recognise as the output path, so `render deck.pptx --help` rendered the slide into a file named `--help`, and a mistyped option was silently ignored. An argument starting with `-` that render doesn't know is now an error naming it. Co-Authored-By: Claude Opus 5.5 --- apps/deckcraft-cli/src/main.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index c450393f..43cf7966 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -127,6 +127,7 @@ fn render(args: &[String]) -> Result<(), String> { "--all" => all = true, "--edit" => edit = true, "--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, + other if other.len() > 1 && other.starts_with('-') => return Err(format!("unknown render option `{other}`")), other if input.is_none() && !sample @@ -278,3 +279,20 @@ fn mcp(args: &[String]) -> Result<(), String> { let stdin = std::io::stdin(); server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() { + let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png"); + let _ = std::fs::remove_file(&out); + for flag in ["--bogus", "-x", "--help"] { + let args: Vec = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect(); + let e = render(&args).unwrap_err(); + assert!(e.contains(&format!("unknown render option `{flag}`")), "{flag}: {e}"); + assert!(!out.exists(), "{flag}: rendered anyway"); + } + } +} From 837e60ce651fb068f9ba2043004f46c57367b1c1 Mon Sep 17 00:00:00 2001 From: "Ryan C. Shoemaker" <45581085+RCShoemaker@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:42:53 -0600 Subject: [PATCH 05/69] Contributors: add my name (RCShoemaker) Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b852df13..92fd2399 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -37,3 +37,6 @@ sync_github_name = false [people.Johannesgauss] sync_github_name = true + +[people.RCShoemaker] +real_name = "Ryan C. Shoemaker" From 717e855cec895543c2b369d9939a9d738f8ab47a Mon Sep 17 00:00:00 2001 From: Yevhenii Hurin Date: Mon, 12 Oct 2026 23:41:42 +0300 Subject: [PATCH 06/69] deckcraft-cli help explains the CLI to an agent `deckcraft-cli help` and each subcommand's `--help` say what it does, with a working example; `--help` never runs the subcommand. `describe` spells out a command's parameters and what it acts on, `commands` lists one command per line, and the MCP instructions say what format.* acts on. [claude-opus-5-5] on behalf of hoblin --- ROADMAP.md | 1 + apps/deckcraft-cli/src/help.rs | 153 ++++++++++++++++++++++++++++++++ apps/deckcraft-cli/src/main.rs | 131 +++++++++++++++++++-------- apps/deckcraft-cli/tests/cli.rs | 136 ++++++++++++++++++++++++++++ crates/engine/src/cmd/design.rs | 2 +- crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/format.rs | 12 +-- crates/engine/src/cmd/mod.rs | 125 ++++++++++++++++++++++++-- crates/engine/src/cmd/shape.rs | 22 ++++- crates/engine/src/cmd/table.rs | 12 ++- crates/engine/src/cmd/text.rs | 6 +- crates/engine/src/tests.rs | 52 +++++++++++ crates/mcp/src/server.rs | 7 +- docs/control-protocol.md | 4 +- docs/mcp.md | 2 +- 15 files changed, 599 insertions(+), 70 deletions(-) create mode 100644 apps/deckcraft-cli/src/help.rs create mode 100644 apps/deckcraft-cli/tests/cli.rs diff --git a/ROADMAP.md b/ROADMAP.md index 801ddd84..6b0be0fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs new file mode 100644 index 00000000..1b76001f --- /dev/null +++ b/apps/deckcraft-cli/src/help.rs @@ -0,0 +1,153 @@ +pub struct Subcommand { + pub name: &'static str, + pub usage: &'static str, + pub summary: &'static str, + pub details: &'static str, +} + +pub const SUBCOMMANDS: &[Subcommand] = &[ + Subcommand { + name: "info", + usage: "info FILE", + summary: "print a presentation's slides, layouts and theme as JSON", + details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes, +notes, transition), its layouts, theme fonts, sections and slide size. + +The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command: + + deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'", + }, + Subcommand { + name: "render", + usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT", + summary: "draw slides as PNG images", + details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first; +OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per +slide. Prints each image's path and size. + + --slide N the slide at index N; indices start at 0, and 0 is the default + --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 + --edit draw what the editor shows: placeholder prompts and their dashed outlines + + deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", + }, + Subcommand { + name: "convert", + usage: "convert IN OUT", + summary: "save a presentation in the format OUT's extension names", + details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names: +.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide. Any other +extension is written as .deckcraft. Prints OUT. + + deckcraft-cli convert deck.deckcraft deck.pptx", + }, + Subcommand { + name: "run", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT] [--print]", + summary: "run commands on a presentation, then save or export it", + details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample, +else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps +everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the +current slide as .png or .jpg, or the presentation as .pdf or .pptx. + +Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one +JSON object. Quote it for the shell. A command that needs no parameters is just its id. + + deckcraft-cli run \\ + --cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\ + --cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\ + --cmd 'format.size={\"size\":28}' \\ + --save q3.deckcraft + +`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its +parameters. + +What the ids don't say: +- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is + [x, y, width, height]. +- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current; + slide.go {\"index\": N} makes slide N current. +- Shape ids are looked up on the current slide only. +- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts + on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands + take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on. + +If a command fails, run stops there and saves nothing. --print shows each command's answer, such as +the id of an inserted shape.", + }, + Subcommand { + name: "commands", + usage: "commands [FILTER]", + summary: "list the commands run can use, one per line", + details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in +the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case. + + deckcraft-cli commands format. + +In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what +the command answers. `deckcraft-cli describe COMMAND` spells one command out.", + }, + Subcommand { + name: "describe", + usage: "describe COMMAND", + summary: "explain one command and its parameters", + details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run +calls it. + + deckcraft-cli describe shape.insert", + }, + Subcommand { + name: "app", + usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]", + summary: "run a command in the DeckCraft app started with --control", + details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default) +and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD +[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object. + + deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}' + deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'", + }, + Subcommand { + name: "mcp", + usage: "mcp [--connect PORT] [--sample]", + summary: "serve the commands to an agent over MCP on stdio", + details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a +window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT. +--sample opens the sample presentation.", + }, + Subcommand { + name: "links", + usage: "links", + summary: "print the DeckCraft community and project links", + details: "Prints the DeckCraft community and project links.", + }, + Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." }, +]; + +pub fn page(name: &str) -> Option { + let name = match name { + "help" | "--help" | "-h" => return Some(overview()), + "--version" | "-V" => "version", + name => name, + }; + SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details)) +} + +pub fn overview() -> String { + let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default(); + let list: Vec = + SUBCOMMANDS.iter().map(|s| format!(" {: ExitCode { let args: Vec = std::env::args().skip(1).collect(); let rest = args.get(1..).unwrap_or(&[]); + let asks_help = rest.iter().any(|a| a == "--help" || a == "-h"); + if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) { + outln!("{page}"); + return ExitCode::SUCCESS; + } match args.first().map(String::as_str) { + Some("help" | "--help" | "-h") => match rest.first() { + None => { + outln!("{}", help::overview()); + ExitCode::SUCCESS + } + Some(name) => match help::page(name) { + Some(page) => { + outln!("{page}"); + ExitCode::SUCCESS + } + None => unknown(name), + }, + }, Some("--version" | "-V" | "version") => { outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS @@ -55,15 +53,16 @@ fn main() -> ExitCode { Some("convert") => report(convert(rest)), Some("run") => report(run(rest)), Some("commands") => { - let s = Session::new(); let f = rest.first().map(|x| x.to_lowercase()); - let list: Vec = s - .commands() - .into_iter() + for c in deckcraft_engine::command_specs() + .iter() .filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str()))) - .map(|c| serde_json::to_value(c).unwrap_or_default()) - .collect(); - outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + { + match c.params.strip_prefix("{}") { + Some(answer) => outln!("{} — {}{answer}", c.id, c.label), + None => outln!("{} — {}: {}", c.id, c.label, c.params), + } + } ExitCode::SUCCESS } Some("describe") => report(describe(rest.first().map(String::as_str))), @@ -74,14 +73,22 @@ fn main() -> ExitCode { outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}"); ExitCode::SUCCESS } - _ => { - eprintln!("{USAGE}"); - eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); - ExitCode::FAILURE - } + Some(other) => unknown(other), + None => usage(), } } +fn unknown(sub: &str) -> ExitCode { + eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n"); + usage() +} + +fn usage() -> ExitCode { + eprintln!("{}", help::overview()); + eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE); + ExitCode::FAILURE +} + fn report(r: Result<(), String>) -> ExitCode { match r { Ok(()) => ExitCode::SUCCESS, @@ -218,11 +225,61 @@ fn run(args: &[String]) -> Result<(), String> { fn describe(id: Option<&str>) -> Result<(), String> { let id = id.ok_or("describe needs a command id")?; - let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?; - outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params); + let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?; + let mut head = format!("{}: {}", c.id, c.label); + if !c.menu.is_empty() { + head.push_str(&format!(", in {}", c.menu.join(" ▸ "))); + } + if let Some(k) = c.shortcut { + head.push_str(&format!(", shortcut {k}")); + } + outln!("{head}.\n"); + let (args, answer) = c.params.split_once('→').unwrap_or((c.params, "")); + let parsed = deckcraft_engine::cmd::parse_params(c.params); + match &parsed { + Err(_) => outln!("Parameters: {}", args.trim()), + Ok(params) if params.is_empty() => outln!("Takes no parameters."), + Ok(params) => print_params(params), + } + let params = parsed.unwrap_or_default(); + if let Some(acts) = c.acts_on() { + outln!("{acts}"); + } + let remark = remark(args); + if !remark.is_empty() { + outln!("Note: {remark}"); + } + if !answer.trim().is_empty() { + outln!("Answers with {}.", answer.trim()); + } + let call = if params.iter().all(|p| p.optional) { c.id.to_string() } else { format!("'{}={{…}}'", c.id) }; + outln!("\nRun it with: deckcraft-cli run --cmd {call}"); Ok(()) } +fn print_params(params: &[deckcraft_engine::cmd::Param]) { + let name = |p: &deckcraft_engine::cmd::Param| { + let or = if p.or { "or " } else { "" }; + let optional = if p.optional { ", optional" } else { "" }; + format!("{or}{}{optional}", p.name) + }; + let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default(); + let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0; + outln!("Parameters:"); + for (k, p) in params.iter().enumerate() { + if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) { + outln!(" {}", if p.object == 0 { "either:" } else { "or:" }); + } + let indent = if sets { " " } else { " " }; + outln!("{}", format!("{indent}{: &str { + let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim(); + after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after) +} + fn app(args: &[String]) -> Result<(), String> { use deckcraft_mcp::{Backend, Remote, control_addr}; let mut port = deckcraft_mcp::DEFAULT_PORT.to_string(); diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs new file mode 100644 index 00000000..4cb8c32e --- /dev/null +++ b/apps/deckcraft-cli/tests/cli.rs @@ -0,0 +1,136 @@ +use std::process::{Command, Output}; + +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() +} + +fn stdout(o: &Output) -> String { + assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr)); + String::from_utf8_lossy(&o.stdout).into_owned() +} + +fn stderr(o: &Output) -> String { + assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout)); + String::from_utf8_lossy(&o.stderr).into_owned() +} + +const SUBCOMMANDS: [&str; 9] = ["info", "render", "convert", "run", "commands", "describe", "app", "mcp", "links"]; + +fn words(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +#[test] +fn an_unknown_subcommand_is_named_before_the_overview() { + for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] { + let err = stderr(&cli(&args)); + assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}"); + } +} + +#[test] +fn no_subcommand_shows_the_overview_and_fails() { + assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on")); +} + +#[test] +fn help_lists_every_subcommand_with_a_run_example() { + for flag in ["help", "--help", "-h"] { + let out = stdout(&cli(&[flag])); + for sub in SUBCOMMANDS { + assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}"); + } + assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}"); + } +} + +#[test] +fn every_subcommand_answers_help_and_does_nothing_else() { + let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for sub in SUBCOMMANDS { + for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] { + let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap(); + assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}"); + } + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing"); + std::fs::remove_dir_all(&dir).unwrap(); + for args in [["help", "version"], ["--version", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}"); + } + for args in [["help", "help"], ["help", "--help"]] { + assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}"); + } +} + +#[test] +fn run_help_says_what_the_ids_do_not() { + let out = words(&stdout(&cli(&["run", "--help"]))); + for fact in [ + "COMMAND is a command id, then `=`, then the command's parameters as one JSON object.", + "points on a 960×540 slide", + "Slide indices start at 0.", + "Shape ids are looked up on the current slide only.", + "A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.", + "format.* acts on the selected text or shapes", + ] { + assert!(out.contains(fact), "{fact}: {out}"); + } +} + +#[test] +fn describe_spells_out_a_command() { + assert_eq!( + stdout(&cli(&["describe", "shape.insert"])), + "shape.insert: Shapes, in Insert ▸ Illustrations. + +Parameters: + preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets) + rect [x,y,w,h] pt + text, optional string + +Run it with: deckcraft-cli run --cmd 'shape.insert={…}' +" + ); + assert_eq!( + stdout(&cli(&["describe", "format.size"])), + "format.size: Font Size, in Home ▸ Font. + +Parameters: + size pt +Acts on the selected text or shapes. + +Run it with: deckcraft-cli run --cmd 'format.size={…}' +" + ); + assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")); + assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n")); + assert_eq!( + stdout(&cli(&["describe", "shape.setDefault"])), + "shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n" + ); +} + +#[test] +fn describe_shows_alternative_sets_of_parameters() { + assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n")); + assert!(stdout(&cli(&["describe", "shape.move"])).contains( + " either: + dx, optional pt (relative) + dy, optional pt (relative) + ids, optional + or: + x, optional pt (absolute) + y, optional pt (absolute) + ids, optional +" + )); +} + +#[test] +fn commands_lists_one_line_per_command() { + assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n"); + assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n"); + assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n"); +} diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 641ddffc..0483bd95 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Slide Size", ["Design", "Customize"], None, - "{preset?: widescreen|standard|… | w, h: pt, scale?: maximize|ensureFit|none}", + "{preset: widescreen|standard|…, scale?: maximize|ensureFit|none} | {w: pt, h: pt, scale?: maximize|ensureFit|none}", has_doc, slide_size ), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 55aac8bd..8bf937a0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export), cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close), cmd!( @@ -30,7 +30,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save), cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list), cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open), - cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard), + cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard), cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert), cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window), ] diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index f02a9a26..ba74aa9c 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -39,15 +39,7 @@ pub fn specs() -> Vec { cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size), cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow), cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink), - cmd!( - "format.color", - "Font Color", - ["Home", "Font"], - None, - "{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}", - has_text_or_shapes, - color - ), + cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color), cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight), cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case), cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps), @@ -149,7 +141,7 @@ pub fn specs() -> Vec { "Text Box Margins", ["Format Shape", "Text Box"], None, - "{left?, top?, right?, bottom?: pt}", + "{left?: pt, top?: pt, right?: pt, bottom?: pt}", has_text_or_shapes, margins ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 29c7013e..9cd8e4f1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -53,6 +53,27 @@ pub struct CommandInfo { } impl CommandSpec { + pub fn acts_on(&self) -> Option { + let keys = param_keys(self.params).unwrap_or_default(); + let named = if keys.contains(&"ids") { + Some("the shapes in `ids`") + } else if keys.contains(&"id") { + Some("the shape in `id`") + } else { + None + }; + let reason = (self.enabled)(&Session::with_new()).err()?; + Some(match (reason.as_str(), named) { + (SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(), + (NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."), + (NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(), + (SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."), + (SELECT_TWO, None) => "Acts on two or more selected shapes.".into(), + (NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(), + _ => return None, + }) + } + pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); CommandInfo { @@ -79,6 +100,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> { } pub const NOTHING_SELECTED: &str = "nothing selected"; pub const SELECT_TWO: &str = "select two or more objects"; +pub const NO_TEXT_POINT: &str = "no text insertion point"; +pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape"; pub fn has_selection(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) } @@ -89,17 +112,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> { } pub fn has_text(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) } + if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) } } /// Text editing or shapes with text selected (formatting commands apply to either). pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { - Ok(()) - } else { - Err("select text or a shape".into()) - } + if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) } } + pub fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } } @@ -148,6 +168,99 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } +pub(crate) fn param_keys(doc: &str) -> std::result::Result, String> { + let mut keys = vec![]; + for p in parse_params(doc)? { + if !keys.contains(&p.name) { + keys.push(p.name); + } + } + Ok(keys) +} + +#[derive(Debug, PartialEq)] +pub struct Param<'a> { + pub name: &'a str, + pub optional: bool, + /// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`). + pub about: &'a str, + /// An alternative to the parameter before it in the same object (`color? | none?`). + pub or: bool, + /// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1. + pub object: usize, +} + +/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset, +/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result +/// and is skipped. Errs where the doc strays from that grammar. +pub fn parse_params(doc: &str) -> std::result::Result>, String> { + let doc = doc.split('→').next().unwrap_or_default(); + let mut params: Vec = vec![]; + let mut open: Vec = vec![]; + let mut expect_key = false; + let mut or = false; + let mut objects: usize = 0; + let mut about_from: Option = None; + let mut chars = doc.char_indices().peekable(); + let mut prev = ' '; + fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option, to: usize) { + if let (Some(f), Some(p)) = (from.take(), params.last_mut()) { + p.about = doc.get(f..to).unwrap_or_default().trim(); + } + } + while let Some((i, c)) = chars.next() { + let in_object = open == ['{']; + match c { + '{' | '[' | '(' => { + open.push(c); + if open == ['{'] { + expect_key = true; + objects += 1; + or = false; + } + } + '}' | ']' | ')' => { + if in_object { + end_about(doc, &mut params, &mut about_from, i); + } + open.pop(); + } + ',' if in_object => { + end_about(doc, &mut params, &mut about_from, i); + expect_key = true; + } + '|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => { + end_about(doc, &mut params, &mut about_from, i); + expect_key = true; + or = true; + } + c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => { + let mut end = i + c.len_utf8(); + while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') { + end = j + n.len_utf8(); + chars.next(); + } + let after = doc.get(end..).unwrap_or_default(); + let rest = after.trim_start_matches('?').trim_start(); + let at = doc.len() - rest.len(); + about_from = match rest.chars().next() { + Some(':') => Some(at + 1), + Some('(') => Some(at), + Some(',' | '}' | '|') => None, + _ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::())), + }; + params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) }); + or = false; + expect_key = false; + } + c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])), + _ => {} + } + prev = c; + } + Ok(params) +} + // ---------- param helpers ---------- pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 7e12f609..fdbb68f9 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -30,8 +30,16 @@ pub fn specs() -> Vec { freeform ), cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites), - cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), - cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), + cmd!( + "shape.move", + "Move", + [], + None, + "{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}", + has_selection, + move_by + ), + cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds), cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate), cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip), @@ -78,7 +86,15 @@ pub fn specs() -> Vec { cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible), cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock), cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default), - cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop), + cmd!( + "picture.crop", + "Crop", + ["Picture Format", "Size"], + None, + "{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}", + has_selection, + crop + ), cmd!( "picture.adjust", "Corrections", diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e9f6f164..e8abb05c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -27,7 +27,15 @@ pub fn specs() -> Vec { s, p, "delCol" )), cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge), - cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split), + cmd!( + "table.split", + "Split Cells", + ["Table Layout", "Merge"], + None, + "{cell?: [r, c] (else the edited cell), id?} | {row: index, col: index, id?}", + has_selection, + split + ), cmd!( "table.style", "Table Styles", @@ -42,7 +50,7 @@ pub fn specs() -> Vec { "Table Style Options", ["Table Design", "Table Style Options"], None, - "{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}", + "{headerRow?: bool, totalRow?: bool, bandedRows?: bool, firstColumn?: bool, lastColumn?: bool, bandedColumns?: bool, id?}", has_selection, options ), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a51..30f86340 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -64,7 +64,7 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti /// Edit the active text body and selection; refits auto-sizing text boxes afterwards. pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { - let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?; + let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?; // Materialise an inherited placeholder box before editing (so autofit can change it). let r = s.edit(|doc, sel| { let mut t = t0.clone(); @@ -567,7 +567,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { @@ -656,7 +656,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex } let ids = st.selection.shapes.clone(); if ids.is_empty() { - return Err(bad("format", "select text or a shape")); + return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } edit_shapes(s, &json!({"ids": ids}), "format", |sh| { match &mut sh.kind { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b7..55ca11bb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -543,3 +543,55 @@ fn inserted_hyperlinks_look_like_links() { s.execute("format.underline", &json!({})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); } + +#[test] +fn every_params_doc_names_its_keys() { + for c in command_specs() { + assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params)); + } + assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]); + assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]); +} + +#[test] +fn a_params_doc_spells_out_each_parameter() { + use cmd::Param; + let p = |name, optional, about, or, object| Param { name, optional, about, or, object }; + assert_eq!( + cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(), + [ + p("preset", false, "rect|roundRect", false, 0), + p("rect", true, "[x,y,w,h] pt", false, 0), + p("index", true, "(else selected shapes)", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(), + [ + p("color", true, "", false, 0), + p("none", true, "true", true, 0), + p("gradient", true, "{stops, kind?: a|b}", false, 0), + p("ids", true, "", false, 0) + ] + ); + assert_eq!( + cmd::parse_params("{name} | {major, minor}").unwrap(), + [p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)] + ); +} + +#[test] +fn what_a_command_acts_on_names_only_keys_it_takes() { + for c in command_specs() { + let keys = cmd::param_keys(c.params).unwrap(); + let said = c.acts_on().unwrap_or_default(); + assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id); + assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id); + } + let acts = |id: &str| find_command(id).and_then(|c| c.acts_on()); + assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes.")); + assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes.")); + assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes.")); + assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes.")); + assert_eq!(acts("slide.new"), None); +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 576e0b4a..9316fa45 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -22,9 +22,10 @@ const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone) current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \ list_commands and run them with run_command (or several with batch). Typical flow: new_presentation → add_slide \ {layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \ -inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shapes are selected with \ -run_command edit.select {ids:[…]}; formatting commands (format.*, shape.fill, shape.line…) act on the selection or \ -on `ids`. Save with save_presentation (.deckcraft or .pptx)."; +inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shape ids are looked up on the \ +current slide. An inserted shape is selected; run_command edit.select {ids:[…]} selects others. format.* acts on \ +the selected text or shapes; shape.fill, shape.line and the other commands whose parameters list `ids` act on \ +those shapes, else on the selected ones. Save with save_presentation (.deckcraft or .pptx)."; /// Resource URIs. pub const DOC_URI: &str = "deckcraft://document"; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 449b7fc6..302ba345 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -12,7 +12,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | Method | Params | What it does | |---|---|---| -| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) | +| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | | `document.inspect` | — | Slides, sections, layouts, shapes, selection | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, Home (start screen over the open presentations), slide show state, documents, perf (fps, render ms) | @@ -29,4 +29,4 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude -and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell. +and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell. diff --git a/docs/mcp.md b/docs/mcp.md index f7e24c7e..72cbd581 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -33,5 +33,5 @@ deck. Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any +`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe COMMAND` prints any command's parameters. From 66f7e6e1ed3c6817cb208dfdcb1a899868cb2436 Mon Sep 17 00:00:00 2001 From: Yevhenii Hurin Date: Tue, 13 Oct 2026 00:15:53 +0300 Subject: [PATCH 07/69] Self-review: no ID[=JSON] left, params grammar documented A --cmd without a value now asks for COMMAND[=PARAMS]. The params field says how describe reads it, table.split's row and col are optional as the command reads them, and the MCP instructions name only shape commands for `ids`. [claude-opus-5-5] on behalf of hoblin --- apps/deckcraft-cli/src/help.rs | 2 +- apps/deckcraft-cli/src/main.rs | 2 +- crates/engine/src/cmd/mod.rs | 6 +++++- crates/engine/src/cmd/table.rs | 2 +- crates/mcp/src/server.rs | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 1b76001f..4245172a 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -147,7 +147,7 @@ A presentation is edited by running commands on it: deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft `deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand; -`deckcraft-cli help run` explains commands.", +`deckcraft-cli help run` explains how to write a command.", list.join("\n") ) } diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index 7b36288f..139b8e90 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -195,7 +195,7 @@ fn run(args: &[String]) -> Result<(), String> { match a.as_str() { "--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?), "--sample" => sample = true, - "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?), + "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?), "--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?), "--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?), "--print" => print = true, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9cd8e4f1..58ad2407 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -33,6 +33,9 @@ pub struct CommandSpec { /// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu). pub menu: &'static [&'static str], pub shortcut: Option<&'static str>, + /// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks + /// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values + /// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer. pub params: &'static str, pub enabled: Enabled, pub run: Run, @@ -53,6 +56,7 @@ pub struct CommandInfo { } impl CommandSpec { + /// Holds because `Session::execute` skips the selection check when `id` or `ids` is given. pub fn acts_on(&self) -> Option { let keys = param_keys(self.params).unwrap_or_default(); let named = if keys.contains(&"ids") { @@ -114,7 +118,7 @@ pub fn has_text(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) } } -/// Text editing or shapes with text selected (formatting commands apply to either). +/// Text editing or selected shapes (formatting commands apply to either). pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e8abb05c..ea5b2207 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Split Cells", ["Table Layout", "Merge"], None, - "{cell?: [r, c] (else the edited cell), id?} | {row: index, col: index, id?}", + "{cell?: [r, c] (else the edited cell), id?} | {row?: index, col?: index, id?}", has_selection, split ), diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 9316fa45..710067cd 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -24,7 +24,7 @@ list_commands and run them with run_command (or several with batch). Typical flo {layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \ inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shape ids are looked up on the \ current slide. An inserted shape is selected; run_command edit.select {ids:[…]} selects others. format.* acts on \ -the selected text or shapes; shape.fill, shape.line and the other commands whose parameters list `ids` act on \ +the selected text or shapes; shape.fill, shape.line and the other shape commands whose parameters list `ids` act on \ those shapes, else on the selected ones. Save with save_presentation (.deckcraft or .pptx)."; /// Resource URIs. From 426fd71bd28beb318a760c24667fc7839f40f676 Mon Sep 17 00:00:00 2001 From: Yevhenii Hurin Date: Tue, 13 Oct 2026 00:19:51 +0300 Subject: [PATCH 08/69] ROADMAP status line names the progress-log entry [claude-opus-5-5] on behalf of hoblin --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6b0be0fb..c33a06aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log for 2026-10-12; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; before that: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere From e6a41d1ada131b415d5a46b78dda1ec16d2ddf40 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 06:03:42 +0200 Subject: [PATCH 09/69] Review: the Language dialog marks the selected text with the language you pick (fixes #339) --- crates/engine/src/cmd/format.rs | 18 ++++++++++++++++ crates/engine/src/tests.rs | 14 +++++++++++++ crates/ui-egui/src/dialogs.rs | 37 +++++++++++++++++++++------------ crates/ui-egui/src/lib.rs | 29 ++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..1b9aee83 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -59,6 +59,15 @@ pub fn specs() -> Vec { case ), cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps), + cmd!( + "format.language", + "Language", + ["Review", "Language"], + None, + "{lang: BCP 47 tag (en-US, fr-FR…)} — the proofing language of the selected text", + has_text_or_shapes, + language + ), cmd!( "format.spacing", "Character Spacing", @@ -297,6 +306,14 @@ fn highlight(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color"); format_run(s, &move |r| r.highlight = c.clone()) } +fn language(s: &mut Session, p: &Value) -> Result { + let l = str_param(p, "lang") + .map(str::trim) + .filter(|l| !l.is_empty() && l.len() <= 35) + .ok_or_else(|| bad("format.language", "missing or invalid `lang`"))? + .to_string(); + format_run(s, &move |r| r.lang = Some(l.clone())) +} fn caps(s: &mut Session, p: &Value) -> Result { let c = match str_param(p, "caps").unwrap_or("none") { "small" => Caps::Small, @@ -678,6 +695,7 @@ fn state(s: &mut Session, _p: &Value) -> Result { "align": pp.align.map(|a| a.xml()), "bullets": matches!(pp.bullet, Some(Bullet::Char { .. })), "numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })), + "lang": r.lang, "lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(), "painter": s.painter.is_some(), })) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..ad87d48f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,17 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn format_language_marks_the_selected_text() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola mundo"})).unwrap(); + s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); + s.execute("format.language", &json!({"lang": "es-ES"})).unwrap(); + assert_eq!(s.execute("format.state", &json!({})).unwrap()["lang"], "es-ES"); + let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect(); + assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..74ec0c91 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -556,21 +556,32 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "language" => { ui.label("Mark selected text as:"); - for l in [ - "English (United States)", - "English (United Kingdom)", - "Español", - "Français", - "Deutsch", - "Italiano", - "Português", - "日本語", - "中文", - "العربية", - ] { - let _ = ui.selectable_label(l.starts_with("English (United States)"), l); + if !d.fields.contains_key("lang") { + let cur = app.session.execute("format.state", &json!({})).ok().and_then(|v| v.get("lang").and_then(Value::as_str).map(String::from)); + d.fields.insert("lang".into(), cur.unwrap_or_else(|| "en-US".into())); } + let mut lang = d.get("lang", "en-US"); + for (tag, name) in [ + ("en-US", "English (United States)"), + ("en-GB", "English (United Kingdom)"), + ("es-ES", "Español"), + ("fr-FR", "Français"), + ("de-DE", "Deutsch"), + ("it-IT", "Italiano"), + ("pt-PT", "Português"), + ("ja-JP", "日本語"), + ("zh-CN", "中文"), + ("ar-SA", "العربية"), + ] { + if ui.selectable_label(lang.eq_ignore_ascii_case(tag), name).clicked() { + lang = tag.to_string(); + } + } + d.fields.insert("lang".into(), lang.clone()); let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "format.language", json!({"lang": lang})); + } ok || cancel } "customShow" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..19b7aec9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1408,4 +1408,33 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + /// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text. + #[test] + fn the_language_dialog_marks_the_selected_text() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap(); + harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + // The anchored window settles over its first frames; click once it stays put. + harness.run_steps(4); + harness.get_by_label("Français").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR")); + } } From 922445300b2ab1d98eb52e710fa5409a9e782e7d Mon Sep 17 00:00:00 2001 From: Yevhenii Hurin Date: Tue, 13 Oct 2026 09:55:19 +0300 Subject: [PATCH 10/69] describe keeps a remark with its option and allows a bare call animation.remove's remark belongs to its `ids` option, so describe shows it there instead of as a note on the whole command. A command with an all-optional set of parameters is suggested as a bare id. [claude-opus-5-5] on behalf of hoblin --- apps/deckcraft-cli/src/main.rs | 3 ++- apps/deckcraft-cli/tests/cli.rs | 3 +++ crates/engine/src/cmd/animation.rs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index 139b8e90..0c252fbe 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -252,7 +252,8 @@ fn describe(id: Option<&str>) -> Result<(), String> { if !answer.trim().is_empty() { outln!("Answers with {}.", answer.trim()); } - let call = if params.iter().all(|p| p.optional) { c.id.to_string() } else { format!("'{}={{…}}'", c.id) }; + let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional)); + let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) }; outln!("\nRun it with: deckcraft-cli run --cmd {call}"); Ok(()) } diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs index 4cb8c32e..3f1ed98b 100644 --- a/apps/deckcraft-cli/tests/cli.rs +++ b/apps/deckcraft-cli/tests/cli.rs @@ -115,6 +115,9 @@ Run it with: deckcraft-cli run --cmd 'format.size={…}' #[test] fn describe_shows_alternative_sets_of_parameters() { assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n")); + let remove = stdout(&cli(&["describe", "animation.remove"])); + assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}"); + assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}"); assert!(stdout(&cli(&["describe", "shape.move"])).contains( " either: dx, optional pt (relative) diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 8b6fe5a7..b7989a69 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { has_selection, add ), - cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove), + cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove), cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim), cmd!( "animation.timing", From 9d185aeb680a5ae7dca54284eab185dd716d1ef8 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:22:11 +0200 Subject: [PATCH 11/69] Canvas: drag the frame of a text box being edited to move it While a text box or placeholder is being edited, every press inside its box placed the caret, so the box could not be moved without leaving editing first. Like PowerPoint, a band on either side of the box edge (the hit tolerance wide, in the shape's rotated frame) is now its frame: hit_test reports Hit::Frame there, the canvas shows the move cursor, and a press leaves text editing, selects the box and drags it as one undo step. A click without a drag just selects the whole box. Inside the box the caret and text selection work as before; resize and adjust handles still win. The text-click zone now follows the rotated box instead of its axis-aligned bounds. --- crates/engine/src/tests.rs | 143 +++++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 101 ++++++++++++++++++++----- crates/ui-egui/src/canvas.rs | 2 +- 3 files changed, 224 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..d9ddf2b5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,146 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// A blank slide holding one text box being edited, with smart guides and grid snapping off so +/// drags move by exactly the pointer delta. Returns the session, the box id and its box. +fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap(); + if rot != 0.0 { + s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap(); + } + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let x = text_box_xfrm(&s, id); + (s, id, x) +} + +fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm { + let st = s.doc().unwrap(); + cmd::xfrm_of(&st.doc, &st.selection, st.shape(deckcraft_model::ShapeId(id as u32)).unwrap()) +} + +/// Slide point for a point in the box's own (rotated) frame. +fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) { + let p = x.affine() * deckcraft_geom::Point::new(lx, ly); + (p.x, p.y) +} + +fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value { + let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let down = s.pointer(ev(PointerKind::Down, from)).unwrap(); + let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0); + s.pointer(ev(PointerKind::Drag, mid)).unwrap(); + s.pointer(ev(PointerKind::Drag, to)).unwrap(); + s.pointer(ev(PointerKind::Up, to)).unwrap(); + down +} + +#[test] +fn dragging_the_frame_of_edited_text_moves_the_box() { + let (mut s, id, x) = editing_text_box(0.0); + assert!(x.h > 12.0, "{x:?}"); + let sid = deckcraft_model::ShapeId(id as u32); + // Hovering the band on either side of the edge says "move"; the inside says "text". + for ly in [-2.0, 2.0, x.h + 2.0] { + let (px, py) = on_box(&x, x.w / 4.0, ly); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}"); + } + let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true })); + + // Press on the top edge, just outside, and drag: the box moves, text editing ends. + let undo_before = s.doc().unwrap().history.undo.len(); + let from = on_box(&x, x.w / 4.0, -1.0); + let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0)); + assert_eq!(down["hit"], json!({"kind": "frame", "id": id})); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!((moved.w, moved.h), (x.w, x.h)); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + // One undo step, which puts the box back. + assert_eq!(st.history.undo.len(), undo_before + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_box_xfrm(&s, id), x); + + // The inner side of the band works too; a click there without a drag selects the box whole + // and changes nothing. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0); + let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0 }; + s.pointer(ev(PointerKind::Down)).unwrap(); + s.pointer(ev(PointerKind::Up)).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + assert_eq!(st.history.undo.len(), undo_before); + assert_eq!(text_box_xfrm(&s, id), x); +} + +#[test] +fn dragging_inside_edited_text_selects_text() { + let (mut s, id, x) = editing_text_box(0.0); + let undo_before = s.doc().unwrap().history.undo.len(); + let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0)); + assert!(down.get("caret").is_some(), "{down}"); + assert_eq!(text_box_xfrm(&s, id), x); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().expect("still editing"); + assert!(t.is_range(), "{t:?}"); + assert_eq!(t.ordered(), ((0, 0), (0, 11))); + assert_eq!(st.history.undo.len(), undo_before); +} + +#[test] +fn frame_of_rotated_edited_text_follows_the_rotation() { + let (mut s, id, x) = editing_text_box(30.0); + assert_eq!(x.rot, 30.0); + let sid = deckcraft_model::ShapeId(id as u32); + // The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation. + let from = on_box(&x, x.w / 4.0, x.h + 1.0); + assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid })); + let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0)); + assert_eq!(down["hit"]["kind"], "frame"); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!(moved.rot, 30.0); + // Inside the rotated box, pressing still places the caret. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let mx = text_box_xfrm(&s, id); + let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert!(r.get("caret").is_some(), "{r}"); + assert_eq!(text_box_xfrm(&s, id), mx); + // Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press + // there leaves editing instead of moving the caret. + let off = on_box(&mx, mx.w / 2.0, -mx.h); + let off_pt = deckcraft_geom::Point::new(off.0, off.1); + assert!(mx.bounds().contains(off_pt)); + assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid })); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert!(r.get("caret").is_none(), "{r}"); + assert!(s.doc().unwrap().selection.text.is_none()); + assert_eq!(text_box_xfrm(&s, id), mx); +} + +#[test] +fn resize_handles_beat_the_frame_of_edited_text() { + let (mut s, id, x) = editing_text_box(0.0); + // The SE corner lies in the frame band, but its handle wins. + let from = on_box(&x, x.w, x.h); + let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1)); + assert_eq!(down["hit"]["kind"], "resize"); + let sized = text_box_xfrm(&s, id); + assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}"); + assert_eq!((sized.x, sized.y), (x.x, x.y)); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..98d52b10 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -85,6 +85,11 @@ pub enum Hit { id: ShapeId, text: bool, }, + /// The frame of the box whose text is being edited (a band on either side of its edge): + /// pressing there leaves text editing and drags the box, like PowerPoint. + Frame { + id: ShapeId, + }, } #[derive(Clone, Debug)] @@ -250,6 +255,13 @@ impl Session { } } } + // The frame of the text being edited: grabbing it moves the box. + if let Some(e) = edited_box(st) + && e.movable + && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + { + return Some(Hit::Frame { id: e.id }); + } // Shapes, topmost first. // Locked objects are still picked (to format them or type in them); they just don't move. for sh in st.shapes().iter().rev() { @@ -362,29 +374,25 @@ impl Session { ToolKind::Select => {} } let st = self.doc()?; - // Clicking inside the text being edited moves the caret / starts a text selection. - if let Some(t) = st.selection.text.clone() - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) + // Clicking inside the text being edited moves the caret / starts a text selection. Its + // handles and its frame (see `Hit::Frame`) win: they size, rotate or move the box. + if let Some(e) = edited_box(st) + && edit_zone(&e.xfrm, p, tol) != EditZone::Outside + && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Frame { .. })) + && let Some(sh) = st.shape(e.id) { - let x = xfrm_of(&st.doc, &st.selection, sh); - if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) - { - let pos = text_pos(st, sh, p); - let shift = mods.shift; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.caret = pos; - if !shift { - tt.anchor = pos; - } + let pos = text_pos(st, sh, p); + let shift = mods.shift; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.caret = pos; + if !shift { + tt.anchor = pos; } - })?; - self.tool.drag = Some(Drag::TextSelect { shape: t.shape }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } + } + })?; + self.tool.drag = Some(Drag::TextSelect { shape: e.id }); + return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); let st = self.doc()?; @@ -412,6 +420,17 @@ impl Session { self.begin("Adjust Shape"); self.tool.drag = Some(Drag::Adjust { id, index, orig }); } + Some(Hit::Frame { id }) => { + // Like PowerPoint: the box leaves text editing and is selected whole (a click + // without a drag stops there), and dragging moves it as one undo step. + let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -1097,6 +1116,46 @@ fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) } +/// The box whose text is being edited on the slide (not speaker notes, not a table cell). +struct EditedBox { + id: ShapeId, + xfrm: Xfrm, + /// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked. + movable: bool, +} + +fn edited_box(st: &crate::DocState) -> Option { + let t = st.selection.text.as_ref()?; + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked }) +} + +/// Where a point falls against the box of the text being edited. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditZone { + /// Well inside: the caret goes there. + Inside, + /// On the frame: a band `tol` wide on either side of the box edge. + Frame, + Outside, +} + +/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside +/// small boxes the band narrows to a quarter of the box so their text stays reachable. +fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone { + let l = x.affine().inverse() * p; + let (w, h) = (x.w.max(0.0), x.h.max(0.0)); + let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 }; + if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol { + return EditZone::Outside; + } + let inner = tol.min(w / 4.0).min(h / 4.0); + if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame } +} + fn contains_rect(outer: Rect, inner: Rect) -> bool { inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..0260e0b9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -412,7 +412,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { egui::CursorIcon::Move } } - (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, + (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, _ => egui::CursorIcon::Default, }; if resp.hovered() { From 8c5f101e683e2a4f1e006fc2a0f9efb3d1bddbe4 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:22:13 +0200 Subject: [PATCH 12/69] Progress docs: drag the frame of edited text to move the box - ui-parity.md: Direct manipulation row added; status line and history. - ROADMAP.md: progress log line. The checklist rows involved (Text boxes, In-place text editing) are already D; no gaps.md entry covered this. --- ROADMAP.md | 3 ++- docs/ui-parity.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2dba0e27..9f1128f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..c1e0bb61 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the frame of a text box being edited moves it; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -81,6 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Edit points on freeforms | yes | **no** | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | +| Drag the frame of a text box being edited to move it | yes | yes | | Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | @@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint | | 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 | From b5a864241733b4a7cc29fe50b029c2146254c72b Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:41 +0200 Subject: [PATCH 13/69] Tables: show the caret and text selection in the cell being edited Editing a table cell outlined the cell but drew no caret or selection, and Up/Down/Home/End fell back to paragraph moves, because the text layout used for drawing, hit testing and caret movement only existed for shapes. text::text_frame now lays out the edited text as it is drawn and gives the transform to the slide, for shapes and cells alike: a cell's text is laid out in its rect (merges honoured) with the cell's margins, anchor and the table style's look, through the renderer's own cell_body and cell_rects. layout_for and tools::text_pos use it, and the canvas draws the caret and selection from it. The cell outline follows merged cells. --- crates/engine/src/cmd/table.rs | 39 ++++++++++- crates/engine/src/cmd/text.rs | 67 ++++++++++++++----- crates/engine/src/tests.rs | 65 +++++++++++++++++++ crates/engine/src/tools.rs | 13 +--- crates/render/src/lib.rs | 2 +- crates/render/src/table.rs | 2 +- crates/ui-egui/src/canvas.rs | 115 ++++++++++++++++++++++++++------- 7 files changed, 247 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e9f6f164..a8476e6b 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1,10 +1,11 @@ //! Table Design and Table Layout tabs. -use deckcraft_model::{ShapeKind, Table}; +use deckcraft_geom::{Affine, Point, Rect}; +use deckcraft_model::{Shape, ShapeKind, Table}; use serde_json::{Value, json}; use super::*; -use crate::{Result, Session}; +use crate::{DocState, Result, Session}; pub fn specs() -> Vec { vec![ @@ -65,6 +66,40 @@ pub fn specs() -> Vec { ] } +/// A table as drawn on the slide: where it is, its drawn row heights and its visible cells. +#[derive(Clone, Debug)] +pub struct TableFrame { + /// Table-local (points from the table's top-left corner) → slide space, as the table is drawn. + pub affine: Affine, + pub cols: Vec, + /// Row heights as drawn: a stored height is a minimum, rows grow to fit their text. + pub heights: Vec, + /// Visible cells (merges honoured): (row, col, table-local rect). + pub cells: Vec<(usize, usize, Rect)>, +} + +/// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table). +pub fn table_frame(st: &DocState, sh: &Shape) -> Option { + let ShapeKind::Table(t) = &sh.kind else { return None }; + // The table is drawn in its own box; rows that grow past it don't move it. + let x = sh.xfrm.unwrap_or_else(|| xfrm_of(&st.doc, &st.selection, sh)); + let heights = table_drawn_heights(&st.doc, &st.selection, t); + let cells = deckcraft_render::table_cell_rects(t, &heights); + Some(TableFrame { affine: x.affine(), cols: t.cols.clone(), heights, cells }) +} + +impl TableFrame { + /// Table-local point for slide point `p`. + pub fn local(&self, p: Point) -> Point { + self.affine.inverse() * p + } + + /// Table-local rect of visible cell (`r`, `c`). + pub fn cell_rect(&self, r: usize, c: usize) -> Option { + self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2) + } +} + fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { let a = p.get(key)?.as_array()?; Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..cd69adbf 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2,10 +2,11 @@ use std::sync::Arc; +use deckcraft_geom::{Affine, Point, Rect}; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; -use deckcraft_model::{Presentation, ShapeId, ShapeKind}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use serde_json::{Value, json}; use super::*; @@ -159,20 +160,50 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option Option { - if t.notes || t.cell.is_some() { + text_frame(st, t).map(|f| f.0) +} + +/// The text being edited as it is drawn: its layout, and the transform from layout space to the +/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the +/// caret, the text selection, hit testing and caret movement all use it. `None` for notes. +pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> { + if t.notes { return None; } let sh = st.shape(t.shape)?; - let body = sh.text.as_ref()?; let ctx = ctx_for(&st.doc, &st.selection)?; + let lay = |s: &Shape, body: &TextBody, rect: Rect| { + deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }) + }; + if let Some((r, c)) = t.cell { + // A cell's text is laid out in the cell's rect with the cell's margins, anchor and the + // table style's text look, exactly as the table draws it. + let ShapeKind::Table(tb) = &sh.kind else { return None }; + let frame = super::table::table_frame(st, sh)?; + let rect = frame.cell_rect(r, c)?; + let body = deckcraft_render::table_cell_body(&ctx, tb, r, c, tb.cell(r, c)?); + let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + let l = lay(&tmp, &body, rect); + let m = deckcraft_render::text_transform(frame.affine, rect, l.rotation); + return Some((l, m)); + } + let body = sh.text.as_ref()?; let x = xfrm_of(&st.doc, &st.selection, sh); - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - Some(deckcraft_text::layout( - &ctx, - sh, - body, - &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - )) + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let l = lay(sh, body, tr); + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + Some((l, m)) +} + +/// The text position under slide point `p` in the text being edited (its shape or cell). +pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos { + match text_frame(st, t) { + Some((l, m)) => { + let h = l.hit(m.inverse() * p); + (h.para, h.ch) + } + None => (0, 0), + } } /// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and @@ -180,9 +211,16 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { - if t.notes || t.cell.is_some() { + if t.notes { return None; } + // A table cell: where the cell's text is drawn. + if t.cell.is_some() { + let (l, m) = text_frame(st, t)?; + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; + return Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))); + } let sh = st.shape(t.shape)?; let ctx = ctx_for(&st.doc, &st.selection)?; let x = xfrm_of(&st.doc, &st.selection, sh); @@ -487,11 +525,8 @@ fn commit(s: &mut Session, p: &Value) -> Result { /// the edited text isn't laid out on the slide (notes, table cells). pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; - let Some(sh) = st.shape(t.shape) else { return vec![] }; - let Some(l) = layout_for(st, t) else { return vec![] }; - let x = xfrm_of(&st.doc, &st.selection, sh); - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + // Where the text is drawn, in a shape or a table cell. + let Some((l, m)) = text_frame(st, t) else { return vec![] }; let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; let mut out = vec![]; let mut add = |a: usize, b: usize, thick: bool| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..10630add 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -401,6 +401,71 @@ fn grown_table_frame_covers_its_drawn_rows() { assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. }))); } +/// A table at (100, 100): columns `cols` wide, rows 40 pt tall, cells holding `data`. +fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft_model::ShapeId { + let rows = data.as_array().map_or(1, Vec::len); + let w: f64 = cols.iter().sum(); + let r = + s.execute("insert.table", &json!({"rows": rows, "cols": cols.len(), "rect": [100.0, 100.0, w, 40.0 * rows as f64], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + for (c, cw) in cols.iter().enumerate() { + s.execute("table.columnWidth", &json!({"id": id, "col": c, "width": cw})).unwrap(); + } + s.execute("edit.deselect", &json!({})).unwrap(); + id +} + +fn text_sel(s: &Session) -> Option { + s.doc().unwrap().selection.text.clone() +} + +/// Slide point of the caret before character `ch` of paragraph `para` in the text being edited. +fn caret_point(s: &Session, para: usize, ch: usize) -> deckcraft_geom::Point { + let st = s.doc().unwrap(); + text_point(st, st.selection.text.as_ref().unwrap(), para, ch) +} + +/// Slide point of the caret before character `ch` of paragraph `para` of text `t`. +fn text_point(st: &DocState, t: &TextSel, para: usize, ch: usize) -> deckcraft_geom::Point { + let (l, m) = cmd::text::text_frame(st, t).unwrap(); + let (x, top, bot) = l.caret(deckcraft_text::Pos { para, ch }).unwrap(); + m * deckcraft_geom::Point::new(x, (top + bot) / 2.0) +} + +#[test] +fn cell_text_is_laid_out_where_the_cell_draws_it() { + // The caret and hit testing of a cell's text use the cell's rect, not the table's. + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", ""]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap(); + let c = caret_point(&s, 0, 0); + assert!(c.x > 300.0 && c.x < 320.0 && c.y > 100.0 && c.y < 140.0, "caret of cell (0, 1) at {c:?}"); + // An empty cell still has a caret. + s.execute("text.edit", &json!({"id": id, "cell": [1, 1]})).unwrap(); + let c = caret_point(&s, 0, 0); + assert!(c.x > 300.0 && c.y > 140.0 && c.y < 180.0, "caret of empty cell (1, 1) at {c:?}"); + // Hit testing: the start and the end of "Hello world". + let st = s.doc().unwrap(); + let t = TextSel { shape: id, cell: Some((0, 0)), ..Default::default() }; + assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(101.0, 115.0)), (0, 0)); + assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11)); +} + +#[test] +fn caret_moves_by_line_in_a_wrapped_cell() { + let mut s = session(); + let id = table_at(&mut s, &[90.0, 200.0], json!([["one two three four five six seven", "b"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0], "at": [0, 0]})).unwrap(); + s.execute("text.move", &json!({"to": "down"})).unwrap(); + let down = text_sel(&s).unwrap().caret; + assert!(down.0 == 0 && down.1 > 0, "down goes to the next line of the paragraph: {down:?}"); + s.execute("text.move", &json!({"to": "lineEnd"})).unwrap(); + let end = text_sel(&s).unwrap().caret; + assert!(end.1 > down.1 && end.1 < 33, "end of the second line: {end:?}"); + s.execute("text.move", &json!({"to": "up"})).unwrap(); + assert!(text_sel(&s).unwrap().caret.1 < down.1); +} + #[test] fn right_to_left_paragraphs() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..c684dc68 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1273,18 +1273,7 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option (usize, usize) { - let x = xfrm_of(&st.doc, &st.selection, sh); - let t = TextSel { shape: sh.id, ..Default::default() }; - match cmd::text::layout_for(st, &t) { - Some(l) => { - // The text's own transform: flips don't mirror text, vertical text is turned. - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p; - let h = l.hit(local); - (h.para, h.ch) - } - None => (0, 0), - } + cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p) } /// Row heights of table `t` as drawn: rows grow to fit their text. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 47a0f1e2..6b084ecf 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -46,7 +46,7 @@ pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; -pub use table::row_heights as table_row_heights; +pub use table::{cell_body as table_cell_body, cell_rects as table_cell_rects, row_heights as table_row_heights}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). pub const MAX_SIDE: u32 = 16_000; diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 685df6f9..929a87a6 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -224,7 +224,7 @@ fn edges(sizes: &[f64]) -> Vec { } /// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold. -pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody { +pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody { let lk = look(rctx, t, r, c); let mut body = cell.text.clone(); let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..e853130c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -486,21 +486,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } } - // Caret / text selection. - if let Some(ts) = &editing - && !ts.notes - && ts.cell.is_none() - && let Some(sh) = st.shape(ts.shape) - && let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts) - { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - // Where the text is drawn: flips don't mirror it, vertical text is turned. - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - let (s0, s1) = ts.ordered(); - for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) { - let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q)); - painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE)); + // Caret / text selection, in a shape or a table cell. + if let Some(ts) = &editing { + for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { + painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE)); } // An input method's composition: underlined in place, thicker under the part being converted. for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { @@ -515,18 +504,14 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } } - // Table cell editing: outline the cell. + // Table cell editing: outline the cell (merged cells as drawn). if let Some(ts) = &editing && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) - && let deckcraft_model::ShapeKind::Table(tb) = &sh.kind + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + && let Some(cr) = f.cell_rect(r, c) { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - let heights = deckcraft_engine::tools::table_rows(st, tb); - let y0: f64 = heights.iter().take(r).sum(); - let x0: f64 = tb.cols.iter().take(c).sum(); - let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0)); - let a = x.affine(); - let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q)); + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] + .map(|q| xf.to_screen(f.affine * q)); poly(painter, pts, Stroke::new(2.0, t.accent)); } // Smart guides, marquee, previews. @@ -588,6 +573,24 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } +/// The text selection (quads) of the text being edited, in slide space: in a shape or a table +/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`. +pub(crate) struct TextMarks { + pub selection: Vec<[Point; 4]>, +} + +pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option { + let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?; + let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 }; + let (s0, s1) = ts.ordered(); + let selection = l + .selection_rects(pos(s0), pos(s1)) + .into_iter() + .map(|r| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| m * q)) + .collect(); + Some(TextMarks { selection }) +} + fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { p.circle_filled(c, 6.0, t.handle_fill); p.add(egui::Shape::Path(egui::epaint::PathShape { @@ -697,3 +700,67 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_engine::Session; + + use super::*; + use crate::Services; + + fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) { + let mut s = Session::with_new(); + let data = json!([["Hello world", "b"], ["c", ""]]); + let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("edit.deselect", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(s, Services::default()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + harness.run_steps(2); + (harness, id) + } + + /// Screen position of slide point (x, y). + fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 { + Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y)) + } + + /// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click). + fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + let t = harness.ctx.input(|i| i.time) + 0.05; + harness.input_mut().time = Some(t); + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } + } + + /// Double-clicking a table cell edits it with the caret drawn in that cell. + #[test] + fn double_click_in_a_table_cell_shows_the_caret_there() { + let (mut harness, id) = app_with_table(); + // The empty cell (1, 1) spans x 300..500, y 140..180. + let p = screen(harness.state(), 400.0, 160.0); + harness.hover_at(p); + harness.step(); + click(&mut harness, p); + click(&mut harness, p); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().expect("editing the cell"); + assert_eq!((ts.shape, ts.cell), (id, Some((1, 1)))); + assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range"); + let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret"); + for q in [top, bottom] { + assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); + } + } +} From 70314fa82822cf37f9b6503de823eee563e9420c Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:42 +0200 Subject: [PATCH 14/69] Tables: clicks place the caret in the clicked cell - A double-click in a cell puts the caret where clicked and selects the word there, as in shapes (it used to put the caret at the end). - While a cell is edited, a click in the same table moves the caret to the clicked cell and position; a drag selects text within the cell and Shift+click extends only within it. - A click in an already selected table enters the clicked cell at the click; dragging the table still moves it. - The release that follows a double-click no longer re-enters the text at the click, which collapsed the word just selected (shapes too). Cells are found from the drawn cell rects, so merged cells and rows that grew to fit their text are hit where they are drawn. --- crates/engine/src/cmd/table.rs | 20 +++++ crates/engine/src/tests.rs | 103 +++++++++++++++++++++++ crates/engine/src/tools.rs | 147 ++++++++++++++++++--------------- crates/ui-egui/src/canvas.rs | 11 ++- 4 files changed, 212 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index a8476e6b..4c46eb5f 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -89,6 +89,14 @@ pub fn table_frame(st: &DocState, sh: &Shape) -> Option { } impl TableFrame { + pub fn width(&self) -> f64 { + self.cols.iter().map(|w| w.max(0.0)).sum() + } + + pub fn height(&self) -> f64 { + self.heights.iter().map(|h| h.max(0.0)).sum() + } + /// Table-local point for slide point `p`. pub fn local(&self, p: Point) -> Point { self.affine.inverse() * p @@ -98,6 +106,18 @@ impl TableFrame { pub fn cell_rect(&self, r: usize, c: usize) -> Option { self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2) } + + /// The visible cell at table-local point `p` (points outside the table go to the nearest cell). + pub fn cell_at(&self, p: Point) -> Option<(usize, usize)> { + let q = Point::new(p.x.clamp(0.0, self.width().max(0.0)), p.y.clamp(0.0, self.height().max(0.0))); + let inside = |r: &Rect| q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1; + // Edges are shared: prefer the cell the point is strictly inside. + self.cells + .iter() + .find(|(_, _, r)| q.x >= r.x0 && q.x < r.x1 && q.y >= r.y0 && q.y < r.y1) + .or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r))) + .map(|(r, c, _)| (*r, *c)) + } } fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 10630add..69d3bc68 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -415,6 +415,17 @@ fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft id } +fn table_of(s: &Session, id: deckcraft_model::ShapeId) -> deckcraft_model::Table { + match &s.doc().unwrap().shape(id).unwrap().kind { + deckcraft_model::ShapeKind::Table(t) => t.clone(), + k => panic!("not a table: {k:?}"), + } +} + +fn tev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 } +} + fn text_sel(s: &Session) -> Option { s.doc().unwrap().selection.text.clone() } @@ -451,6 +462,86 @@ fn cell_text_is_laid_out_where_the_cell_draws_it() { assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11)); } +#[test] +fn double_click_in_a_cell_selects_the_word_clicked() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + // Where "world" is drawn. + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let p = caret_point(&s, 0, 8); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + // As the UI sends it: press, release, press, double-click (on release), then the release. + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + assert!(text_sel(&s).is_none(), "a click on an unselected table selects it"); + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))), "the word under the double-click"); + // The control channel sends the double-click alone. + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + assert_eq!(text_sel(&s).map(|t| t.ordered()), Some(((0, 6), (0, 11)))); +} + +#[test] +fn clicks_while_editing_a_cell_move_the_caret_between_cells() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "Second cell"], ["c", "d"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap(); + let p6 = caret_point(&s, 0, 6); + let p11 = caret_point(&s, 0, 11); + s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap(); + // A click in another cell of the table moves the caret there, at the click. + s.pointer(tev(PointerKind::Down, p6.x, p6.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.shape, t.cell, t.caret, t.anchor), (id, Some((0, 1)), (0, 6), (0, 6))); + // Dragging selects within that cell, even past its edge. + s.pointer(tev(PointerKind::Drag, p11.x, p11.y)).unwrap(); + s.pointer(tev(PointerKind::Drag, 600.0, p11.y)).unwrap(); + s.pointer(tev(PointerKind::Up, 600.0, p11.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 1)), ((0, 6), (0, 11)))); + // Shift+click in another cell starts a new caret there instead of extending across cells. + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(PointerEvent { mods: shift, ..tev(PointerKind::Down, 150.0, 165.0) }).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 165.0)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.is_range()), (Some((1, 0)), false)); + // Typing goes into the clicked cell. + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + assert_eq!(table_of(&s, id).cell(1, 0).unwrap().text.text(), "cX"); + // A click outside the table stops editing. + s.pointer(tev(PointerKind::Down, 50.0, 400.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 50.0, 400.0)).unwrap(); + assert!(text_sel(&s).is_none()); +} + +#[test] +fn a_click_in_a_selected_table_enters_the_clicked_cell() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "Hello world"]])); + s.pointer(tev(PointerKind::Down, 350.0, 160.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 350.0, 160.0)).unwrap(); + assert_eq!((s.doc().unwrap().selection.shapes.clone(), text_sel(&s)), (vec![id], None), "the first click selects the table"); + let st = s.doc().unwrap(); + let p = text_point(st, &TextSel { shape: id, cell: Some((1, 1)), ..Default::default() }, 0, 6); + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.caret), (Some((1, 1)), (0, 6)), "caret at the click"); + // Dragging a selected table still moves it. + s.execute("text.exit", &json!({})).unwrap(); + s.pointer(tev(PointerKind::Down, 150.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 170.0, 130.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 170.0, 130.0)).unwrap(); + assert!(text_sel(&s).is_none()); + assert_ne!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().x, 100.0); +} + #[test] fn caret_moves_by_line_in_a_wrapped_cell() { let mut s = session(); @@ -466,6 +557,18 @@ fn caret_moves_by_line_in_a_wrapped_cell() { assert!(text_sel(&s).unwrap().caret.1 < down.1); } +#[test] +fn rotated_tables_hit_the_cell_under_the_pointer() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]])); + s.execute("shape.rotate", &json!({"ids": [id], "deg": 90})).unwrap(); + let st = s.doc().unwrap(); + let f = cmd::table::table_frame(st, st.shape(id).unwrap()).unwrap(); + let p = f.affine * deckcraft_geom::Point::new(150.0, 60.0); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1))); +} + #[test] fn right_to_left_paragraphs() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index c684dc68..ab051c7a 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -361,30 +361,10 @@ impl Session { } ToolKind::Select => {} } - let st = self.doc()?; // Clicking inside the text being edited moves the caret / starts a text selection. - if let Some(t) = st.selection.text.clone() - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) - { - let x = xfrm_of(&st.doc, &st.selection, sh); - if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) - { - let pos = text_pos(st, sh, p); - let shift = mods.shift; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.caret = pos; - if !shift { - tt.anchor = pos; - } - } - })?; - self.tool.drag = Some(Drag::TextSelect { shape: t.shape }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } + if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { + self.tool.drag = Some(Drag::TextSelect { shape }); + return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); let st = self.doc()?; @@ -432,8 +412,10 @@ impl Session { let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); // A click (no drag) on text puts the caret there; unselected text boxes and - // placeholders take the caret straight away like PowerPoint. - let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p)); + // placeholders take the caret straight away like PowerPoint. A click in a selected + // table puts the caret in the clicked cell. + let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); + let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p)); let ids2 = ids.clone(); self.select(|_, sel| { sel.shapes = ids2; @@ -630,9 +612,10 @@ impl Session { self.tool.preview = Some(x); } Drag::TextSelect { shape } => { + // The selection stays in the shape or table cell where it started. let st = self.doc()?; - if let Some(sh) = st.shape(shape) { - let pos = text_pos(st, sh, p); + if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { + let pos = cmd::text::text_pos_at(st, t, p); self.select(|_, sel| { if let Some(t) = sel.text.as_mut() { t.caret = pos; @@ -669,11 +652,8 @@ impl Session { let mut out = Value::Null; match drag { Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { - // Plain click on text: caret at the click. - let st = self.doc()?; - if let Some(sh) = st.shape(id) { - let pos = text_pos(st, sh, at); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; + // Plain click on text: caret at the click (in a table, in the clicked cell). + if self.edit_text_at(id, at)? { out = json!({"editing": id}); } } @@ -792,8 +772,12 @@ impl Session { self.tool.sticky = false; return Ok(Value::Null); } - let st = self.doc()?; - if st.selection.text.is_some() { + // The double-click's second press started a click (caret on release): this replaces it. + if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { + self.tool.drag = None; + } + // In the text being edited: select the word double-clicked. + if self.place_caret(p, tol, false)?.is_some() { return self.execute("text.selectWord", &json!({})); } match self.hit_test(p, tol) { @@ -801,18 +785,12 @@ impl Session { let st = self.doc()?; let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; match &sh.kind { - ShapeKind::Table(_) => { - let x = xfrm_of(&st.doc, &st.selection, &sh); - let local = x.affine().inverse() * p; - if let ShapeKind::Table(t) = &sh.kind { - let cell = deckcraft_render_cell(t, &table_rows(st, t), local); - return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]})); + ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { + // Caret at the double-click (in a table, in the clicked cell), then select + // the word there. + if !self.edit_text_at(id, p)? { + return Ok(Value::Null); } - Ok(Value::Null) - } - ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { - let pos = text_pos(st, &sh, p); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; self.execute("text.selectWord", &json!({})) } ShapeKind::Group { .. } => Ok(Value::Null), @@ -826,6 +804,62 @@ impl Session { } } + /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection + /// grows to it). In a table being edited, a click in another cell moves the caret into that + /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text + /// (or is on a handle or a table border). + fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { + let st = self.doc()?; + let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) }; + let Some(sh) = st.shape(t.shape) else { return Ok(None) }; + let x = xfrm_of(&st.doc, &st.selection, sh); + if !x.bounds().inflate(tol, tol).contains(p) + || matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) + { + return Ok(None); + } + let old_cell = t.cell; + if old_cell.is_some() + && let Some(f) = cmd::table::table_frame(st, sh) + { + t.cell = f.cell_at(f.local(p)).or(old_cell); + } + let pos = cmd::text::text_pos_at(st, &t, p); + // A selection can't span cells: a click in another cell starts afresh. + let extend = extend && t.cell == old_cell; + let cell = t.cell; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.cell = cell; + tt.caret = pos; + if !extend { + tt.anchor = pos; + } + } + })?; + Ok(Some((t.shape, pos))) + } + + /// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell + /// at `p`. Returns whether editing started. + fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(false) }; + let params = match cmd::table::table_frame(st, sh) { + Some(f) => { + let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) }; + let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p); + json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]}) + } + None => { + let pos = text_pos(st, sh, p); + json!({"id": id, "at": [pos.0, pos.1]}) + } + }; + self.execute("text.edit", ¶ms)?; + Ok(true) + } + fn is_picture(&self, id: ShapeId) -> bool { self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) } @@ -1281,29 +1315,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec cmd::table_drawn_heights(&st.doc, &st.selection, t) } -/// Which table cell contains a table-local point (`heights` from [`table_rows`]). -fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) { - let mut y = 0.0; - let mut row = heights.len().saturating_sub(1); - for (i, h) in heights.iter().enumerate() { - if p.y < y + h { - row = i; - break; - } - y += h; - } - let mut x = 0.0; - let mut col = t.cols.len().saturating_sub(1); - for (i, w) in t.cols.iter().enumerate() { - if p.x < x + w { - col = i; - break; - } - x += w; - } - (row, col) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e853130c..d3181247 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -743,7 +743,7 @@ mod tests { } } - /// Double-clicking a table cell edits it with the caret drawn in that cell. + /// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked. #[test] fn double_click_in_a_table_cell_shows_the_caret_there() { let (mut harness, id) = app_with_table(); @@ -762,5 +762,14 @@ mod tests { for q in [top, bottom] { assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); } + // A click in another cell moves the caret there. + let p = screen(harness.state(), 110.0, 120.0); + click(&mut harness, p); + harness.run_steps(1); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().unwrap(); + assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0))); + let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); + assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); } } From 456c5c53739b44faefcbdcbdea3d2055b1d82b0b Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:43 +0200 Subject: [PATCH 15/69] Tables: drag row and column borders to resize them Hovering a table border shows a resize cursor (new hit kinds TableColumn and TableRow) and dragging it resizes, one undo step per drag, as in PowerPoint: - an inner column border moves between its two columns and the table keeps its width; - the right edge widens or narrows the last column and the table; - a row's bottom border (the last one is the table's bottom edge) sets that row's height, which stays a minimum: rows still grow to fit their text. Columns stop at 12 pt. Borders hidden inside merged cells aren't hit; the left and top edges still move the table. A border drag while a cell is edited keeps editing it. The drag goes through table.columnWidth, which takes a new keepTotal option (the next column absorbs the change), and table.rowHeight. Hit testing is done in the table's own frame, so rotated tables resize along their axes. --- crates/engine/src/cmd/table.rs | 76 ++++++++++++++++++++++- crates/engine/src/tests.rs | 107 +++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 76 ++++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 25 ++++++++ 4 files changed, 280 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 4c46eb5f..fd39faac 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -60,12 +60,23 @@ pub fn specs() -> Vec { cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute( s, p, false )), - cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width), + cmd!( + "table.columnWidth", + "Width", + ["Table Layout", "Cell Size"], + None, + "{col, width: pt, keepTotal?: bool (the next column absorbs the change), id?}", + has_selection, + column_width + ), cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height), cmd!(noundo "table.selectCells", "Select Cells", [], None, "{from: [r,c], to: [r,c], id?}", has_selection, select_cells), ] } +/// Narrowest a column gets when its border is dragged (points). +pub const MIN_COLUMN_WIDTH: f64 = 12.0; + /// A table as drawn on the slide: where it is, its drawn row heights and its visible cells. #[derive(Clone, Debug)] pub struct TableFrame { @@ -78,6 +89,15 @@ pub struct TableFrame { pub cells: Vec<(usize, usize, Rect)>, } +/// A border between table rows or columns that can be dragged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableBorder { + /// The right edge of this column (the last one is the table's right edge). + Column(usize), + /// The bottom edge of this row (the last one is the table's bottom edge). + Row(usize), +} + /// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table). pub fn table_frame(st: &DocState, sh: &Shape) -> Option { let ShapeKind::Table(t) = &sh.kind else { return None }; @@ -118,6 +138,38 @@ impl TableFrame { .or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r))) .map(|(r, c, _)| (*r, *c)) } + + /// The draggable border within `tol` of table-local point `p`: the inner borders and the right + /// and bottom edges (the left and top edges move the table). Borders hidden inside merged + /// cells don't count. + pub fn border_at(&self, p: Point, tol: f64) -> Option { + let (w, h) = (self.width(), self.height()); + if !(p.x >= -tol && p.y >= -tol && p.x <= w + tol && p.y <= h + tol) { + return None; + } + let q = Point::new(p.x.clamp(0.0, w), p.y.clamp(0.0, h)); + let mut best: Option<(f64, TableBorder)> = None; + let mut x = 0.0; + for (i, cw) in self.cols.iter().enumerate() { + x += cw.max(0.0); + let d = (p.x - x).abs(); + // A cell ending at this edge at this height shows the border. + let shown = self.cells.iter().any(|(_, _, r)| (r.x1 - x).abs() < 1e-6 && q.y >= r.y0 && q.y <= r.y1); + if d <= tol && shown && best.is_none_or(|b| d < b.0) { + best = Some((d, TableBorder::Column(i))); + } + } + let mut y = 0.0; + for (i, rh) in self.heights.iter().enumerate() { + y += rh.max(0.0); + let d = (p.y - y).abs(); + let shown = self.cells.iter().any(|(_, _, r)| (r.y1 - y).abs() < 1e-6 && q.x >= r.x0 && q.x <= r.x1); + if d <= tol && shown && best.is_none_or(|b| d < b.0) { + best = Some((d, TableBorder::Row(i))); + } + } + best.map(|b| b.1) + } } fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { @@ -288,9 +340,27 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result { fn column_width(s: &mut Session, p: &Value) -> Result { let (_, c) = at(s, p); let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0); + let keep_total = bool_or(p, "keepTotal", false); with_table(s, p, "table.columnWidth", move |t| { - if let Some(x) = t.cols.get_mut(c) { - *x = w; + // Keeping the table's width, the column to the right gives or takes the difference (dragging + // an inner border). The last column has none: the table gets wider or narrower. + let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite()); + match pair { + Some(pair) if keep_total => { + let min = MIN_COLUMN_WIDTH.min(pair / 2.0); + let w = w.max(min).min(pair - min); + if let Some(x) = t.cols.get_mut(c) { + *x = w; + } + if let Some(x) = t.cols.get_mut(c + 1) { + *x = pair - w; + } + } + _ => { + if let Some(x) = t.cols.get_mut(c) { + *x = w; + } + } } Ok(()) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 69d3bc68..772a42d0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -557,6 +557,113 @@ fn caret_moves_by_line_in_a_wrapped_cell() { assert!(text_sel(&s).unwrap().caret.1 < down.1); } +#[test] +fn table_borders_are_hit_where_they_are_drawn() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + let hit = |s: &Session, x: f64, y: f64| s.hit_test(deckcraft_geom::Point::new(x, y), 3.0); + assert_eq!(hit(&s, 201.0, 125.0), Some(tools::Hit::TableColumn { id, col: 0 })); + assert_eq!(hit(&s, 400.0, 125.0), Some(tools::Hit::TableColumn { id, col: 2 }), "the right edge"); + assert_eq!(hit(&s, 150.0, 139.0), Some(tools::Hit::TableRow { id, row: 0 })); + assert_eq!(hit(&s, 150.0, 181.0), Some(tools::Hit::TableRow { id, row: 1 }), "the bottom edge"); + assert!(matches!(hit(&s, 100.0, 125.0), Some(tools::Hit::Shape { .. })), "the left edge moves the table"); + assert!(matches!(hit(&s, 150.0, 125.0), Some(tools::Hit::Shape { .. }))); + // Hovering a border shows it (the UI picks the resize cursor from the hover). + s.pointer(tev(PointerKind::Move, 300.0, 170.0)).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::TableColumn { id, col: 1 })); + // A border inside a merged cell isn't there. + s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap(); + assert!(matches!(hit(&s, 200.0, 120.0), Some(tools::Hit::Shape { .. }))); + assert_eq!(hit(&s, 200.0, 160.0), Some(tools::Hit::TableColumn { id, col: 0 })); +} + +#[test] +fn dragging_column_borders_resizes_columns() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + let undo_depth = s.doc().unwrap().history.undo.len(); + // An inner border: the two columns share the change, the table keeps its width. + s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 215.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 230.0, 128.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 230.0, 128.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![130.0, 70.0, 100.0]); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 300.0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step per drag"); + // Columns don't collapse. + s.pointer(tev(PointerKind::Down, 230.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 500.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 500.0, 125.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH, 100.0]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 100.0]); + // The right edge: the last column and the table get wider. + s.pointer(tev(PointerKind::Down, 400.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 450.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 450.0, 120.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 150.0]); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 350.0); + // A click on a border without dragging changes nothing. + let before = s.doc().unwrap().doc.clone(); + s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 200.0, 125.0)).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); +} + +#[test] +fn dragging_a_row_border_sets_the_row_height() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "d"]])); + let drawn = |s: &Session| { + let st = s.doc().unwrap(); + tools::table_rows(st, &table_of(s, id)) + }; + s.pointer(tev(PointerKind::Down, 150.0, 140.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, 170.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 170.0)).unwrap(); + assert_eq!(drawn(&s), vec![70.0, 40.0]); + let st = s.doc().unwrap(); + assert_eq!(cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()).h, 110.0, "the table grows"); + // Rows still fit their text: dragging the border up stops at the text. + s.pointer(tev(PointerKind::Down, 150.0, 170.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, 60.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 60.0)).unwrap(); + let h = drawn(&s)[0]; + assert!(h > 10.0 && h < 40.0, "row 0 shrinks to its text: {h}"); + // The bottom edge sizes the last row. + let bottom = 100.0 + drawn(&s).iter().sum::(); + s.pointer(tev(PointerKind::Down, 150.0, bottom)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, bottom + 20.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, bottom + 20.0)).unwrap(); + assert_eq!(drawn(&s)[1], 60.0); +} + +#[test] +fn dragging_a_border_while_editing_a_cell_keeps_editing() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1]})).unwrap(); + s.pointer(tev(PointerKind::Down, 200.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 180.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 180.0, 120.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![80.0, 120.0]); + assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((0, 1))); +} + +#[test] +fn column_width_can_keep_the_table_width() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"]])); + s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 150.0, "keepTotal": true})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 150.0, 50.0]); + s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 1000.0, "keepTotal": true})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH]); + // The last column has no neighbour: the table changes width. + s.execute("table.columnWidth", &json!({"id": id, "col": 2, "width": 80.0, "keepTotal": true})).unwrap(); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 100.0 + (200.0 - cmd::table::MIN_COLUMN_WIDTH) + 80.0); +} + #[test] fn rotated_tables_hit_the_cell_under_the_pointer() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ab051c7a..7e062667 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -85,6 +85,16 @@ pub enum Hit { id: ShapeId, text: bool, }, + /// The right edge of table column `col` (dragging it resizes the column). + TableColumn { + id: ShapeId, + col: usize, + }, + /// The bottom edge of table row `row` (dragging it resizes the row). + TableRow { + id: ShapeId, + row: usize, + }, } #[derive(Clone, Debug)] @@ -129,6 +139,18 @@ enum Drag { TextSelect { shape: ShapeId, }, + /// Dragging a table border: the right edge of column `index`, or the bottom edge of row + /// `index`. `start` is the table-local press point, `size` the column's width or the row's + /// drawn height then, `frame` where the table was drawn. + TableBorder { + id: ShapeId, + column: bool, + index: usize, + start: Point, + size: f64, + frame: Affine, + moved: bool, + }, Ink { points: Vec<(f64, f64, f32)>, }, @@ -369,6 +391,8 @@ impl Session { let hit = self.hit_test(p, tol); let st = self.doc()?; match hit { + Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, + Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Size"); @@ -623,6 +647,26 @@ impl Session { })?; } } + Drag::TableBorder { id, column, index, start, size, frame, moved } => { + let local = frame.inverse() * p; + let d = if column { local.x - start.x } else { local.y - start.y }; + if !moved && d.abs() < tol.max(1.0) { + return Ok(Value::Null); + } + if !moved { + self.begin(if column { "Column Width" } else { "Row Height" }); + } + // An inner column border moves between its two columns (the table keeps its + // width); the right edge widens the last column and the table. A row's height is + // a minimum: rows still grow to fit their text. + if column { + let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); + self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; + } else { + self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); + } Drag::Freeform { mode, mut points, pressed } => { // Dragging scribbles (except for curves, which only take clicked points). if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { @@ -814,7 +858,10 @@ impl Session { let Some(sh) = st.shape(t.shape) else { return Ok(None) }; let x = xfrm_of(&st.doc, &st.selection, sh); if !x.bounds().inflate(tol, tol).contains(p) - || matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) + || matches!( + self.hit_test(p, tol), + Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::TableColumn { .. } | Hit::TableRow { .. }) + ) { return Ok(None); } @@ -860,6 +907,24 @@ impl Session { Ok(true) } + /// Press on a table border: dragging it resizes the column or row. Editing a cell of the + /// table goes on; otherwise the table becomes the selection. + fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> { + let st = self.doc()?; + let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) }; + let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0); + let (start, frame) = (f.local(p), f.affine); + if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) { + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false }); + Ok(()) + } + fn is_picture(&self, id: ShapeId) -> bool { self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) } @@ -1244,6 +1309,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; + if near && let Some(f) = cmd::table::table_frame(st, sh) { + match f.border_at(f.local(p), tol) { + Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }), + Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }), + None => {} + } + } match &sh.kind { ShapeKind::Group { children, child } => { // A group is hit when any child is hit; the group is what gets selected. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d3181247..bdce269b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -404,6 +404,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, + (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, + (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, (_, Some(Hit::Shape { text: true, .. })) => { if app.session.active().is_some_and(|d| d.selection.text.is_some()) { @@ -772,4 +774,27 @@ mod tests { let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); } + + /// Table borders show a resize cursor and drag to resize columns. + #[test] + fn table_borders_resize_with_the_pointer() { + let (mut harness, id) = app_with_table(); + let p = screen(harness.state(), 300.0, 120.0); + harness.hover_at(p); + harness.step(); + harness.step(); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn); + let q = screen(harness.state(), 340.0, 120.0); + harness.drag_at(p); + harness.step(); + for k in 1..=4 { + harness.hover_at(p + (q - p) * k as f32 / 4.0); + harness.step(); + } + harness.drop_at(q); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") }; + assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); + } } From d63d250d584882c9c1900d9e8c8a3a9554f8ac99 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:43 +0200 Subject: [PATCH 16/69] Tables: Tab and Shift+Tab skip cells hidden under a merge Tab moved into the cells covered by a merged cell, editing text that is never drawn. It now goes to the next visible cell; Tab in the last visible cell still adds a row, and Shift+Tab in the first cell stays. --- crates/engine/src/keys.rs | 23 +++++++++++++++-------- crates/engine/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 665cc22e..2582dd97 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -285,15 +285,22 @@ impl Session { let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) }; let Some((r, c)) = t.cell else { return Ok(Value::Null) }; let Some(deckcraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) }; - let (nr, nc) = (tb.n_rows(), tb.n_cols()); + let (nr, nc) = (tb.n_rows(), tb.n_cols().max(1)); let idx = r * nc + c; - if !back && idx + 1 >= nr * nc { - // Tab in the last cell adds a row. - self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?; - return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]})); - } - let ni = if back { idx.saturating_sub(1) } else { idx + 1 }; - self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?; + // Cells hidden under a merged cell are skipped. + let visible = |i: &usize| tb.cell(i / nc, i % nc).is_some_and(|x| !x.is_covered()); + let next = if back { (0..idx).rev().find(visible) } else { (idx + 1..nr * nc).find(visible) }; + let ni = match next { + Some(ni) => ni, + None if !back => { + // Tab in the last cell adds a row. + let last = nr.saturating_sub(1); + self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": last}))?; + return self.execute("text.edit", &json!({"id": t.shape, "cell": [last + 1, 0]})); + } + None => idx, + }; + self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc, ni % nc]}))?; self.execute("text.selectAll", &json!({})) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 772a42d0..e252f2eb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -676,6 +676,25 @@ fn rotated_tables_hit_the_cell_under_the_pointer() { assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1))); } +#[test] +fn tab_moves_between_cells_skipping_merged_ones() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let cell = |s: &Session| text_sel(s).and_then(|t| t.cell); + let shift = Mods { shift: true, ..Default::default() }; + s.key("Tab", Mods::default()).unwrap(); + assert_eq!(cell(&s), Some((0, 2)), "the cell under the merge is skipped"); + s.key("Tab", shift).unwrap(); + assert_eq!(cell(&s), Some((0, 0))); + s.key("Tab", shift).unwrap(); + assert_eq!(cell(&s), Some((0, 0)), "Shift+Tab in the first cell stays"); + s.execute("text.edit", &json!({"id": id, "cell": [1, 2]})).unwrap(); + s.key("Tab", Mods::default()).unwrap(); + assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row"); +} + #[test] fn right_to_left_paragraphs() { let mut s = session(); From 8392081976aca3fe485c1d6046da971d4fd19104 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:44 +0200 Subject: [PATCH 17/69] Progress docs: table cell editing and border resizing --- ROADMAP.md | 4 +++- docs/ui-parity.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2dba0e27..347a3d34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log: table cell editing and border resizing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | @@ -147,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: table cell editing and border resizing | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..1451e6d2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (table cells: caret, clicks between cells, border resizing; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -81,6 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Edit points on freeforms | yes | **no** | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | +| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells | | Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | @@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize | | 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 | From 44a032d3de345351aef912b231bda505c663dae9 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:52:59 +0200 Subject: [PATCH 18/69] Tables: editing tests for tables with a file-defined style A table whose style the file defines (its parts and background drawn since #292 and #294) edits like the others: the caret is laid out with the file style's lettering (a regular header where the built-in style bolds it), a double-click selects the word drawn there, a click moves the caret to the clicked cell and a border drag resizes the columns while the cell stays edited. --- crates/engine/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e252f2eb..600520b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1129,3 +1129,56 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// Tables whose style the file defines (#292, #294: its look and background drawn) edit like the +/// others: the caret is laid out with the file style's lettering, clicks and double-clicks land +/// in the cell where it is drawn, and borders still resize. +#[test] +fn tables_with_a_file_defined_style_edit_and_resize() { + use deckcraft_model::style::{ColorRef, Fill}; + use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef}; + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let built_in = caret_point(&s, 0, 8); + s.execute("text.exit", &json!({})).unwrap(); + // The table's style, defined by the file: a plain (not bold) header, a filled body, a + // background. + { + let style = table_of(&s, id).style; + let d = s.doc_mut().unwrap(); + let doc = std::sync::Arc::make_mut(&mut d.doc); + let solid = |r, g, b| Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)))); + doc.table_styles.push(TableStyleDef { + id: style, + name: "File style".into(), + parts: vec![ + (TablePart::WholeTbl, TablePartStyle { fill: solid(230, 240, 250), ..Default::default() }), + (TablePart::FirstRow, TablePartStyle { fill: solid(40, 60, 90), bold: Some(false), ..Default::default() }), + ], + background: Some(TablePartStyle { fill: solid(200, 200, 200), ..Default::default() }), + raw: String::new(), + }); + } + s.execute("edit.deselect", &json!({})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let p = caret_point(&s, 0, 8); + assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}"); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + // A double-click selects the word drawn there. + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11)))); + // A click in another cell moves the caret there. + s.pointer(tev(PointerKind::Down, 400.0, 160.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 400.0, 160.0)).unwrap(); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); + // Border drag, while editing: the columns resize, editing goes on. + s.pointer(tev(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 320.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 330.0, 121.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 330.0, 121.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); +} From 503ee5d67e3014ae64496c5a3094e0538acfdcc0 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 13 Oct 2026 12:38:57 +0300 Subject: [PATCH 19/69] A tabbed slide body is an outline, and --help exits (#360) slide.new kept a leading tab as text, while text.set already turned it into a bullet level. The desktop --help flag was opened as a missing file. Co-authored-by: Cursor --- apps/deckcraft/src/main.rs | 84 +++++++++++++++++++++++++++------- crates/engine/src/cmd/slide.rs | 7 ++- crates/engine/src/cmd/text.rs | 10 ++-- crates/engine/src/tests.rs | 28 ++++++++++++ crates/model/src/text.rs | 15 ++++++ 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bb..6499e8e1 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -1,6 +1,7 @@ //! DeckCraft desktop app. //! //! Usage: `deckcraft [--control ] [--sample] [--show] [files…]` +//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits. //! //! `--control ` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. @@ -129,6 +130,41 @@ fn services() -> Services { const APP_ID: &str = "ai.storyteller.deckcraft"; +const USAGE: &str = "\ +Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...] + + --control PORT JSON control server on 127.0.0.1:PORT + --sample open the sample presentation + --show start the slide show + --help, -h print this help and exit + --version, -V print the version and exit +"; + +enum Startup { + Help, + Version, + Run { control_port: Option, files: Vec, sample: bool, show: bool }, +} + +/// `--help` and `--version` leave before the window and the log file. +fn startup(mut control_port: Option, args: impl IntoIterator) -> Startup { + let mut files = Vec::new(); + let mut sample = false; + let mut show = false; + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--sample" => sample = true, + "--show" => show = true, + "--version" | "-V" => return Startup::Version, + "--help" | "-h" => return Startup::Help, + _ => files.push(a), + } + } + Startup::Run { control_port, files, sample, show } +} + fn app_icon() -> Option { let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png"); eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() @@ -138,26 +174,21 @@ fn main() -> eframe::Result { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. let logger = logging::install(); - let mut control_port: Option = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); - let mut files = Vec::new(); - let mut sample = false; - let mut show = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), - "--sample" => sample = true, - "--show" => show = true, - "--version" => { + let (control_port, files, sample, show) = + match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) { + Startup::Help => { + print!("{USAGE}"); + return Ok(()); + } + Startup::Version => { println!("deckcraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - _ => files.push(a), - } - } + Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show), + }; log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); - // The log file lives under the settings directory; opened after the arguments, so `--version` - // leaves no file behind. Records logged until now are written to it first. + // The log file lives under the settings directory; opened after the arguments, so `--help` and + // `--version` leave no file behind. Records logged until now are written to it first. if let Some(logger) = logger { match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { Some(Ok(path)) => log::info!("log file {}", path.display()), @@ -228,3 +259,24 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::{Startup, startup}; + + #[test] + fn help_exits_instead_of_opening_a_file() { + assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help)); + assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version)); + let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]); + assert!(matches!(launch, Startup::Run { .. })); + if let Startup::Run { control_port, files, sample, show } = launch { + assert_eq!(control_port, Some(4242)); + assert_eq!(files, ["talk.pptx".to_string()]); + assert!(sample); + assert!(!show); + } + } +} diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..41878b34 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -108,7 +108,12 @@ fn new(s: &mut Session, p: &Value) -> Result { if let Some(b) = str_param(p, "body") && 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(b)); + // Same outline rule as text.set: a leading tab is the next bullet level. + let mut text = TextBody::from_text(b); + for para in &mut text.paragraphs { + para.promote_leading_tabs(); + } + sh.text = Some(text); } 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; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..3c0cc7ec 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -705,14 +705,10 @@ fn set(s: &mut Session, p: &Value) -> Result { let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect(); let mut nb = TextBody::from_text(&text); for (i, para) in nb.paragraphs.iter_mut().enumerate() { - // Leading tabs set the outline level. - let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); - if tabs > 0 - && let Some(r) = para.runs.first_mut() + // Leading tabs set the outline level; otherwise keep the paragraph's own level. + if !para.promote_leading_tabs() + && let Some((lv, pp)) = levels.get(i) { - r.text = r.text.trim_start_matches('\t').to_string(); - para.level = tabs.min(8) as u8; - } else if let Some((lv, pp)) = levels.get(i) { para.level = *lv; para.props = pp.clone(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..a042deb8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -226,6 +226,34 @@ fn base64_roundtrip() { assert!(cmd::base64_decode("!!").is_none()); } +#[test] +fn new_slide_body_tabs_match_text_set() { + let body = "Install from DMG\nLaunch app\n\tCheck signing\n\t\tDeep\nBuild a deck"; + let mut s = session(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Test Plan", "body": body})).unwrap(); + let levels = |s: &Session| { + let slide = s.doc().unwrap().current_slide().unwrap(); + let sh = slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Obj)).unwrap(); + let rows: Vec<(u8, String)> = sh.text.as_ref().unwrap().paragraphs.iter().map(|p| (p.level, p.text())).collect(); + (sh.id.0, rows) + }; + let (id, from_new) = levels(&s); + assert_eq!( + from_new, + vec![ + (0, "Install from DMG".to_string()), + (0, "Launch app".to_string()), + (1, "Check signing".to_string()), + (2, "Deep".to_string()), + (0, "Build a deck".to_string()), + ] + ); + s.execute("text.set", &json!({"id": id, "text": body})).unwrap(); + assert_eq!(levels(&s).1, from_new); + s.execute("text.set", &json!({"id": id, "text": "\t\t\t\t\t\t\t\t\tCapped"})).unwrap(); + assert_eq!(levels(&s).1, vec![(8, "Capped".to_string())]); +} + #[test] fn layout_change_keeps_text() { let mut s = session(); diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index b8138a39..f6bd747d 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -453,6 +453,21 @@ impl Paragraph { pub fn new(text: &str) -> Self { Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() } } + /// Leading tabs are the outline level (at most 8), the same rule as a typed indent. + /// Returns whether any tab was consumed. + pub fn promote_leading_tabs(&mut self) -> bool { + let Some(r) = self.runs.first_mut() else { return false }; + if r.kind != RunKind::Text { + return false; + } + let tabs = r.text.chars().take_while(|c| *c == '\t').count(); + if tabs == 0 { + return false; + } + r.text = r.text.trim_start_matches('\t').to_string(); + self.level = tabs.min(8) as u8; + true + } pub fn text(&self) -> String { self.runs.iter().map(|r| r.content()).collect() } From c238d933df560dcc06d0ef20889c372e89e3724a Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 13 Oct 2026 12:48:28 +0300 Subject: [PATCH 20/69] Mark the current transition effect option (#365) The Effect Options menu drew every direction as the same button, so the active one was invisible. Co-authored-by: Cursor --- crates/ui-egui/src/ribbon.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..e5354df7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1378,12 +1378,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { - let opts: &[&str] = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let selected = cur.as_ref().map(|t| t.option.as_str()); + let rows = transition_effect_rows(&kind, selected); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { + for &(o, on) in &rows { + if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { run(app, "transition.options", json!({"option": o})); + ui.close(); } } }); @@ -2408,6 +2410,11 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { }); } +/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. +fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { + deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]).iter().map(|o| (*o, current == Some(*o))).collect() +} + /// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); @@ -2420,3 +2427,18 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +#[cfg(test)] +mod tests { + use super::transition_effect_rows; + + #[test] + fn effect_options_mark_the_current_transition_option() { + let on = |kind, current| transition_effect_rows(kind, current).into_iter().map(|(_, marked)| marked).collect::>(); + assert_eq!(on("push", Some("l")), vec![false, false, true, false]); + assert_eq!(on("fade", Some("throughBlack")), vec![false, true]); + assert_eq!(on("push", Some("missing")), vec![false, false, false, false]); + assert!(on("none", None).is_empty()); + assert_eq!(transition_effect_rows("cut", Some("")).first().map(|(o, marked)| (*o, *marked)), Some(("", true))); + } +} From 8b1836bc5320f6c2bebc724c1453c75912bfb253 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 13 Oct 2026 13:10:24 +0300 Subject: [PATCH 21/69] Effect Options marks the stored choice for animations too (#365) The same rule that highlights a transition direction now highlights an animation's direction and text build. Co-authored-by: Cursor --- crates/ui-egui/src/ribbon.rs | 69 +++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e5354df7..12eab294 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1593,18 +1593,23 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { }); } }); - let opts: &[&str] = cur.as_ref().and_then(|c| deckcraft_model::anim::animation_info(&c.effect, c.class)).map(|x| x.5).unwrap_or(&[]); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let build = cur.as_ref().map(|c| text_build_id(c.text_build)); + let build_rows = marked_options(&["asOne", "byParagraph"], build); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); egui::Popup::menu(&r).show(|ui| { - for o in opts { - if ui.button(option_label(o)).clicked() { + for &(o, on) in &effect_rows { + if ui.selectable_label(on, option_label(o)).clicked() { run(app, "animation.options", json!({"option": o})); + ui.close(); } } ui.separator(); - for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] { - if ui.button(l).clicked() { - run(app, "animation.options", json!({"textBuild": b})); + for &(id, on) in &build_rows { + let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; + if ui.selectable_label(on, label).clicked() { + run(app, "animation.options", json!({"textBuild": id})); + ui.close(); } } }); @@ -2410,9 +2415,30 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { }); } +/// The stored id is the one marked row. Every other id stays unmarked. +fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { + options.iter().copied().map(|o| (o, current == Some(o))).collect() +} + /// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { - deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]).iter().map(|o| (*o, current == Some(*o))).collect() + let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); + marked_options(opts, current) +} + +/// One row of Animations ▸ Effect Options for the selected effect. +fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); + marked_options(opts, current) +} + +fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { + match build { + deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", + deckcraft_model::anim::TextBuild::ByWord => "byWord", + deckcraft_model::anim::TextBuild::ByLetter => "byLetter", + deckcraft_model::anim::TextBuild::AsOne => "asOne", + } } /// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). @@ -2430,15 +2456,26 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { #[cfg(test)] mod tests { - use super::transition_effect_rows; + use super::{animation_effect_rows, marked_options, text_build_id, transition_effect_rows}; + use deckcraft_model::anim::{AnimClass, TextBuild}; + + fn marked(rows: Vec<(&str, bool)>) -> Vec<&str> { + rows.into_iter().filter(|(_, on)| *on).map(|(id, _)| id).collect() + } #[test] - fn effect_options_mark_the_current_transition_option() { - let on = |kind, current| transition_effect_rows(kind, current).into_iter().map(|(_, marked)| marked).collect::>(); - assert_eq!(on("push", Some("l")), vec![false, false, true, false]); - assert_eq!(on("fade", Some("throughBlack")), vec![false, true]); - assert_eq!(on("push", Some("missing")), vec![false, false, false, false]); - assert!(on("none", None).is_empty()); - assert_eq!(transition_effect_rows("cut", Some("")).first().map(|(o, marked)| (*o, *marked)), Some(("", true))); + fn effect_options_mark_only_the_stored_choice() { + assert_eq!(marked(transition_effect_rows("push", Some("l"))), vec!["l"]); + assert_eq!(transition_effect_rows("push", Some("l")).into_iter().map(|(_, on)| on).collect::>(), vec![false, false, true, false]); + assert_eq!(marked(transition_effect_rows("fade", Some("throughBlack"))), vec!["throughBlack"]); + assert!(marked(transition_effect_rows("push", Some("missing"))).is_empty()); + assert!(transition_effect_rows("none", None).is_empty()); + assert_eq!(marked(transition_effect_rows("cut", Some(""))), vec![""]); + assert_eq!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("l"))), vec!["l"]); + assert!(marked(animation_effect_rows("fly", AnimClass::Entrance, Some("nope"))).is_empty()); + assert!(animation_effect_rows("appear", AnimClass::Entrance, Some("l")).is_empty()); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByParagraph)))), vec!["byParagraph"]); + assert_eq!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::AsOne)))), vec!["asOne"]); + assert!(marked(marked_options(&["asOne", "byParagraph"], Some(text_build_id(TextBuild::ByWord)))).is_empty()); } } From 78a99ad1cfc662295c88b4a618a72f751da7a5b0 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Tue, 13 Oct 2026 02:12:23 +0600 Subject: [PATCH 22/69] Start screen: a missing Recent file is marked, its error shows, and entries can be removed (#172) The start screen reported errors through set_status, but the status bar is only drawn while a presentation is open: clicking a Recent entry whose file had moved or been deleted did nothing visible, and the same went for every other start-screen error, like a failed sample open. The start screen now draws the transient status message itself: a real label, so screen readers see it too, wrapped so a long OS error does not clip at the panel edge. Recent entries whose file no longer exists are dimmed with a "(missing)" mark before anything is clicked, each entry has a remove button, and a successful open clears a stale error. Existence is probed once per path per session; a stat on a dead network share can block for the OS timeout, so UNC paths and URLs are never probed and are judged when opened instead. The remove button is a plain multiplication sign: U+2715 has no glyph in the UI font stack and egui would draw the replacement character. Tests: the missing mark and the drawn error (kittest, clicked through the real UI), removal from the list, and the negative case - an existing file is not marked. --- ROADMAP.md | 1 + crates/ui-egui/src/backstage.rs | 47 +++++++++++-- crates/ui-egui/src/lib.rs | 120 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/status.rs | 10 +-- 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..aff356bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 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 | +| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f1d0fde3..6a1980aa 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -391,6 +391,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text)); ui.add_space(16.0); } + // Errors raised on a backstage page (a missing recent file, a failed open) would + // otherwise vanish: the status bar only draws once the backstage is closed (#172). + // A real label, not painted text, so screen readers see it too. + if let Some(msg) = app.fresh_status() { + ui.label(RichText::new(&msg).font(theme::font(12.0)).color(t.accent)); + ui.add_space(8.0); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } match bs.page { Page::Home => home(app, ui, &mut bs, &mut out), Page::New => new_page(app, ui, &mut bs), @@ -558,6 +566,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { } let width = ui.available_width().min(CONTENT_W); for path in list { + let missing = !app.recent_exists(&path); let (name, folder) = split_path(&path); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); @@ -565,10 +574,40 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { if resp.hovered() { p.rect_filled(rect, CornerRadius::same(3), t.hover); } - icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, t.text, false); - p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), t.text); - p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); - if resp.on_hover_text(&path).clicked() + let ink = if missing { t.text_dim } else { t.text }; + // The row is as wide as the page's content, which a narrow window clips: keep the mark + // and the remove button inside the visible part of the row. + let visible_right = rect.right().min(ui.clip_rect().right() - 6.0); + { + let p = ui.painter(); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false); + p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink); + p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); + // A missing file says so before it is clicked (#172). + if missing { + p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); + } + } + let resp = if missing { + resp.on_hover_text(format!("{path} — this file can't be found")) + } else { + resp.on_hover_text(&path) + }; + // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon + // (a text glyph would need a font that has it). + let x = ui.allocate_rect( + Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), + Sense::click(), + ); + x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent")); + if x.hovered() { + ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); + if x.on_hover_text("Remove from Recent").clicked() { + app.forget_recent(&path); + } + if resp.clicked() && let Err(e) = app.open_path(&path) { app.set_status(e); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..8975ab87 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ pub mod toast; pub mod uifonts; pub mod widgets; +use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; use deckcraft_engine::{Mods, Session, UiRequest}; @@ -44,6 +45,9 @@ pub type DownloadFn = Box; /// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). pub type Inbox = std::sync::Arc)>>>; +/// How long a transient status message stays visible. +const STATUS_LINGER_MS: f64 = 6000.0; + /// Platform services injected by the host (desktop or web). #[derive(Default)] pub struct Services { @@ -227,6 +231,9 @@ pub struct SlideApp { pub(crate) thumb_drag: Option<(usize, usize)>, /// Selection pane: folded groups, renaming, drag and drop. pub selpane: selpane::State, + /// Which recent paths exist on disk, probed once per path per session (a dead network share + /// must not stall every frame). Not persisted. + recent_checked: HashMap, } impl SlideApp { @@ -267,6 +274,7 @@ impl SlideApp { ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, selpane: selpane::State::default(), + recent_checked: HashMap::new(), } } @@ -279,6 +287,56 @@ impl SlideApp { self.status = Some((msg.into(), now_ms())); } + /// The transient status message while it is fresh; clears it once stale. Both the status bar + /// and the start screen draw it — the start screen has no status bar, and errors raised there + /// (a missing recent file, a failed sample) used to vanish (issue #172). + pub fn fresh_status(&mut self) -> Option { + let (msg, at) = self.status.clone()?; + if now_ms() - at < STATUS_LINGER_MS { + Some(msg) + } else { + self.status = None; + None + } + } + + /// Does this recent path exist on disk? Probed once per path per session and only on + /// desktop (the web build has no file system to ask): a stat on a dead network share can + /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — + /// opening still tries and reports, and a successful open re-probes. + pub fn recent_exists(&mut self, path: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = path; + true + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ok) = self.recent_checked.get(path) { + return *ok; + } + // A stat on a dead network location (UNC share, URL) can block for the OS timeout, + // so remote-looking paths are never probed: they stay unmarked and are judged when + // opened. A disconnected mapped drive would still stall once — accepted. + if path.starts_with("\\\\") || path.contains("://") { + return true; + } + let ok = std::path::Path::new(path).is_file(); + if self.recent_checked.len() > 64 { + let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); + self.recent_checked.retain(|p, _| live.contains(p)); + } + self.recent_checked.insert(path.to_string(), ok); + ok + } + } + + /// Drop a path from the Recent list (its file is gone or the user asked for it). + pub fn forget_recent(&mut self, path: &str) { + self.ui.recent.retain(|p| p != path); + self.recent_checked.remove(path); + } + /// Run a command by id (UI commands here, everything else in the engine). Errors show in the /// status bar and are returned. pub fn run(&mut self, id: &str, params: Value) -> Result { @@ -573,6 +631,8 @@ impl SlideApp { pub fn open_path(&mut self, path: &str) -> Result<(), String> { self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; + self.status = None; // an older error (a missing file just before this one) must not linger + self.recent_checked.remove(path); self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); self.ui.recent.truncate(12); @@ -1135,6 +1195,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; + use egui_kittest::kittest::Queryable; fn title_text(app: &SlideApp) -> String { let d = app.session.active().unwrap(); @@ -1408,4 +1469,63 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + fn harness() -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::new(), Services::default()), + ) + } + + /// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws + /// the open error right there (the status bar is hidden while the backstage is open, so the + /// message used to vanish). + #[test] + fn missing_recent_is_marked_and_its_error_shows() { + let mut harness = harness(); + let missing = "/deckcraft-tests/172/missing.deckcraft"; + harness.state_mut().ui.recent = vec![missing.to_string()]; + harness.run_steps(2); + // The row is labelled with the file's name. Clicking it raises the open error, and the + // drawn message is a second node that mentions the name (the OS text after it varies). + harness.get_by_label("missing.deckcraft").click(); + harness.run_steps(2); + assert!(harness.state().status.is_some(), "the open error was raised"); + assert!( + harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, + "the open error is drawn on the page, next to the entry" + ); + } + + /// #172: a Recent entry can be removed from the list. + #[test] + fn recent_entry_can_be_removed() { + let mut harness = harness(); + harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; + harness.run_steps(2); + let removes = harness.get_all_by_label("Remove from Recent").collect::>(); + assert_eq!(removes.len(), 2); + // The remove button must sit inside the visible part of the row even when the page's + // content is wider than the window. + assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect()); + removes[1].click(); + harness.run_steps(1); + assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]); + } + + /// The existence probe tells present files from missing ones and never touches remote paths. + #[test] + fn recent_exists_tells_missing_from_present() { + let mut app = SlideApp::new(Session::new(), Services::default()); + let mut path = std::env::temp_dir(); + path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id())); + std::fs::write(&path, b"").unwrap(); + assert!(app.recent_exists(&path.to_string_lossy()), "the file exists"); + assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft")); + assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed"); + std::fs::remove_file(&path).ok(); + } } diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 8e915b71..026f6526 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -22,13 +22,9 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, "English (United States)", theme::font(12.0), t.text_dim); // Transient status message. - if let Some((msg, at)) = app.status.clone() { - if crate::now_ms() - at < 6000.0 { - p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - } else { - app.status = None; - } + if let Some(msg) = app.fresh_status() { + p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } let mut right = ui.new_child( egui::UiBuilder::new() From 97855c70a06f6d632ded50f9a56c65500ab546bd Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:28 +0200 Subject: [PATCH 23/69] File: save through a unique, exclusively created temporary file (#391) --- crates/engine/src/cmd/file.rs | 66 ++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f6424f7c..0fd30ce9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -215,10 +215,30 @@ fn mark_saved(s: &mut Session, path: Option) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - // Write to a temporary sibling then rename, so a failed save never truncates the old file. - let tmp = format!("{path}.saving"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + use std::io::Write; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); + // Write to a uniquely named temporary sibling, created exclusively (never following or reusing an + // existing file or symlink), then rename it over the destination so a failed save never truncates + // the old file and concurrent saves never share temporary bytes. + let mut attempts = 0; + let (tmp, mut file) = loop { + let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(f) => break (tmp, f), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, + Err(e) => return Err(err(e)), + } + }; + let written = file.write_all(bytes).and_then(|()| file.sync_all()); + drop(file); + let result = written.and_then(|()| std::fs::rename(&tmp, path)); + if let Err(e) = result { + let _ = std::fs::remove_file(&tmp); + return Err(err(e)); + } + Ok(()) } #[cfg(target_arch = "wasm32")] fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> { @@ -430,3 +450,41 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result { } ok() } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::write_file; + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn write_file_does_not_touch_a_predictable_saving_sibling() { + let dir = scratch("sibling"); + let dest = dir.join("deck.deckcraft"); + let dest_str = dest.to_str().unwrap(); + std::fs::write(&dest, b"old").unwrap(); + std::fs::write(dir.join("deck.deckcraft.saving"), b"precious").unwrap(); + write_file(dest_str, b"new").unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"new"); + assert_eq!(std::fs::read(dir.join("deck.deckcraft.saving")).unwrap(), b"precious"); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names.len(), 2, "no temporary file may be left behind: {names:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn write_file_failed_rename_keeps_destination_and_cleans_up() { + let dir = scratch("failed"); + let dest = dir.join("target"); + std::fs::create_dir(&dest).unwrap(); + assert!(write_file(dest.to_str().unwrap(), b"new").is_err()); + assert!(dest.is_dir()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } +} From 952ebad3cf45943cb38cb2529d2dcbd701093e3a Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 20:27:21 +0300 Subject: [PATCH 24/69] Media: a failed or stalled audio output hands the clock back to wall time (#417) A cpal stream error was only kept and logged on stop, and the player kept `streaming` true, so a device that died mid-play froze the voice and video. AudioOut gains `failure()` (CpalOut reports DeviceNotAvailable), and Player::tick also treats a stream with no callbacks for a second while a voice plays as stalled. Either way the stream is stopped, voices continue on the wall clock, and the next play retries the device; after three failures the player gives up on the device and plays silently. Co-authored-by: Cursor --- apps/deckcraft/src/audio.rs | 19 +++- crates/media/src/player.rs | 201 +++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 3 deletions(-) diff --git a/apps/deckcraft/src/audio.rs b/apps/deckcraft/src/audio.rs index 53aa48cc..afa74a6a 100644 --- a/apps/deckcraft/src/audio.rs +++ b/apps/deckcraft/src/audio.rs @@ -1,6 +1,7 @@ //! cpal audio output for media playback: the device pulls mixed samples from the //! `deckcraft_media::Player`, which makes it the playback clock video frames follow. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; @@ -29,6 +30,8 @@ fn take_stream_error(slot: &ErrorSlot) -> Option { pub struct CpalOut { stream: Option, error: ErrorSlot, + /// Set by the realtime error callback when the device went away: the stream is dead. + dead: Arc, } impl CpalOut { @@ -56,7 +59,14 @@ impl AudioOut for CpalOut { let rate = cfg.sample_rate().0; let config: cpal::StreamConfig = cfg.clone().into(); let slot = self.error.clone(); - let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e); + let dead = Arc::new(AtomicBool::new(false)); + self.dead = dead.clone(); + let err = move |e: cpal::StreamError| { + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + dead.store(true, Ordering::Relaxed); + } + keep_stream_error(&slot, &e); + }; let stream = match cfg.sample_format() { cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None), cpal::SampleFormat::I16 => { @@ -86,6 +96,13 @@ impl AudioOut for CpalOut { self.stream = None; self.report(); } + + fn failure(&mut self) -> Option { + if !self.dead.swap(false, Ordering::Relaxed) { + return None; + } + Some(take_stream_error(&self.error).unwrap_or_else(|| "the audio device is no longer available".to_string())) + } } #[cfg(test)] diff --git a/crates/media/src/player.rs b/crates/media/src/player.rs index 9ac4a804..3fa3a16a 100644 --- a/crates/media/src/player.rs +++ b/crates/media/src/player.rs @@ -11,6 +11,7 @@ //! decoded runs silently. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use crate::{Bytes, ClipParams, Pcm}; @@ -20,6 +21,12 @@ use crate::{Bytes, ClipParams, Pcm}; pub trait AudioOut { fn start(&mut self, fill: Box) -> Result<(), String>; fn stop(&mut self); + /// Why the started stream has died, if it has (the device went away), reported once. Polled + /// by [`Player::tick`] on the host thread; must not block. Outputs that cannot tell return + /// `None`: the player also notices a stream whose callbacks stopped. + fn failure(&mut self) -> Option { + None + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -186,6 +193,13 @@ impl Mixer { /// Default mix rate when no device says otherwise. const DEFAULT_RATE: u32 = 48_000; +/// Seconds without a single device callback, while a voice plays, after which the stream counts +/// as stalled and the wall clock takes over. +const STALL_SECS: f64 = 1.0; + +/// Output failures after which the player stops retrying the device and plays silently. +const MAX_OUTPUT_FAILURES: u32 = 3; + pub struct Player { out: Option>, streaming: bool, @@ -193,12 +207,28 @@ pub struct Player { last_tick: Option, idle_since: Option, cache: HashMap, + /// Device callbacks so far (the audio thread counts, [`Player::tick`] watches). + pulls: Arc, + /// The pull count last seen by `tick` and when it last changed. + pulls_seen: Option<(u64, f64)>, + /// Times the output failed or stalled after starting. + failures: u32, } impl Player { /// A player over `out` (`None`: silent, clocked by [`Player::tick`]). pub fn new(out: Option>) -> Player { - Player { out, streaming: false, mixer: Arc::new(Mutex::new(Mixer::default())), last_tick: None, idle_since: None, cache: HashMap::new() } + Player { + out, + streaming: false, + mixer: Arc::new(Mutex::new(Mixer::default())), + last_tick: None, + idle_since: None, + cache: HashMap::new(), + pulls: Arc::new(AtomicU64::new(0)), + pulls_seen: None, + failures: 0, + } } pub fn has_output(&self) -> bool { @@ -267,15 +297,20 @@ impl Player { } let Some(out) = self.out.as_mut() else { return }; let m = self.mixer.clone(); + let pulls = self.pulls.clone(); let fill = Box::new(move |buf: &mut [f32], channels: usize, rate: u32| match m.lock() { Ok(mut g) => { + pulls.fetch_add(1, Ordering::Relaxed); let frames = buf.len() / channels.max(1); g.mix(Some(buf), frames, channels, rate) } Err(_) => buf.fill(0.0), }); match out.start(fill) { - Ok(()) => self.streaming = true, + Ok(()) => { + self.streaming = true; + self.pulls_seen = None; + } Err(e) => { log::warn!("audio output unavailable ({e}); media plays silently"); self.out = None; @@ -338,6 +373,7 @@ impl Player { pub fn tick(&mut self, now: f64) { let dt = self.last_tick.map(|l| (now - l).clamp(0.0, 0.25)).unwrap_or(0.0); self.last_tick = Some(now); + self.check_output(now); if !self.streaming && dt > 0.0 { let frames = (dt * DEFAULT_RATE as f64).round() as usize; self.with(|m| m.mix(None, frames, 2, DEFAULT_RATE)); @@ -355,6 +391,50 @@ impl Player { } } + /// Drop a stream that died or stalled, so the voices fall back to the wall clock instead of + /// freezing. The next `play` tries the device again, up to [`MAX_OUTPUT_FAILURES`] times. + fn check_output(&mut self, now: f64) { + if !self.streaming { + return; + } + // Only a playing voice needs callbacks; an idle stream may legitimately sleep. + let playing = self.any_playing(); + let Some(out) = self.out.as_mut() else { return }; + let pulls = self.pulls.load(Ordering::Relaxed); + let reason = if let Some(e) = out.failure() { + Some(e) + } else { + match self.pulls_seen { + Some((seen, since)) if seen == pulls => { + if !playing { + self.pulls_seen = Some((pulls, now)); + None + } else if now - since > STALL_SECS { + Some("the audio device stopped asking for samples".to_string()) + } else { + None + } + } + _ => { + self.pulls_seen = Some((pulls, now)); + None + } + } + }; + let Some(reason) = reason else { return }; + out.stop(); + self.streaming = false; + self.pulls_seen = None; + self.idle_since = None; + self.failures = self.failures.saturating_add(1); + if self.failures >= MAX_OUTPUT_FAILURES { + log::warn!("audio output failed ({reason}); giving up on the device, media plays silently"); + self.out = None; + } else { + log::warn!("audio output failed ({reason}); media plays silently until it restarts"); + } + } + /// Block until voice `key`'s audio has decoded (tests, offline rendering). pub fn wait_loaded(&self, key: u64, timeout: std::time::Duration) -> bool { let slot = self.with(|m| m.voices.iter().find(|v| v.key == key).and_then(|v| v.audio.clone())); @@ -502,4 +582,121 @@ mod tests { let pos = p.status(3).map(|s| s.position).unwrap_or(-1.0); assert!((pos - 0.1).abs() < 1e-6, "{pos}"); } + + type Fill = Box; + + /// An output that starts, then fails or goes quiet on command. + #[derive(Clone, Default)] + struct Flaky { + fill: Arc>>, + fail: Arc>>, + starts: Arc>, + } + impl AudioOut for Flaky { + fn start(&mut self, fill: Fill) -> Result<(), String> { + *self.fill.lock().map_err(|e| e.to_string())? = Some(fill); + *self.starts.lock().map_err(|e| e.to_string())? += 1; + Ok(()) + } + fn stop(&mut self) { + if let Ok(mut f) = self.fill.lock() { + *f = None; + } + } + fn failure(&mut self) -> Option { + self.fail.lock().ok().and_then(|mut f| f.take()) + } + } + impl Flaky { + fn pull(&self, frames: usize) { + let mut buf = vec![0.0; frames * 2]; + if let Some(f) = self.fill.lock().ok().as_mut().and_then(|g| g.as_mut()) { + f(&mut buf, 2, 48_000); + } + } + fn starts(&self) -> u32 { + self.starts.lock().map(|s| *s).unwrap_or(0) + } + } + + fn pos(p: &Player, key: u64) -> f64 { + p.status(key).map(|s| s.position).unwrap_or(-1.0) + } + + #[test] + fn a_failed_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.tick(0.0); + out.pull(4800); + assert!((pos(&p, 1) - 0.1).abs() < 1e-6); + if let Ok(mut f) = out.fail.lock() { + *f = Some("unplugged".into()); + } + p.tick(0.2); + p.tick(0.4); + // The voice keeps moving on wall time instead of freezing: 0.1 s from the device, then + // the 0.4 s of wall time since the last tick before the failure. + assert!((pos(&p, 1) - 0.5).abs() < 1e-3, "{}", pos(&p, 1)); + assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Playing)); + // Playing again restarts the device. + p.play(2, 2, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 2); + assert!(p.has_output()); + } + + #[test] + fn a_stalled_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + // No callback ever arrives. + for i in 0..=5 { + p.tick(i as f64 * 0.25); + } + let before = pos(&p, 1); + assert!(before > 0.0, "{before}"); + p.tick(1.5); + assert!((pos(&p, 1) - before - 0.25).abs() < 1e-3); + } + + #[test] + fn an_idle_stream_without_callbacks_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.pause(1); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Resuming keeps the running stream: no restart. + p.play(1, 1, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 1); + } + + #[test] + fn repeated_failures_give_up_on_the_device() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + let mut now = 0.0; + for _ in 0..MAX_OUTPUT_FAILURES { + p.play(1, 1, &b, spec(100.0, false), None); + if let Ok(mut f) = out.fail.lock() { + *f = Some("broken".into()); + } + p.tick(now); + now += 0.1; + } + assert!(!p.has_output()); + assert_eq!(out.starts(), MAX_OUTPUT_FAILURES); + // Still plays, silently on wall time. + let before = pos(&p, 1); + p.tick(now + 0.2); + assert!(pos(&p, 1) > before); + } } From 8bab5c20535b2871b8e568a9f2dde2a0e99fedc2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 20:53:10 +0300 Subject: [PATCH 25/69] Web: keep a picked file on the document and slide that asked for it The browser file picker only queued a name and bytes. When the read finished, DeckCraft guessed the command and ran it on whichever document and slide were active, so a picture, sound or outline could land in the wrong place, and a later pick could finish first. The request now keeps the document, the slide or master, the operation and a cancellation id. A completion applies there, or is dropped when that target is gone or the picker was dismissed. Co-authored-by: Cursor --- ROADMAP.md | 4 +- apps/deckcraft-web/src/main.rs | 3 +- apps/deckcraft-web/src/web.rs | 23 +- crates/ui-egui/src/inbox_tests.rs | 188 +++++++++++++++ crates/ui-egui/src/lib.rs | 366 ++++++++++++++++++++++++++---- 5 files changed, 537 insertions(+), 47 deletions(-) create mode 100644 crates/ui-egui/src/inbox_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..455d0b32 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: web file picker keeps its document and slide, #414; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 366ea346..6d2b7e94 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -7,7 +7,8 @@ //! Differences from the desktop app: //! - no TCP control channel (browsers can't listen on sockets); //! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive -//! asynchronously through `Services::inbox` (presentations open, media is inserted); +//! asynchronously through `Services::inbox`, applied to the document, slide and operation +//! that started the pick (a dismissed picker or a closed document drops the file); //! - Save and Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - preferences are not persisted. diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f0199a55..8fa95918 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. use deckcraft_engine::Session; -use deckcraft_ui_egui::{Inbox, Services, SlideApp}; +use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp}; use wasm_bindgen::JsCast as _; const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"]; @@ -77,16 +77,23 @@ impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); for f in dropped { + // Capture the document and slide now. The bytes arrive later, after the user may + // have switched slides. + let id = self.app.begin_external_file("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); match f.bytes_async().await { Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes })); + ctx.request_repaint(); + } + Err(e) => { + log::error!("couldn't read dropped file {name}: {e}"); + push_inbox(&inbox, InboxEvent::Cancelled { id }); ctx.request_repaint(); } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } }); } @@ -107,7 +114,7 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { - open_async: Some(Box::new(move |purpose: &str| { + open_async: Some(Box::new(move |purpose: &str, request: u64| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); let dialog = match purpose { @@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { }; wasm_bindgen_futures::spawn_local(async move { let Some(file) = dialog.pick_file().await else { + push_inbox(&inbox, InboxEvent::Cancelled { id: request }); + ctx.request_repaint(); return; }; let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes })); ctx.request_repaint(); }); })), @@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } } +fn push_inbox(inbox: &Inbox, event: InboxEvent) { + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event); +} + /// Trigger a browser download of `bytes` named after the last component of `path`. fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/crates/ui-egui/src/inbox_tests.rs b/crates/ui-egui/src/inbox_tests.rs new file mode 100644 index 00000000..dc657173 --- /dev/null +++ b/crates/ui-egui/src/inbox_tests.rs @@ -0,0 +1,188 @@ +//! #414: a browser file pick finishes on the document, slide and operation that started it. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::{Arc, Mutex}; + +use deckcraft_model::ShapeKind; +use serde_json::json; + +use super::{Inbox, InboxEvent, InboxFile, Services, SlideApp}; +use crate::Session; + +fn png() -> Vec { + // 1×1 RGB PNG. Enough for `insert.picture` to accept it. + const HEX: &str = + "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de0000000c49444154789c63f8cfc0000003010100c9fe92ef0000000049454e44ae426082"; + (0..HEX.len()).step_by(2).map(|i| u8::from_str_radix(&HEX[i..i + 2], 16).expect("hex")).collect() +} + +fn wav() -> Vec { + let data = [0u8; 8]; + let mut b = b"RIFF".to_vec(); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&8000u32.to_le_bytes()); + b.extend_from_slice(&16000u32.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b +} + +fn setup() -> (SlideApp, Inbox, Arc>>) { + let inbox: Inbox = Inbox::default(); + let started = Arc::new(Mutex::new(Vec::new())); + let record = started.clone(); + let services = Services { + inbox: Some(inbox.clone()), + open_async: Some(Box::new(move |purpose, id| { + record.lock().expect("request log").push((purpose.to_string(), id)); + })), + ..Default::default() + }; + (SlideApp::new(Session::with_new(), services), inbox, started) +} + +fn deliver(inbox: &Inbox, id: u64, name: &str, bytes: Vec) { + inbox.lock().expect("inbox").push(InboxEvent::File(InboxFile { id, name: name.into(), bytes })); +} + +fn pictures(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize { + let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document"); + let i = st.doc.slide_index(slide).expect("slide"); + st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count() +} + +fn audios(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize { + let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document"); + let i = st.doc.slide_index(slide).expect("slide"); + st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Media(ref m) if !m.video)).count() +} + +/// Two picks finish in reverse order, after the user has changed slide and document. +/// Each file uses the operation that was requested and lands on the slide that asked. +#[test] +fn late_picker_files_keep_their_document_slide_and_operation() { + let (mut app, inbox, started) = setup(); + let uid_a = app.session.active().unwrap().uid; + let slide0 = app.session.active().unwrap().current_slide().unwrap().id; + app.session.execute("slide.new", &json!({})).unwrap(); + let slide1 = app.session.active().unwrap().current_slide().unwrap().id; + assert_ne!(slide0, slide1); + + // Audio was asked for on slide 1; the picture and the outline on slide 0. + app.pick_and_insert("audio", "insert.audio"); + app.session.execute("slide.go", &json!({"id": slide0.0})).unwrap(); + app.pick_and_insert("picture", "insert.picture"); + app.pick_and_open(); + + app.session.execute("file.new", &json!({})).unwrap(); + let uid_b = app.session.active().unwrap().uid; + assert_ne!(uid_a, uid_b); + let shapes_b = app.session.active().unwrap().doc.slides[0].shapes.len(); + // A video was requested on B. Delivering a PNG must not insert a picture anywhere. + app.pick_and_insert("video", "insert.video"); + + let log = started.lock().expect("request log").clone(); + assert_eq!(log.iter().map(|(p, _)| p.as_str()).collect::>(), vec!["audio", "picture", "open", "video"]); + let audio_id = log[0].1; + let picture_id = log[1].1; + let outline_id = log[2].1; + let video_id = log[3].1; + + // The video request completes first, while B is still current. A PNG must not become a picture. + deliver(&inbox, video_id, "clip.mp4", png()); + app.drain_inbox(); + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert!(status.contains("not a supported video") || status.contains("no video"), "{status}"); + assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b); + + // The earlier picks then finish in reverse order. + deliver(&inbox, outline_id, "talk.md", b"Brought Later\n".to_vec()); + deliver(&inbox, picture_id, "shot.png", png()); + deliver(&inbox, audio_id, "tone.wav", wav()); + app.drain_inbox(); + + assert_eq!(app.session.active().unwrap().uid, uid_b, "the view stays on the document the user switched to"); + assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b, "the video request must not insert the PNG here"); + assert_eq!(pictures(&app, uid_b, app.session.documents().iter().find(|d| d.uid == uid_b).unwrap().doc.slides[0].id), 0); + + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert_eq!(pictures(&app, uid_a, slide0), 1, "{status}"); + assert_eq!(pictures(&app, uid_a, slide1), 0); + assert_eq!(audios(&app, uid_a, slide1), 1); + assert_eq!(audios(&app, uid_a, slide0), 0); + let a = app.session.documents().iter().find(|d| d.uid == uid_a).unwrap(); + assert!(a.doc.slides.iter().any(|s| s.title() == "Brought Later"), "the outline stays on the document that opened it"); + assert_eq!(a.doc.slides.len(), 3); + // The user had moved to slide 0 of A before switching documents; B is what's on screen. + assert_eq!(app.session.active().unwrap().selection.slide, 0); +} + +/// Closing the presentation, or dismissing the picker, drops the file instead of inserting +/// it into whatever is open now. +#[test] +fn a_closed_or_cancelled_request_does_not_insert_into_the_active_document() { + let (mut app, inbox, started) = setup(); + let slide = app.session.active().unwrap().current_slide().unwrap().id; + app.pick_and_insert("picture", "insert.picture"); + app.session.execute("slide.new", &json!({})).unwrap(); + let kept = app.session.active().unwrap().current_slide().unwrap().id; + app.session.execute("slide.delete", &json!({"ids": [slide.0]})).unwrap(); + app.pick_and_insert("picture", "insert.picture"); + let log = started.lock().expect("request log").clone(); + let deleted_slide = log[0].1; + let dismiss = log[1].1; + + inbox.lock().expect("inbox").push(InboxEvent::Cancelled { id: dismiss }); + deliver(&inbox, dismiss, "late.png", png()); + deliver(&inbox, deleted_slide, "gone.png", png()); + // An id that was never started must not insert either. + deliver(&inbox, 999, "stray.png", png()); + app.drain_inbox(); + + assert_eq!(pictures(&app, app.session.active().unwrap().uid, kept), 0); + let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); + assert!(status.contains("deleted"), "{status}"); + + app.session.execute("file.close", &json!({})).unwrap(); + app.session.execute("file.new", &json!({})).unwrap(); + // The deleted-slide request was already consumed. Start another and close before it arrives. + let closed = app.session.active().unwrap().uid; + app.pick_and_insert("audio", "insert.audio"); + let audio_id = started.lock().expect("request log").last().unwrap().1; + app.session.execute("file.close", &json!({})).unwrap(); + app.session.execute("file.new", &json!({})).unwrap(); + let other = app.session.active().unwrap().uid; + assert_ne!(closed, other); + deliver(&inbox, audio_id, "tone.wav", wav()); + app.drain_inbox(); + let st = app.session.documents().iter().find(|d| d.uid == other).unwrap(); + let n = st.doc.slides.iter().flat_map(|s| s.shapes.iter()).filter(|s| matches!(s.kind, ShapeKind::Media(_))).count(); + assert_eq!(n, 0, "a file for a closed presentation is not inserted into the one now open"); + let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); + assert!(status.contains("closed"), "{status}"); +} + +/// A drop is bound to the slide under the pointer at drop time, not the slide current when +/// the bytes have been read. +#[test] +fn a_dropped_file_stays_on_the_slide_it_was_dropped_on() { + let (mut app, inbox, _) = setup(); + let uid = app.session.active().unwrap().uid; + let slide0 = app.session.active().unwrap().current_slide().unwrap().id; + let id = app.begin_external_file("drop"); + app.session.execute("slide.new", &json!({})).unwrap(); + let slide1 = app.session.active().unwrap().current_slide().unwrap().id; + deliver(&inbox, id, "shot.png", png()); + app.drain_inbox(); + assert_eq!(app.session.active().unwrap().current_slide().unwrap().id, slide1, "the view stays on the slide the user moved to"); + let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert_eq!(pictures(&app, uid, slide0), 1, "{status}"); + assert_eq!(pictures(&app, uid, slide1), 0); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..409ff68d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,7 +30,8 @@ pub mod widgets; use std::sync::mpsc::{Receiver, Sender}; -use deckcraft_engine::{Mods, Session, UiRequest}; +use deckcraft_engine::{Mods, Session, Target, UiRequest}; +use deckcraft_model::{LayoutId, MasterId, SlideId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -39,10 +40,144 @@ pub use control::{ControlRequest, ControlResponse}; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type PickFn = Box Option>; -pub type OpenAsyncFn = Box; +/// `(purpose, request id)`. The host shows a picker and later pushes an [`InboxEvent`] +/// carrying that same id. Completing a cancelled or unknown id does nothing: the file is +/// not applied to whichever document happens to be active. +pub type OpenAsyncFn = Box; pub type DownloadFn = Box; -/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). -pub type Inbox = std::sync::Arc)>>>; + +/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. +#[derive(Clone, Debug)] +pub struct InboxFile { + /// Cancellation identity. A file whose id was cancelled, or never started, is ignored. + pub id: u64, + pub name: String, + pub bytes: Vec, +} + +/// What the host delivers through [`Inbox`] after an asynchronous read. +#[derive(Clone, Debug)] +pub enum InboxEvent { + File(InboxFile), + /// The picker was dismissed or the file could not be read. `id` will not be applied. + Cancelled { + id: u64, + }, +} + +/// Files delivered asynchronously by the host (web file picker, dropped files). +pub type Inbox = std::sync::Arc>>; + +/// The operation the user asked for. Kept with the request so a late completion does not +/// guess a command from the file name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FilePurpose { + Open, + Picture, + Audio, + Video, + /// A drop, or any other purpose: decide from the name and bytes. + Infer, +} + +/// Where in a presentation a file should land. Ids, not indices: slides can move while +/// the browser is still reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FilePlace { + Slide(SlideId), + Master(MasterId), + Layout { + master: MasterId, + layout: LayoutId, + }, + /// The document was open but had no current slide, master or layout. + Unspecified, +} + +/// The presentation (and place in it) captured when the request started. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileTarget { + /// Nothing was open. An insert makes its own document rather than using one opened + /// while the file was being read. + Absent, + Document { + doc: u64, + place: FilePlace, + }, +} + +struct PendingFile { + id: u64, + purpose: FilePurpose, + target: FileTarget, +} + +struct LocatedPlace { + target: Target, + slide: Option, +} + +fn purpose_of(purpose: &str) -> FilePurpose { + match purpose { + "open" => FilePurpose::Open, + "picture" => FilePurpose::Picture, + "audio" => FilePurpose::Audio, + "video" => FilePurpose::Video, + _ => FilePurpose::Infer, + } +} + +fn is_presentation_name(lower: &str) -> bool { + lower.ends_with(".deckcraft") + || lower.ends_with(".slidecraft") + || lower.ends_with(".pptx") + || lower.ends_with(".potx") + || lower.ends_with(".ppsx") +} + +/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. +fn open_command(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + Some("file.openBytes") + } else if lower.ends_with(".txt") || lower.ends_with(".md") { + Some("slide.fromOutline") + } else { + None + } +} + +/// A drop has no requested operation, so the name and bytes decide: a presentation opens, +/// then a recognised picture, sound or video is inserted, then an outline becomes slides. +fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + return Some("file.openBytes"); + } + let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); + if ct.starts_with("image/") { + Some("insert.picture") + } else if ct.starts_with("audio/") { + Some("insert.audio") + } else if ct.starts_with("video/") { + Some("insert.video") + } else if lower.ends_with(".txt") || lower.ends_with(".md") { + Some("slide.fromOutline") + } else { + None + } +} + +fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { + let cmd = match purpose { + FilePurpose::Picture => Some("insert.picture"), + FilePurpose::Audio => Some("insert.audio"), + FilePurpose::Video => Some("insert.video"), + FilePurpose::Open => open_command(name), + FilePurpose::Infer => infer_command(name, bytes), + }; + cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) +} /// Platform services injected by the host (desktop or web). #[derive(Default)] @@ -53,7 +188,8 @@ pub struct Services { pub pick_save: Option, pub read: Option, pub write: Option, - /// Asynchronous open dialog (web): the chosen file arrives through `inbox`. + /// Asynchronous open dialog (web). The callback receives the purpose and the request id + /// from [`SlideApp::begin_external_file`]; the chosen file arrives through `inbox`. pub open_async: Option, /// Hand bytes to the user as a named file (browser download). pub download: Option, @@ -227,6 +363,10 @@ pub struct SlideApp { pub(crate) thumb_drag: Option<(usize, usize)>, /// Selection pane: folded groups, renaming, drag and drop. pub selpane: selpane::State, + /// Next cancellation identity for a browser pick or a dropped file. + next_file_id: u64, + /// Picks and drops whose bytes have not arrived yet. + pending_files: Vec, } impl SlideApp { @@ -267,6 +407,8 @@ impl SlideApp { ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, selpane: selpane::State::default(), + next_file_id: 1, + pending_files: Vec::new(), } } @@ -566,8 +708,11 @@ impl SlideApp { if let Some(path) = pick("open") { let _ = self.open_path(&path); } - } else if let Some(f) = self.services.open_async.as_mut() { - f("open"); + } else if self.services.open_async.is_some() { + let id = self.begin_external_file("open"); + if let Some(f) = self.services.open_async.as_mut() { + f("open", id); + } } } @@ -594,8 +739,11 @@ impl SlideApp { self.set_status(e.to_string()); } } - } else if let Some(f) = self.services.open_async.as_mut() { - f(purpose); + } else if self.services.open_async.is_some() { + let id = self.begin_external_file(purpose); + if let Some(f) = self.services.open_async.as_mut() { + f(purpose, id); + } } } @@ -678,42 +826,177 @@ impl SlideApp { fn drain_inbox(&mut self) { let Some(inbox) = self.services.inbox.clone() else { return }; - let files: Vec<(String, Vec)> = inbox.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default(); - for (name, bytes) in files { - self.open_or_insert_bytes(&name, &bytes); + let events = match inbox.lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), + }; + for event in events { + match event { + InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), + InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), + } } } - /// A dropped or picked file: presentations open, media is inserted. + /// Remember the open presentation, the slide (or master/layout) and the operation for a + /// file the host will read asynchronously. Returns the cancellation identity the host must + /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the + /// bytes arrive. + pub fn begin_external_file(&mut self, purpose: &str) -> u64 { + let id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); + id + } + + /// A dropped or picked file read synchronously: presentations open, media is inserted on + /// the slide that is current now. pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { - let lower = name.to_ascii_lowercase(); - let data = deckcraft_engine::cmd::base64_encode(bytes); - let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); - let cmd = if lower.ends_with(".deckcraft") - || lower.ends_with(".slidecraft") - || lower.ends_with(".pptx") - || lower.ends_with(".potx") - || lower.ends_with(".ppsx") - { - "file.openBytes" - } else if ct.starts_with("image/") { - "insert.picture" - } else if ct.starts_with("audio/") { - "insert.audio" - } else if ct.starts_with("video/") { - "insert.video" - } else if lower.ends_with(".txt") || lower.ends_with(".md") { - "slide.fromOutline" - } else { - self.set_status(format!("{name}: DeckCraft can't open this kind of file")); - return; + let id = self.begin_external_file("drop"); + self.apply_external_file(id, name, bytes); + } + + fn capture_file_target(&self) -> FileTarget { + let Some(d) = self.session.active() else { return FileTarget::Absent }; + let place = match d.selection.target { + Target::Slides => match d.current_slide() { + Some(s) => FilePlace::Slide(s.id), + None => FilePlace::Unspecified, + }, + Target::Master { master } => match d.doc.masters.get(master) { + Some(m) => FilePlace::Master(m.id), + None => FilePlace::Unspecified, + }, + Target::Layout { master, layout } => match d.doc.masters.get(master) { + Some(m) => match m.layouts.get(layout) { + Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, + None => FilePlace::Unspecified, + }, + None => FilePlace::Unspecified, + }, }; - let p = if cmd == "slide.fromOutline" { json!({"text": String::from_utf8_lossy(bytes)}) } else { json!({"name": name, "data": data}) }; - if self.session.active().is_none() && cmd != "file.openBytes" { - let _ = self.session.execute("file.new", &json!({})); + FileTarget::Document { doc: d.uid, place } + } + + /// True when the user is still looking at the place this request captured. + fn viewing_target(&self, target: &FileTarget) -> bool { + let FileTarget::Document { doc, place } = target else { return false }; + let Some(d) = self.session.active() else { return false }; + if d.uid != *doc { + return false; } - if let Err(e) = self.session.execute(cmd, &p) { - self.set_status(e.to_string()); + match place { + FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), + FilePlace::Master(id) => { + matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) + } + FilePlace::Layout { master, layout } => { + matches!(d.selection.target, Target::Layout { master: mi, layout: li } + if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) + } + FilePlace::Unspecified => false, + } + } + + fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { + let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; + let req = self.pending_files.remove(pos); + let cmd = match command_for(req.purpose, name, bytes) { + Ok(cmd) => cmd, + Err(e) => { + self.set_status(e); + return; + } + }; + let params = if cmd == "slide.fromOutline" { + json!({"text": String::from_utf8_lossy(bytes)}) + } else { + json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) + }; + let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { + self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) + } else { + self.execute_on_target(&req.target, name, cmd, ¶ms) + }; + if let Err(e) = result { + self.set_status(e); + } + self.drain_requests(); + } + + /// Run `cmd` on the document and slide captured for the request, then put the view back + /// if the user has since moved. A missing document or slide cancels the file. + fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { + let FileTarget::Document { doc, place } = target else { + self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; + return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + }; + let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { + return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); + }; + let view = self.session.active_index(); + let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); + self.session.set_active(idx); + let located = match self.locate_place(place) { + Ok(located) => located, + Err(e) => { + self.restore_file_view(view); + return Err(format!("{name} was not applied: {e}")); + } + }; + if let Some(st) = self.session.active_mut() { + st.selection.target = located.target; + if let Some(slide) = located.slide { + st.selection.slide = slide; + st.selection.slides.clear(); + } + st.selection.shapes.clear(); + st.selection.text = None; + st.selection.cells = None; + } + let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + if view == Some(idx) { + if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { + st.selection = sel; + } + } else { + self.restore_file_view(view); + } + result + } + + fn restore_file_view(&mut self, view: Option) { + if let Some(i) = view + && i < self.session.documents().len() + { + self.session.set_active(i); + } + } + + fn locate_place(&self, place: &FilePlace) -> Result { + let d = self.session.doc().map_err(|e| e.to_string())?; + match place { + FilePlace::Slide(id) => { + let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) + } + FilePlace::Master(id) => { + let master = + d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Master { master }, slide: None }) + } + FilePlace::Layout { master, layout } => { + let mi = + d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + let li = d + .doc + .masters + .get(mi) + .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) + .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) + } + FilePlace::Unspecified => Err("there was no slide to put it on".into()), } } @@ -770,7 +1053,7 @@ impl SlideApp { } } // On the web, dropped files can only be read asynchronously: the host reads them and - // delivers them through `Services::inbox`. + // delivers them through `Services::inbox`, tagged with the request captured at the drop. #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); @@ -1130,6 +1413,9 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod inbox_tests; + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] From 2a79cb76713396172ea3ec38419a0fb4587495e5 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:17:39 +0300 Subject: [PATCH 26/69] Shapes: smooth a rectangle's corners into a squircle (#286) deckcraft_geom::squircle builds a rectangle outline whose corners start curving earlier along the edge and keep a shorter circular arc, as design tools' corner smoothing does. shape.smoothCorners turns rectangles and rounded rectangles into that outline (custom geometry, so it round-trips through PPTX), and Edit Shape gains a Smooth Corners slider. Co-authored-by: Cursor --- crates/engine/src/cmd/shape.rs | 42 ++++++++ crates/engine/src/tests.rs | 29 ++++++ crates/geom/src/lib.rs | 1 + crates/geom/src/squircle.rs | 183 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 44 ++++++++ 5 files changed, 299 insertions(+) create mode 100644 crates/geom/src/squircle.rs diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..1b53f148 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -37,6 +37,15 @@ pub fn specs() -> Vec { cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip), cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust), cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change), + cmd!( + "shape.smoothCorners", + "Smooth Corners", + ["Shape Format", "Insert Shapes", "Edit Shape"], + None, + "{smoothing: 0..100 (%), radius?: pt (default: the rounded rectangle's own), ids?} → rectangle outline with smoothed (squircle) corners", + has_selection, + smooth_corners + ), cmd!( "shape.fill", "Shape Fill", @@ -216,6 +225,39 @@ fn adjust(s: &mut Session, p: &Value) -> Result { }) } +/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed +/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says. +fn smooth_corners(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "shape.smoothCorners"; + let smoothing = + f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0; + let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0)); + edit_geometry(s, p, CMD, |sh| { + let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) }; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0), + Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + let custom = matches!(sh.geom, Geom::Custom { .. }); + let r = match (radius, own) { + (Some(r), _) if own.is_some() || custom => r, + (None, Some(r)) => r, + _ => return Err(bad(CMD, "only rectangles and rounded rectangles can be smoothed (custom outlines need `radius`)")), + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: x.w, + h: x.h, + d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing), + ..Default::default() + }], + }; + Ok(()) + }) +} + fn change(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?; let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..3196d43e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,32 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// Smooth Corners (#286): a rounded rectangle becomes a squircle outline that survives PPTX. +#[test] +fn smooth_corners_makes_a_squircle_outline() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("shape.smoothCorners", &json!({"smoothing": 60})).unwrap(); + let d = |s: &Session| match &s.doc().unwrap().shape(id).unwrap().geom { + deckcraft_model::Geom::Custom { paths } => paths[0].d.clone(), + g => panic!("{g:?}"), + }; + // The default roundRect radius is 16.667% of the short side: 16.667 pt, smoothed 60%. + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6)); + // A custom outline takes a new smoothing with an explicit radius; without one it is refused. + s.execute("shape.smoothCorners", &json!({"smoothing": 100, "radius": 30})).unwrap(); + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 30.0, 1.0)); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6)); + // Other shapes are refused. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [0, 0, 50, 50]})).unwrap(); + assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err()); + // The outline round-trips through PPTX as custom geometry. + let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + let back = crate::cmd::file::open_presentation("x.pptx", &bytes).unwrap(); + let sh = back.slides.iter().flat_map(|sl| sl.shapes.iter()).find(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. })); + assert!(sh.is_some(), "a custom outline in the saved file"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index dd41db37..3be72e03 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -8,6 +8,7 @@ pub mod morph; pub mod preset; +pub mod squircle; pub mod warp; pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; diff --git a/crates/geom/src/squircle.rs b/crates/geom/src/squircle.rs new file mode 100644 index 00000000..8dedc972 --- /dev/null +++ b/crates/geom/src/squircle.rs @@ -0,0 +1,183 @@ +//! Rectangles with smoothed ("squircle") corners, as design tools offer next to the corner radius +//! (#286). A plain rounded corner joins the straight edge with a sudden change of curvature; a +//! smoothed corner starts curving earlier, along a cubic Bézier, and keeps a shorter circular arc +//! in the middle, so the outline flows into the edge. +//! +//! For a corner of radius `r` and smoothing `s` (0 = circular, 1 = no arc left), the corner takes +//! `p = (1 + s) · r` of each edge. The arc keeps `90° · (1 − s)` around the corner's diagonal and +//! each side of it is a cubic whose handles lie on the edge, so the curve is tangent to the edge +//! where it starts and to the arc where it meets it. When `p` would exceed half the shorter side, +//! the smoothing is reduced first, then the radius. The construction is our own derivation from +//! these constraints. + +/// The outline of a `w` x `h` rectangle with corners of radius `radius` smoothed by `smoothing` +/// (0..=1), as path data in the rectangle's own coordinates (`M`, `L`, `C`, `Z`; y down). +pub fn path_d(w: f64, h: f64, radius: f64, smoothing: f64) -> String { + let w = if w.is_finite() { w.max(0.0) } else { 0.0 }; + let h = if h.is_finite() { h.max(0.0) } else { 0.0 }; + let half = w.min(h) / 2.0; + let r = if radius.is_finite() { radius.clamp(0.0, half) } else { 0.0 }; + let mut s = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; + if r <= 0.0 { + return format!("M 0 0 L {w} 0 L {w} {h} L 0 {h} Z"); + } + s = s.min(half / r - 1.0).max(0.0); + let c = corner(r, s); + // The top-right corner in local terms: `u` runs back along the top edge from the corner, + // `v` down the right edge. Each corner maps (u, v) to the rectangle by symmetry. + let corners: [&dyn Fn(f64, f64) -> (f64, f64); 4] = [ + &|u, v| (w - u, v), // top right: from the top edge to the right edge + &|u, v| (w - v, h - u), // bottom right: from the right edge to the bottom edge + &|u, v| (u, h - v), // bottom left: from the bottom edge to the left edge + &|u, v| (v, u), // top left: from the left edge to the top edge + ]; + let mut d = String::new(); + let pt = |f: &dyn Fn(f64, f64) -> (f64, f64), (u, v): (f64, f64)| { + let (x, y) = f(u, v); + format!("{} {}", round(x), round(y)) + }; + for (i, f) in corners.iter().enumerate() { + let segs = c.segments(); + if i == 0 { + d.push_str(&format!("M {}", pt(*f, segs[0]))); + } else { + d.push_str(&format!(" L {}", pt(*f, segs[0]))); + } + for k in 0..3 { + let b = 1 + k * 3; + d.push_str(&format!(" C {} {} {}", pt(*f, segs[b]), pt(*f, segs[b + 1]), pt(*f, segs[b + 2]))); + } + } + d.push_str(" Z"); + d +} + +fn round(v: f64) -> f64 { + (v * 1000.0).round() / 1000.0 + 0.0 +} + +/// One corner in (u, v) terms: distance back along the incoming edge, distance along the outgoing. +struct Corner { + p: f64, + a: f64, + b: f64, + c: f64, + d: f64, + r: f64, + arc: f64, +} + +fn corner(r: f64, s: f64) -> Corner { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + let p = (1.0 + s) * r; + let arc = FRAC_PI_2 * (1.0 - s); + // The arc's chord, and where the edge curve meets the arc. + let arc_chord = (arc / 2.0).sin() * r * std::f64::consts::SQRT_2; + let alpha = (FRAC_PI_2 - arc) / 2.0; + let p3_p4 = r * (alpha / 2.0).tan(); + let beta = FRAC_PI_4 * s; + let c = p3_p4 * beta.cos(); + let d = c * beta.tan(); + let b = ((p - arc_chord - c - d) / 3.0).max(0.0); + let a = 2.0 * b; + Corner { p, a, b, c, d, r, arc } +} + +impl Corner { + /// Ten points: the start on the incoming edge, then three cubics (control, control, end): + /// edge → arc start, the arc, arc end → outgoing edge. + fn segments(&self) -> [(f64, f64); 10] { + let Corner { p, a, b, c, d, r, arc } = *self; + // Edge curve into the arc: handles on the edge, ending where the arc starts. + let e0 = (p, 0.0); + let e1 = (p - a, 0.0); + let e2 = (p - a - b, 0.0); + let e3 = (p - a - b - c, d); + // The arc around the centre (r, r) in (u, v), symmetric about the corner's diagonal. + // Angles from +u towards +v: the corner itself is at 5π/4 from the centre; the arc runs + // from the incoming edge's side (larger angle) to the outgoing edge's side. + let start = std::f64::consts::FRAC_PI_4 * 5.0 + arc / 2.0; + let end = start - arc; + let at = |t: f64| (r + r * t.cos(), r + r * t.sin()); + let k = 4.0 / 3.0 * (arc / 4.0).tan() * r; + let a0 = at(start); + let a3 = at(end); + // Tangent of the arc travelled towards smaller angles: (sin t, −cos t). + let a1 = (a0.0 + k * start.sin(), a0.1 - k * start.cos()); + let a2 = (a3.0 - k * end.sin(), a3.1 + k * end.cos()); + // Mirror of the edge curve onto the outgoing edge (swap u and v), reversed. + let o1 = (e2.1, e2.0); + let o2 = (e1.1, e1.0); + let o3 = (e0.1, e0.0); + let _ = (e3, a0); + [e0, e1, e2, a0, a1, a2, a3, o1, o2, o3] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn numbers(d: &str) -> Vec { + d.split_whitespace().filter_map(|t| t.parse().ok()).collect() + } + + #[test] + fn no_radius_is_a_rectangle() { + assert_eq!(path_d(100.0, 50.0, 0.0, 0.6), "M 0 0 L 100 0 L 100 50 L 0 50 Z"); + } + + #[test] + fn edge_curve_meets_the_arc() { + for s in [0.0, 0.3, 0.6, 1.0] { + let c = corner(10.0, s); + let seg = c.segments(); + let e3 = (c.p - c.a - c.b - c.c, c.d); + // The arc starts on the circle and where the edge curve ends. + assert!((seg[3].0 - e3.0).abs() < 1e-6 && (seg[3].1 - e3.1).abs() < 1e-6, "s={s}: {:?} vs {:?}", seg[3], e3); + // The arc is symmetric about the diagonal u = v. + assert!((seg[3].0 - seg[6].1).abs() < 1e-9 && (seg[3].1 - seg[6].0).abs() < 1e-9); + } + } + + #[test] + fn zero_smoothing_is_a_circular_corner() { + let c = corner(10.0, 0.0); + let seg = c.segments(); + assert!((c.p - 10.0).abs() < 1e-9); + assert!((seg[3].0 - 10.0).abs() < 1e-9 && seg[3].1.abs() < 1e-9); + assert!((seg[6].0).abs() < 1e-9 && (seg[6].1 - 10.0).abs() < 1e-9); + } + + #[test] + fn smoothing_uses_more_of_the_edge() { + let d0 = numbers(&path_d(200.0, 100.0, 20.0, 0.0)); + let d6 = numbers(&path_d(200.0, 100.0, 20.0, 0.6)); + // The first point is where the top-right corner starts on the top edge. + assert_eq!((d0[0], d0[1]), (180.0, 0.0)); + assert_eq!((d6[0], d6[1]), (168.0, 0.0)); + } + + #[test] + fn stays_inside_and_reduces_smoothing_when_the_rectangle_is_small() { + for (w, h, r, s) in [(100.0, 40.0, 20.0, 1.0), (40.0, 40.0, 50.0, 0.8), (10.0, 300.0, 4.0, 0.6), (300.0, 120.0, 30.0, 0.6)] { + let n = numbers(&path_d(w, h, r, s)); + assert!(n.chunks(2).all(|p| p[0] >= -1e-6 && p[0] <= w + 1e-6 && p[1] >= -1e-6 && p[1] <= h + 1e-6), "{w}x{h} r{r} s{s}"); + // Corners never cross the middle of an edge. + assert!(n[0] >= w / 2.0 - 1e-6); + } + assert!(numbers(&path_d(f64::NAN, 10.0, f64::INFINITY, f64::NAN)).iter().all(|v| v.is_finite())); + } + + #[test] + fn four_symmetric_corners() { + let n = numbers(&path_d(120.0, 80.0, 16.0, 0.6)); + // 4 corners x 10 points, 2 numbers each. + assert_eq!(n.len(), 80); + let pts: Vec<(f64, f64)> = n.chunks(2).map(|p| (p[0], p[1])).collect(); + for &(x, y) in &pts { + let mirrored = (120.0 - x, 80.0 - y); + assert!(pts.iter().any(|q| (q.0 - mirrored.0).abs() < 0.01 && (q.1 - mirrored.1).abs() < 0.01), "{x},{y}"); + } + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..ad8a814f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1891,6 +1891,8 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); } + ui.separator(); + smooth_corners_row(app, ui); }); let can_merge = enabled(app, "shape.merge"); let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); @@ -2420,3 +2422,45 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a +/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui +/// memory, per shape) so later moves re-smooth the same corners. +fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { + let Some((id, radius)) = app.session.active().and_then(|d| { + let id = *d.selection.shapes.first()?; + let sh = d.shape(id)?; + let x = sh.xfrm?; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => { + Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0) + } + deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + Some((id, own)) + }) else { + return; + }; + let key = egui::Id::new(("deckcraft-smooth-corners", id.0)); + let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); + if let Some(r) = radius { + state.0 = r; + } + let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); + ui.horizontal(|ui| { + ui.label("Smooth Corners"); + let mut pct = state.1; + let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()); + let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; + if resp.changed() { + state.1 = pct; + ui.data_mut(|d| d.insert_temp(key, state)); + } + // One undo step per adjustment: apply when the drag ends (or on a click / keyboard step). + if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0})); + } + }); +} From ad24f545e2c1d5c491c55e0a164e076726a23845 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:09:33 +0300 Subject: [PATCH 27/69] Format Shape: picture or texture fill options shape.fill gains pictureProps for a picture fill: tile on/off, tile offset X/Y (pt), scale X/Y, alignment and mirror type, stretch offsets (left/top/right/bottom), and rotate with shape. `transparency` now also sets a picture fill's opacity. Values are clamped and unknown keywords or non-finite numbers ignored. The Fill page of the Format Shape pane gets a "Picture or texture fill" option with a picture source button and these controls. The model, renderer and PPTX reader/writer already carry the fields; a new engine test checks they survive a PPTX save and reopen. Co-authored-by: Cursor --- crates/engine/src/cmd/shape.rs | 65 +++++++++++++++++++-- crates/engine/src/tests.rs | 40 +++++++++++++ crates/ui-egui/src/panes.rs | 104 ++++++++++++++++++++++++++++++++- docs/ui-parity.md | 2 +- 4 files changed, 203 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..ea5a0940 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -2,7 +2,7 @@ //! names and alt text. use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; -use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow}; +use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow}; use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle}; use serde_json::{Value, json}; @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}", + "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill", has_selection, fill ), @@ -290,8 +290,9 @@ fn fill(s: &mut Session, p: &Value) -> Result { None }; let transparency = f64_param(p, "transparency"); - if new_fill.is_none() && transparency.is_none() { - return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`")); + let pic_props = p.get("pictureProps").filter(|v| v.is_object()); + if new_fill.is_none() && transparency.is_none() && pic_props.is_none() { + return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`")); } // Transparency on an inherited (style) fill needs the effective colour. let st = s.doc()?; @@ -319,6 +320,14 @@ fn fill(s: &mut Session, p: &Value) -> Result { if let Some(f) = &new_fill { sh.fill.clone_from(f); } + if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) { + picture_props(pf, pp); + } + if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); + return Ok(()); + } if let Some(t) = transparency { let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone())); if let Some(Fill::Solid { mut color }) = base { @@ -333,6 +342,54 @@ fn fill(s: &mut Session, p: &Value) -> Result { }) } +/// Applies Format Shape ▸ Picture or texture fill options to a picture fill. +fn picture_props(pf: &mut PictureFill, pp: &Value) { + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); + } + } + } + } + } +} + fn line_end(v: &str) -> Option { if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..1b8e3453 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,43 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn shape_picture_fill_options_tile_offset_and_round_trip() { + use deckcraft_model::style::PictureMode; + let mut img = image::RgbaImage::new(4, 4); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let pf = |s: &Session| match s.doc().unwrap().selected_shapes()[0].fill.clone() { + Some(deckcraft_model::Fill::Picture(pf)) => pf, + other => panic!("{other:?}"), + }; + // Options without a picture fill are a no-op, not an error. + s.execute("shape.fill", &json!({"pictureProps": {"tile": true}})).unwrap(); + s.execute("shape.fill", &json!({"picture": cmd::base64_encode(&png)})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"stretch": [0.1, 0.2, 5, "x"]}})).unwrap(); + assert_eq!(pf(&s).mode, PictureMode::Stretch { fill_rect: [0.1, 0.2, 0.99, 0.0] }); + s.execute("shape.fill", &json!({"pictureProps": {"tile": true, "offsetX": 12, "offsetY": -3, "scaleX": 0.5, "scaleY": 50, "align": "ctr", "mirror": "xy", "rotateWithShape": false}})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"align": "bogus", "offsetX": f64::NAN}, "transparency": 0.4})).unwrap(); + let want = PictureMode::Tile { tx: 12.0, ty: -3.0, sx: 0.5, sy: 10.0, flip: "xy".into(), align: "ctr".into() }; + let got = pf(&s); + assert_eq!(got.mode, want); + assert!(got.upright); + assert!((got.alpha.unwrap() - 0.6).abs() < 1e-9); + // PPTX round trip keeps the options. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "p.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let doc = &s.doc().unwrap().doc; + let back = doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.fill { + Some(deckcraft_model::Fill::Picture(pf)) => Some(pf.clone()), + _ => None, + }); + let back = back.unwrap(); + assert_eq!(back.mode, want); + assert!(back.upright); + assert!((back.alpha.unwrap() - 0.6).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..c56528a6 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -262,9 +262,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Pattern(_)) => "pattern", _ => "auto", }; - for (l, k) in - [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")] - { + for (l, k) in [ + ("No fill", "none"), + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Automatic", "auto"), + ] { if ui.radio(kind == k, l).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), @@ -275,6 +280,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), ), "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), + "picture" => pick_fill_picture(app), _ => run(app, "shape.fill", json!({"reset": true})), } } @@ -293,6 +299,9 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0})); } }); + if let Some(Fill::Picture(pf)) = &sh.fill { + picture_fill_options(app, ui, sh.id.0, pf); + } if let Some(Fill::Gradient(g)) = &sh.fill { let sc = scheme(app); if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { @@ -339,6 +348,95 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } +/// Asks for a picture file and makes it the selected shapes' fill. +fn pick_fill_picture(app: &mut SlideApp) { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } +} + +/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}` +/// when it changes. +fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); + } + }); +} + +/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency. +fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + ui.horizontal(|ui| { + ui.label("Picture source"); + if ui.button("Insert…").clicked() { + pick_fill_picture(app); + } + }); + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); + } + match &pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0); + pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0); + pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01); + pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01); + let aligns = [ + ("tl", "Top left"), + ("t", "Top"), + ("tr", "Top right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom left"), + ("b", "Bottom"), + ("br", "Bottom right"), + ]; + let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; + for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); + egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + for (k, l) in opts { + if ui.selectable_label(*k == cur.as_str(), *l).clicked() { + run(app, "shape.fill", json!({"pictureProps": {key: k}})); + } + } + }); + }); + } + } + PictureMode::Stretch { fill_rect } => { + for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = fill_rect[i] * 100.0; + let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let mut rect = *fill_rect; + rect[i] = v / 100.0; + run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}})); + } + }); + } + } + } + let mut rot = !pf.upright; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); + } +} + fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..24504b0e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -58,7 +58,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D | | Format Background pane | yes | yes | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | From a7bc932cc84b5ffe0de72da6799e3f486f009023 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:38:16 +0300 Subject: [PATCH 28/69] UI: WordArt adjust handles drag a text warp A selected text warp shows one yellow handle per adjust. Dragging it sets that guide value in one undo step. Co-authored-by: Cursor --- ROADMAP.md | 3 +- crates/engine/src/tests.rs | 40 ++++++++ crates/engine/src/tools.rs | 88 +++++++++++++++- crates/geom/src/warp.rs | 188 ++++++++++++++++++++++++++++++++++ crates/geom/src/warp/tests.rs | 40 ++++++++ crates/ui-egui/src/canvas.rs | 6 +- docs/gaps.md | 16 +-- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 4 +- 10 files changed, 380 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..588977b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) | | 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 | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..31290a11 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,43 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn wordart_adjust_handle_drags_and_undoes() { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + let id = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 60.0, 420.0, 200.0], "text": "ARCHED"})).unwrap()["id"].as_u64().unwrap(); + let sid = model::ShapeId(id as u32); + { + let st = s.doc_mut().unwrap(); + let slide = st.selection.slide; + let mut doc = (*st.doc).clone(); + let sh = model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[slide]).shapes, sid).unwrap(); + sh.text.as_mut().unwrap().body.warp = Some("textArchUp".into()); + st.doc = std::sync::Arc::new(doc); + } + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let (at, top) = { + let st = s.doc().unwrap(); + let sh = st.shape(sid).unwrap(); + let x = cmd::xfrm_of(&st.doc, &st.selection, sh); + let hs = tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h); + assert_eq!(hs.len(), 1); + let at = x.affine() * hs[0].pos; + (at, deckcraft_geom::Point::new(x.center().x, x.y)) + }; + assert!(matches!(s.hit_test(at, 4.0), Some(tools::Hit::Warp { index: 0, .. }))); + let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 4.0 }; + s.pointer(ev(PointerKind::Down, at)).unwrap(); + s.pointer(ev(PointerKind::Drag, top)).unwrap(); + s.pointer(ev(PointerKind::Up, top)).unwrap(); + let adj = s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body.warp_adj.clone(); + let deg = adj.first().copied().unwrap_or(0.0) / 60_000.0; + assert!((deg - 270.0).abs() < 2.0, "{deg} from {adj:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let body = &s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body; + assert_eq!(body.warp.as_deref(), Some("textArchUp")); + assert!(body.warp_adj.is_empty(), "undo restores the default adjusts"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..61939c40 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -76,6 +76,11 @@ pub enum Hit { id: ShapeId, index: usize, }, + /// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`. + Warp { + id: ShapeId, + index: usize, + }, /// Line end 0/1 of a line-like shape. LineEnd { id: ShapeId, @@ -119,6 +124,14 @@ enum Drag { index: usize, orig: Xfrm, }, + /// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults). + Warp { + id: ShapeId, + index: usize, + name: String, + base: Vec, + orig: Xfrm, + }, Marquee { start: Point, add: bool, @@ -204,6 +217,45 @@ pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) { (pts, rot) } +/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the +/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on. +pub fn wordart_handles( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + sh: &Shape, + w: f64, + h: f64, +) -> Vec { + if sh.text.is_none() { + return Vec::new(); + } + let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() }; + let bp = deckcraft_model::resolve::body(&ctx, sh); + let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() }; + let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect; + deckcraft_geom::warp::handles(name, &bp.warp_adj, tr) +} + +/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults). +fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) { + let Some(text) = sh.text.as_mut() else { return }; + if !value.is_finite() || index > 7 { + return; + } + let defaults = deckcraft_geom::warp::adjust_defaults(name); + let n = defaults.len().max(index + 1).min(8); + if n == 0 { + return; + } + let mut adj = Vec::with_capacity(n); + for i in 0..n { + let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) }; + adj.push(v); + } + text.body.warp = Some(name.to_string()); + text.body.warp_adj = adj; +} + impl Session { pub fn set_tool(&mut self, kind: ToolKind) { self.tool.kind = kind; @@ -239,6 +291,11 @@ impl Session { return Some(Hit::Adjust { id: *id, index: hd.index }); } } + for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Warp { id: *id, index: hd.index }); + } + } let (pts, rot) = box_handles(&x); if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { return Some(Hit::Rotate { id: *id }); @@ -370,7 +427,7 @@ impl Session { { let x = xfrm_of(&st.doc, &st.selection, sh); if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) + && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. })) { let pos = text_pos(st, sh, p); let shift = mods.shift; @@ -412,6 +469,18 @@ impl Session { self.begin("Adjust Shape"); self.tool.drag = Some(Drag::Adjust { id, index, orig }); } + Some(Hit::Warp { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + let found = st.shape(id).and_then(|sh| { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; + let bp = deckcraft_model::resolve::body(&ctx, sh); + bp.warp.map(|n| (n, bp.warp_adj)) + }); + if let Some((name, base)) = found { + self.begin("Adjust WordArt"); + self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); + } + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -601,6 +670,23 @@ impl Session { Ok(()) })?; } + Drag::Warp { id, index, name, base, orig } => { + let v = { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; + let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; + let hs = deckcraft_geom::warp::handles(&name, &base, tr); + let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + h.value_at(orig.affine().inverse() * p) + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + set_warp_adj(sh, &name, &base, index, v); + } + Ok(()) + })?; + } Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index acdee999..25eb104f 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -234,6 +234,194 @@ pub fn is_supported(name: &str) -> bool { !adjust_defaults(name).is_empty() } +/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect` +/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the +/// text rectangle's centre for an angle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpHandle { + pub index: usize, + pub pos: Point, + kind: HandleKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum HandleKind { + /// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps. + Line { origin: Point, dir: Vec2, min: f64, max: f64 }, + /// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`. + Angle { center: Point, invert: bool }, +} + +impl WarpHandle { + /// The raw guide value (`adj` / `adjN`) for a pointer at `p`. + pub fn value_at(&self, p: Point) -> f64 { + match self.kind { + HandleKind::Line { origin, dir, min, max } => { + let l2 = dir.hypot2(); + if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) { + return min; + } + let v = (p - origin).dot(dir) / l2; + if !v.is_finite() { + return min; + } + let (lo, hi) = (min.min(max), max.max(min)); + v.clamp(lo, hi).round() + } + HandleKind::Angle { center, invert } => { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return 0.0; + } + let mut deg = d.y.atan2(d.x).to_degrees(); + if invert { + deg = 180.0 - deg; + } + (deg.rem_euclid(360.0) * 60_000.0).round() + } + } + } +} + +fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 { + adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) +} + +fn unit(rect: Rect, u: f64, v: f64) -> Point { + Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height()) +} + +/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`. +fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, x, sign * frac), + kind: HandleKind::Line { + origin: unit(rect, x, 0.0), + dir: Vec2::new(0.0, sign * rect.height() / 100_000.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// A fraction handle that slides horizontally: unit `(frac, y)`. +fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, frac, y), + kind: HandleKind::Line { + origin: unit(rect, 0.0, y), + dir: Vec2::new(rect.width() / 100_000.0, 0.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle. +fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle { + let geom = if invert { 180.0 - deg } else { deg }; + let rad = geom.to_radians(); + let c = rect.center(); + WarpHandle { + index, + pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()), + kind: HandleKind::Angle { center: c, invert }, + } +} + +/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse. +fn radius_handle(rect: Rect, r: f64) -> WarpHandle { + let r = r.clamp(0.0, 0.99); + WarpHandle { + index: 1, + pos: unit(rect, 0.5, 0.5 + 0.5 * r), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 }, + } +} + +/// Wave phase: slides sideways around the middle of the lower half. +fn shift_handle(rect: Rect, s: f64) -> WarpHandle { + let s = s.clamp(-0.1, 0.1); + WarpHandle { + index: 1, + pos: unit(rect, 0.5 + s, 0.78), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 }, + } +} + +/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn +/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults. +pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return Vec::new(); + } + let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + if rect.width() < 1e-3 || rect.height() < 1e-3 { + return Vec::new(); + } + let defaults = adjust_defaults(name); + if defaults.is_empty() { + return Vec::new(); + } + let raw = |i| stored_raw(adj, defaults, i); + let frac = |i| raw(i) / 100_000.0; + let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0); + let y = |out: &mut Vec, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign)); + let xh = |out: &mut Vec, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi)); + let mut out = Vec::with_capacity(defaults.len()); + match name { + "textPlain" => xh(&mut out, 0.28, 0.3, 0.7), + "textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0), + "textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0), + "textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0), + "textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0), + "textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0), + "textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999), + "textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999), + "textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0), + "textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0), + "textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0), + "textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0), + "textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0), + "textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0), + "textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0), + "textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0), + "textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0), + "textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0), + "textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0), + "textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0), + "textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0), + "textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)), + "textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)), + "textArchUpPour" | "textCirclePour" | "textButtonPour" => { + out.push(angle_handle(rect, 0, deg(0), false)); + out.push(radius_handle(rect, frac(1))); + } + "textArchDownPour" => { + out.push(angle_handle(rect, 0, deg(0), true)); + out.push(radius_handle(rect, frac(1))); + } + "textWave1" | "textWave2" => { + y(&mut out, 0.32, 0.0, 0.2, 1.0); + out.push(shift_handle(rect, frac(1))); + } + "textDoubleWave1" | "textWave4" => { + y(&mut out, 0.32, 0.0, 0.125, 1.0); + out.push(shift_handle(rect, frac(1))); + } + _ => return Vec::new(), + } + out +} + /// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). #[derive(Clone, Copy, Debug)] enum Seg { diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index 2d18b13a..2d56d210 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -307,3 +307,43 @@ fn hostile_adjusts_and_boxes_stay_finite() { } assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); } + +#[test] +fn every_preset_has_one_handle_per_adjust() { + let r = rect(); + for name in ALL { + let hs = handles(name, &[], r); + assert_eq!(hs.len(), adjust_count(name), "{name}"); + let defaults = adjust_defaults(name); + for h in &hs { + assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}"); + let back = h.value_at(h.pos); + let want = defaults[h.index]; + assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index); + } + } + assert!(handles("textNoShape", &[], r).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty()); + // A hostile adjust still places a finite handle on the clamped default. + let hs = handles("textInflate", &[f64::NAN], r); + assert_eq!(hs.len(), 1); + assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite()); +} + +#[test] +fn arch_and_inflate_handles_follow_the_pointer() { + let r = rect(); + let arch = &handles("textArchUp", &[], r)[0]; + let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0; + assert!((deg - 270.0).abs() < 0.5, "{deg}"); + let down = &handles("textArchDown", &[], r)[0]; + // The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90 + // (guide angle 180 − adj). + let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0; + assert!((deg - 90.0).abs() < 0.5, "{deg}"); + let inf = &handles("textInflate", &[], r)[0]; + let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0)); + assert!(more > adjust_defaults("textInflate")[0], "{more}"); + assert!((more - 20_000.0).abs() < 1.0, "{more}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..ed86f6d1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -404,7 +404,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, (_, Some(Hit::Shape { text: true, .. })) => { if app.session.active().is_some_and(|d| d.selection.text.is_some()) { egui::CursorIcon::Text @@ -484,6 +484,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let q = xf.to_screen(a * hd.pos); painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); } + for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } } } // Caret / text selection. diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..7459dbe1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; 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) 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 @@ -166,12 +166,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 - `prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or - command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by - 1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting - render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, - only the three-point rig, and no command or pane to apply 3-D). -- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, + `prstTxWarp` presets render, their adjusts round-trip, and a selected warp shows a yellow handle + per adjust that dragging sets; still no Transform gallery or command to apply one, and arc/circle + placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation" + (partial: bevels, camera presets and rotations and face lighting render as approximations and + `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, only the three-point rig, and + no command or pane to apply 3-D). +- **Estimate:** 4–6 h (edit points), 1–2 h (Transform gallery and command), 6–10 h (3-D extrusion, rigs and materials, Format Shape page). ### Proofing: spelling, thesaurus, AutoCorrect @@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/roadmap.md b/docs/roadmap.md index 97953034..5a766343 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps: adjust handles drag; gallery and command remain) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -79,7 +79,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as | M6 | Transitions and animations | mostly | Animation Pane timeline, remaining effects, transition sounds | | M7 | Slide show | mostly | second-display placement, laser, record | | M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table | -| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | +| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp gallery and command | | M10 | Views | done | grid options, New Window | | M11 | Output | partly | vector PDF, SVG, GIF, MP4, print | | M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare | @@ -109,5 +109,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..65e12820 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (text-warp shortfall is the gallery and command; adjust handles drag) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -184,7 +184,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | | Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) | -| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) | +| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp gallery and command, WordArt depth | [ui-parity.md](ui-parity.md) | | Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | | Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) | | Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..83f4a455 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -79,6 +79,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | | Edit points on freeforms | yes | **no** | +| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes | @@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | WordArt adjust handles on a selected text warp; dragging one sets that `prstTxWarp` adjust | | 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 | From 10ad8d7ba7487d847a7709b3059638ae668129dd Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:44:42 +0300 Subject: [PATCH 29/69] UI: keep editor slides on the Retina pixel grid The editing canvas, thumbnails and layout previews were drawn between physical pixels, so a linear filter blurred small text. The slide show draws much larger glyphs, which is why presenting still looked sharp (#184). Co-authored-by: Cursor --- ROADMAP.md | 2 + crates/ui-egui/src/canvas.rs | 7 ++-- crates/ui-egui/src/panes.rs | 17 +++++--- crates/ui-egui/src/ribbon.rs | 10 +++-- crates/ui-egui/src/sorter.rs | 9 +++-- crates/ui-egui/src/textures.rs | 74 ++++++++++++++++++++++++++++++++-- crates/ui-egui/src/thumbs.rs | 15 ++++--- docs/hardware-parity.md | 5 ++- docs/ui-parity.md | 9 +++-- 9 files changed, 117 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..36ab29a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..8b78b903 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -95,14 +95,15 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); ui.data_mut(|d| d.insert_temp(scroll_id, offset)); - let slide_rect = content_rect.translate(-offset); + // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). + let ppp = ui.ctx().pixels_per_point(); + let slide_rect = crate::textures::device_pixel_rect(content_rect.translate(-offset), ppp); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; let xf = Xf { origin: slide_rect.min, scale }; let painter = ui.painter_at(avail); // Shadow + slide. painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow); - let ppp = ui.ctx().pixels_per_point(); let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); let now = ui.input(|i| i.time); let gifs = match app.session.active() { @@ -320,7 +321,7 @@ fn master_tex( layout, &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region)) + Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), crate::textures::editor_texture_options()), region)) } fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..80af92f6 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -613,18 +613,23 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { m.theme = th.clone(); } let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp) as u32; - let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; + let (r, resp) = ui.allocate_exact_size(vec2(w, w * (d.slide_size.height / d.slide_size.width.max(1.0)) as f32), Sense::click()); + let at = crate::textures::device_pixel_rect(r, ppp); + let px = (at.width() * ppp).round().max(1.0) as u32; + let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).round().max(1.0) as u32; let img = deckcraft_render::render_slide( &d, idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click()); - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let tex = ui.ctx().load_texture( + format!("idea-{}", th.name), + crate::textures::to_color_image(&img, false), + crate::textures::editor_texture_options(), + ); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke( - r, + at, CornerRadius::same(2), Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), egui::StrokeKind::Outside, diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..12766d37 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -339,9 +339,13 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); - ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let tex = ui.ctx().load_texture( + format!("layout-{li}"), + crate::textures::to_color_image(&img, false), + crate::textures::editor_texture_options(), + ); + let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke( diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd85..c416d1b0 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -32,10 +32,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { break; } let (cell, resp) = ui.allocate_exact_size(vec2(tw + 36.0, th + 36.0), Sense::click_and_drag()); - let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th)); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th)), ppp); rects.push((i, cell)); let p = ui.painter(); - match app.textures.thumb(ui.ctx(), &doc, i, (tw * ppp) as u32, &mut budget) { + match app.textures.thumb(ui.ctx(), &doc, i, (img.width() * ppp).round() as u32, &mut budget) { Some(tex) => { p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } @@ -123,10 +123,11 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { ui.painter().rect_filled(page, CornerRadius::ZERO, Color32::WHITE); let sw = page_w * 0.78; let sh = sw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; - let srect = Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh)); let ppp = ui.ctx().pixels_per_point(); + let srect = + crate::textures::device_pixel_rect(Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh)), ppp); let mut budget = 2; - if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) { + if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) { ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside); diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..3304b516 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use deckcraft_geom::Rect; use deckcraft_model::{Master, Presentation, ShapeId, Slide}; use deckcraft_render::{Image, Region, RenderOpts}; -use egui::{ColorImage, TextureHandle, TextureOptions}; +use egui::{ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode}; #[derive(Clone, PartialEq)] struct Key { @@ -50,6 +50,39 @@ pub struct Textures { pub last_render_ms: f64, } +/// Snap `rect` onto the physical pixel grid. +/// +/// The editor draws the slide as a texture of device pixels. A rect that starts or ends +/// between pixels is sampled with a linear filter, so every glyph edge is a blend of two +/// texels and small text looks soft. On a Retina display that offset is half a point, which +/// is the whole edge of an editing-size glyph. The slide show draws much larger glyphs, so +/// the same blend is not visible there (#184). +pub fn device_pixel_rect(rect: egui::Rect, ppp: f32) -> egui::Rect { + if !ppp.is_finite() || ppp <= 0.0 || !rect.is_finite() { + return rect; + } + let snap = |v: f32| { + let p = v * ppp; + if p.is_finite() { p.round() / ppp } else { v } + }; + let min = egui::pos2(snap(rect.min.x), snap(rect.min.y)); + let step = 1.0 / ppp; + let max = egui::pos2(snap(rect.max.x).max(min.x + step), snap(rect.max.y).max(min.y + step)); + egui::Rect::from_min_max(min, max) +} + +/// Editor slide textures. Nearest magnification keeps a texel that is stretched by a +/// fraction of a pixel from blurring the glyph; minification still blends when many texels +/// share a screen pixel. +pub fn editor_texture_options() -> TextureOptions { + TextureOptions { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + } +} + pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { let w = img.width as usize; let h = img.height as usize; @@ -153,10 +186,10 @@ impl Textures { let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { Some(mut e) => { - e.tex.set(ci, TextureOptions::LINEAR); + e.tex.set(ci, editor_texture_options()); e.tex } - None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), + None => ctx.load_texture("slide-canvas", ci, editor_texture_options()), }; self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 }); Some((tex, region)) @@ -199,7 +232,7 @@ impl Textures { *budget -= 1; let scale = width_px as f64 / p.slide_size.width.max(1.0); let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); - let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); + let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), editor_texture_options()); // Drop the old entry for this slide address; entries for slides that no longer exist age out. let region = key.want; self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock }); @@ -213,3 +246,36 @@ impl Textures { Some(tex) } } + +#[cfg(test)] +mod tests { + use super::device_pixel_rect; + use egui::{Rect, pos2, vec2}; + + fn on_grid(v: f32, ppp: f32) -> bool { + let px = v * ppp; + (px - px.round()).abs() < 1e-3 + } + + #[test] + fn a_retina_slide_rect_lands_on_device_pixels() { + let ppp = 2.0; + let r = Rect::from_min_size(pos2(10.3, 20.25), vec2(100.2, 50.4)); + let s = device_pixel_rect(r, ppp); + assert!(on_grid(s.min.x, ppp) && on_grid(s.min.y, ppp) && on_grid(s.max.x, ppp) && on_grid(s.max.y, ppp)); + assert!(s.width() > 0.0 && s.height() > 0.0); + // One texture pixel per device pixel: the snapped size is an integer pixel count. + let px = ((s.width() * ppp).round() as u32, (s.height() * ppp).round() as u32); + assert!((s.width() / px.0 as f32 * ppp - 1.0).abs() < 1e-3); + assert!((s.height() / px.1 as f32 * ppp - 1.0).abs() < 1e-3); + let aligned = Rect::from_min_max(pos2(1.0, 2.5), pos2(80.0, 40.0)); + assert_eq!(device_pixel_rect(aligned, ppp), aligned); + } + + #[test] + fn a_bad_scale_does_not_move_the_rect() { + let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0)); + assert_eq!(device_pixel_rect(r, 0.0), r); + assert_eq!(device_pixel_rect(r, f32::NAN), r); + } +} diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index ebdc81d0..38e6d4ca 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -86,7 +86,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { }); } let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag()); - let img = Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)), ppp); rects.push((i, row)); let p = ui.painter(); let id = doc.slides.get(i).map(|s| s.id); @@ -106,7 +106,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { if has_anim { p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint); } - let wpx = (tw * ppp).round() as u32; + let wpx = (img.width() * ppp).round() as u32; match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) { Some(tex) => { p.image( @@ -268,9 +268,9 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 }; let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click()); - let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)); let ppp = ui.ctx().pixels_per_point(); - let px = ((tw * ppp) as u32).max(1); + let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)), ppp); + let px = ((img.width() * ppp).round() as u32).max(1); let pic = deckcraft_render::render_layout( &doc, mi, @@ -282,8 +282,11 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR); + let tex = ui.ctx().load_texture( + format!("ml-{mi}-{li:?}"), + crate::textures::to_color_image(&pic, false), + crate::textures::editor_texture_options(), + ); ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let selected = match (target, li) { (Target::Master { master }, None) => master == mi, diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..42747bd6 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor rasters on the Retina pixel grid, #184; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -13,7 +13,7 @@ because it mostly follows from hardware use. | Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback | | Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) | | Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same | -| High-DPI / Retina | yes | yes | yes | yes | yes | +| High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes | | Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no | | Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a | | Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no | @@ -71,5 +71,6 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..76580bad 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor slide rasters sit on the physical pixel grid, #184; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -128,8 +128,10 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93). Colours, spacing, ribbon card and tab geometry follow the observations in `plan/powerpoint/01-observed-ui.md`; icons are original (~240, drawn in code or Lucide) and must not -imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been run since -2026-10-07. Cross-app icon consistency is open (#73, #56). +imitate Microsoft's. The editing canvas, thumbnails and layout previews are drawn on the physical +pixel grid, so a Retina display does not blur them between pixels (#184). No pixel comparison +against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon consistency is open +(#73, #56). ## Estimates @@ -148,6 +150,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 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 | From 5acf9eeebf13277c75b4751ae3e2b3d2d0cf21f2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:49:55 +0300 Subject: [PATCH 30/69] Review: compare with a revised copy and accept its slides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review.compare {path | name, data} reads a revised presentation (native or PPTX) and lists slide-level revisions against the open one: inserted, deleted and changed slides, with what changed (text, text formatting, moved, resized, rotated, formatting, picture, objects inserted/deleted, layout, notes, transition, animations, background, hidden). Slides match by id, or by position when the decks share none (fixed for the session so accepting doesn't switch the matching half way). Shapes match by id, then name. review.accept {index | all} applies revisions as one undo step: a revised slide's media is copied (remapped on an id clash), its shape ids kept unless another slide uses them (animations follow), its layout matched by id, then name. review.revisions and review.endCompare complete the set. Review tab: Compare (file picker), Accept All, End Review; the Revisions pane lists changes with Go-to and Accept. Parity row: Compare presentations M → P. Co-authored-by: Cursor --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/review.rs | 81 +++++ crates/engine/src/compare.rs | 449 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 4 + crates/ui-egui/src/compare_tests.rs | 61 ++++ crates/ui-egui/src/lib.rs | 14 + crates/ui-egui/src/panes.rs | 57 ++++ crates/ui-egui/src/ribbon.rs | 13 + docs/gaps.md | 3 +- docs/parity-checklist.md | 6 +- 10 files changed, 685 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/compare.rs create mode 100644 crates/ui-egui/src/compare_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..229296c0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -48,7 +48,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool { || s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty()) } -fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { +pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { deckcraft_model::walk(shapes, &mut |s, _| { match &s.kind { deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media), diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..b9f25d40 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -16,6 +16,10 @@ pub fn specs() -> Vec { cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), + cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare), + cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions), + cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept), + cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare), cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { @@ -264,3 +268,80 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { })?; ok() } + +fn comparing(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) } +} + +fn revised_bytes(p: &Value) -> Result<(String, Vec)> { + if let Some(data) = str_param(p, "data") { + let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes)); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = str_param(p, "path") { + let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + return Ok((path.to_string(), bytes)); + } + Err(bad("review.compare", "give `path` or `data`")) +} + +fn revisions_json(st: &crate::DocState) -> Result { + let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?; + let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone()); + Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position})) +} + +fn compare(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = revised_bytes(p)?; + let revised = super::file::open_presentation(&name, &bytes)?; + let st = s.doc_mut()?; + let by_position = crate::compare::unrelated(&st.doc, &revised); + st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position }); + s.ui_requests.push(UiRequest::Pane { id: "revisions".into() }); + revisions_json(s.doc()?) +} + +fn revisions(s: &mut Session, _: &Value) -> Result { + revisions_json(s.doc()?) +} + +fn accept(s: &mut Session, p: &Value) -> Result { + let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?; + let all = bool_param(p, "all").unwrap_or(false); + let index = usize_param(p, "index"); + if !all && index.is_none() { + return Err(bad("review.accept", "give `index` or `all`")); + } + s.edit(|doc, sel| { + let mut accepted = 0usize; + // Each accept shifts indices, so the list is recomputed; bounded by the slide counts. + let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1); + for _ in 0..limit { + let changes = crate::compare::diff(doc, &rev, by_pos); + let c = if all { + changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1)) + } else { + changes.into_iter().nth(index.unwrap_or(0)) + }; + let Some(c) = c else { break }; + let Some(at) = crate::compare::accept(doc, &rev, &c) else { break }; + accepted += 1; + sel.slide = at; + sel.shapes.clear(); + sel.text = None; + if !all { + break; + } + } + if accepted == 0 { + return Err(bad("review.accept", "no such revision")); + } + Ok(json!({"accepted": accepted})) + }) +} + +fn end_compare(s: &mut Session, _: &Value) -> Result { + s.doc_mut()?.compare = None; + ok() +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs new file mode 100644 index 00000000..5d65c0cc --- /dev/null +++ b/crates/engine/src/compare.rs @@ -0,0 +1,449 @@ +//! Review › Compare: the differences between the open presentation and a revised copy of it, slide +//! by slide, and accepting a revised slide into the open one. +//! +//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no +//! slide id at all, by position. Shapes match by id, then by name. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId}; +use serde::Serialize; + +/// The revised presentation a document is being compared with. +#[derive(Clone, Debug)] +pub struct Compare { + pub name: String, + pub revised: Arc, + /// Slides match by position (the decks shared no slide id when the compare started); fixed + /// for the session so accepting slides doesn't switch the matching half way. + pub by_position: bool, +} + +/// Whether `cur` and `rev` share no slide id (then slides match by position). +pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool { + let ids: HashSet = rev.slides.iter().map(|s| s.id).collect(); + !cur.slides.iter().any(|s| ids.contains(&s.id)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + /// Only in the revised deck. + Inserted, + /// Only in the open deck. + Deleted, + /// In both, different. + Changed, +} + +/// One slide-level revision. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub kind: ChangeKind, + /// Index in the open deck (for inserted: where it would go). + pub slide: usize, + /// Index in the revised deck (none for deleted). + pub revised: Option, + /// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`… + pub details: Vec, +} + +const MAX_DETAILS: usize = 50; + +/// Slide pairs (open index, revised index). +fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> { + if by_position { + return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect(); + } + let rev_ids: HashMap = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect(); + cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect() +} + +pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec { + let pairs = pairs(cur, rev, by_position); + let cur_matched: HashSet = pairs.iter().map(|p| p.0).collect(); + let rev_matched: HashSet = pairs.iter().map(|p| p.1).collect(); + let mut out = vec![]; + for (i, j) in &pairs { + let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue }; + let details = slide_details(cur, a, rev, b); + if !details.is_empty() { + out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details }); + } + } + for (i, s) in cur.slides.iter().enumerate() { + if !cur_matched.contains(&i) { + out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] }); + } + } + for (j, s) in rev.slides.iter().enumerate() { + if !rev_matched.contains(&j) { + // After the open slide that matches the revised slide before it. + let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0); + out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] }); + } + } + out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0))); + out +} + +fn slide_title(s: &Slide) -> String { + let t = s + .shapes + .iter() + .find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle))) + .and_then(|x| x.text.as_ref()) + .map(|b| b.text()) + .unwrap_or_default(); + let t = t.trim(); + if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() } +} + +fn shape_label(s: &Shape) -> String { + if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() } +} + +fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> { + p.media(id).map(|m| m.data.as_slice()) +} + +/// A shape without its ids (shape and media), for comparing across decks. +fn plain_shape(s: &Shape) -> Shape { + let mut c = s.clone(); + fn rec(shapes: &mut [Shape]) { + for s in shapes { + s.id = ShapeId(0); + if let Some(ch) = s.children_mut() { + rec(ch); + } + } + } + rec(std::slice::from_mut(&mut c)); + c +} + +fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec) { + let label = shape_label(b); + let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default(); + let mut what = vec![]; + if text(a) != text(b) { + what.push("text"); + } else if a.text != b.text { + what.push("text formatting"); + } + match (&a.xfrm, &b.xfrm) { + (Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"), + (Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"), + (x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"), + _ => {} + } + if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects { + what.push("formatting"); + } + let pic = |p: &Presentation, s: &Shape| match &s.kind { + ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()), + _ => None, + }; + if pic(cp, a) != pic(rp, b) { + what.push("picture"); + } + if what.is_empty() { + let (mut x, mut y) = (plain_shape(a), plain_shape(b)); + x.text = None; + y.text = None; + if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) { + x.kind = ShapeKind::Shape; + y.kind = ShapeKind::Shape; + } + if x != y { + what.push("properties"); + } + } + if !what.is_empty() { + out.push(format!("{label}: {}", what.join(", "))); + } +} + +fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec { + let mut out = vec![]; + let mut used: HashSet = HashSet::new(); + for sa in &a.shapes { + let k = b + .shapes + .iter() + .position(|x| x.id == sa.id) + .or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty())); + match k { + Some(k) if !used.contains(&k) => { + used.insert(k); + if let Some(sb) = b.shapes.get(k) { + shape_details(cp, sa, rp, sb, &mut out); + } + } + _ => out.push(format!("{} deleted", shape_label(sa))), + } + } + for (k, sb) in b.shapes.iter().enumerate() { + if !used.contains(&k) { + out.push(format!("{} inserted", shape_label(sb))); + } + } + let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::>(); + if out.is_empty() && order(a) != order(b) { + out.push("order of objects".into()); + } + let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone()); + if layout_name(cp, a) != layout_name(rp, b) { + out.push("layout".into()); + } + if a.notes.text() != b.notes.text() { + out.push("notes".into()); + } + if a.transition != b.transition { + out.push("transition".into()); + } + if a.animations.len() != b.animations.len() + || a.animations + .iter() + .zip(&b.animations) + .any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms)) + { + out.push("animations".into()); + } + if a.background != b.background { + out.push("background".into()); + } + if a.hidden != b.hidden { + out.push(if b.hidden { "hidden" } else { "shown" }.into()); + } + out.truncate(MAX_DETAILS); + out +} + +fn max_shape_id(shapes: &[Shape], m: &mut u32) { + deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0)); +} + +/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape +/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its +/// layout matched by id, then by name, then the first layout. +pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option) -> Slide { + let mut sl = slide.clone(); + // Media. + let mut mids = vec![]; + crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids); + let mut media_map: HashMap = HashMap::new(); + for id in mids { + let Some(m) = rev.media(id) else { continue }; + match doc.media(id) { + Some(x) if x.data == m.data => {} + None => doc.media.push(m.clone()), + Some(_) => { + let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); + media_map.insert(id, new); + } + } + } + // Shape ids used elsewhere in the open deck. + let mut taken: HashSet = HashSet::new(); + for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) { + deckcraft_model::walk(&s.shapes, &mut |x, _| { + taken.insert(x.id.0); + }); + } + let mut id_map: HashMap = HashMap::new(); + fn rec( + doc: &mut Presentation, + shapes: &mut [Shape], + taken: &HashSet, + ids: &mut HashMap, + media: &HashMap, + depth: usize, + ) { + if depth > 64 { + return; + } + for s in shapes { + if taken.contains(&s.id.0) { + let new = ShapeId(doc.alloc_id()); + ids.insert(s.id, new); + s.id = new; + } + let remap = |m: &mut MediaId| { + if let Some(n) = media.get(m) { + *m = *n; + } + }; + match &mut s.kind { + ShapeKind::Picture { fill } => remap(&mut fill.media), + ShapeKind::Media(m) => { + remap(&mut m.media); + if let Some(p) = m.poster.as_mut() { + remap(p); + } + } + _ => {} + } + if let Some(Fill::Picture(pf)) = &mut s.fill { + remap(&mut pf.media); + } + if let Some(ch) = s.children_mut() { + rec(doc, ch, taken, ids, media, depth + 1); + } + } + } + rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0); + for a in &mut sl.animations { + if let Some(n) = id_map.get(&a.shape) { + a.shape = *n; + } + if let Some(t) = a.trigger.as_mut() + && let Some(n) = id_map.get(t) + { + *t = *n; + } + } + let mut max = 0; + max_shape_id(&sl.shapes, &mut max); + doc.next_id = doc.next_id.max(max); + // Layout. + if doc.layout(sl.layout).is_none() { + let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone()); + let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id)); + sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default(); + } + match replacing { + Some(id) => sl.id = id, + None => { + if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 { + sl.id = SlideId(doc.alloc_id()); + } + doc.next_id = doc.next_id.max(sl.id.0); + } + } + sl +} + +/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show. +pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> Option { + match change.kind { + ChangeKind::Changed => { + let b = rev.slides.get(change.revised?)?.clone(); + let id = doc.slides.get(change.slide)?.id; + let sl = adopt(doc, rev, &b, Some(id)); + *doc.slides.get_mut(change.slide)? = Arc::new(sl); + Some(change.slide) + } + ChangeKind::Inserted => { + let b = rev.slides.get(change.revised?)?.clone(); + let sl = adopt(doc, rev, &b, None); + let at = change.slide.min(doc.slides.len()); + doc.slides.insert(at, Arc::new(sl)); + Some(at) + } + ChangeKind::Deleted => { + if change.slide >= doc.slides.len() || doc.slides.len() <= 1 { + return None; + } + doc.slides.remove(change.slide); + Some(change.slide.min(doc.slides.len().saturating_sub(1))) + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + /// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title + /// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3. + fn decks() -> (Session, String, usize) { + let mut s = Session::new(); + crate::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + // Make the revision in a second document. + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap(); + let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0; + s.execute("edit.select", &json!({"ids": [first]})).unwrap(); + s.execute("text.edit", &json!({"id": first})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Revised title"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": 1})).ok(); + let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0; + s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok(); + s.execute("slide.go", &json!({"index": n - 2})).ok(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + (s, rev, n) + } + + fn kinds(v: &Value) -> Vec { + v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect() + } + + #[test] + fn lists_inserted_deleted_and_changed_slides() { + let (mut s, rev, n) = decks(); + let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let k = kinds(&v); + assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}"); + let ch = &v["changes"].as_array().unwrap()[0]; + assert_eq!(ch["slide"], 0); + assert!(ch["details"].to_string().contains("text"), "{ch}"); + let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap(); + assert_eq!(ins["details"][0], "Brand new"); + let at = ins["slide"].as_u64().unwrap() as usize; + assert_eq!(at, n, "after the open slide matching the revised one before it"); + assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing"); + } + + #[test] + fn accept_one_then_all_makes_the_decks_equal_and_undoes() { + let (mut s, rev, n) = decks(); + s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + s.execute("review.accept", &json!({"index": 0})).unwrap(); + let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + assert_eq!(after, before - 1); + assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title")))); + s.execute("review.accept", &json!({"all": true})).unwrap(); + let left = s.execute("review.revisions", &json!({})).unwrap(); + assert!(left["changes"].as_array().unwrap().is_empty(), "{left}"); + assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate()); + assert_eq!(s.doc().unwrap().doc.slides.len(), n); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before); + s.execute("review.endCompare", &json!({})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"all": true})).is_err()); + } + + #[test] + fn unrelated_decks_match_by_position_and_bad_input_fails() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap(); + let mut other = Session::with_new(); + other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap(); + other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap(); + assert!(kinds(&v).contains(&"inserted".into()), "{v}"); + s.execute("review.accept", &json!({"all": true})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty()); + assert!(s.doc().unwrap().doc.validate().is_empty()); + assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err()); + assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err()); + assert!(s.execute("review.compare", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"index": 999})).is_err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..2c4df3cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod cmd; +pub mod compare; pub mod connect; pub mod fonts; pub mod guard; @@ -173,6 +174,8 @@ pub struct DocState { pub missing_fonts: Vec, /// An input method's composition in progress ([`Composition`]). pub composition: Option, + /// Review › Compare in progress: the revised copy. + pub compare: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -192,6 +195,7 @@ impl DocState { title, missing_fonts: Vec::new(), composition: None, + compare: None, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/ui-egui/src/compare_tests.rs b/crates/ui-egui/src/compare_tests.rs new file mode 100644 index 00000000..9108b4de --- /dev/null +++ b/crates/ui-egui/src/compare_tests.rs @@ -0,0 +1,61 @@ +//! Review › Compare: the Revisions pane lists a revised copy's changes and accepts them. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// The sample deck compared with a copy whose slide 2 is gone and that has a new last slide. +fn harness() -> (Harness<'static, SlideApp>, usize) { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let data = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": data})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": n - 2})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "From the reviewer", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + s.execute("review.compare", &json!({"name": "Q3 review.deckcraft", "data": rev})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, n) +} + +#[test] +fn pane_opens_lists_changes_and_accept_all_applies_them() { + let (mut h, n) = harness(); + assert_eq!(h.state().ui.pane.as_deref(), Some("revisions"), "compare opens the Revisions pane"); + assert!(h.query_by_label("Compared with Q3 review.deckcraft").is_some()); + assert!(h.query_by_label("Slide 2 deleted").is_some()); + assert!(h.query_by_label_contains("Slide inserted after").is_some()); + h.get_by_label_contains("Accept All").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.slides.len(), n, "one slide gone, one slide added"); + assert!(h.query_by_label("No differences left.").is_some()); + h.get_by_label("End Review").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().compare.is_none()); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let (mut h, _) = harness(); + // Review tab. + h.state_mut().ui.tab = "Review".into(); + h.run_steps(4); + h.render().unwrap().save("/tmp/compare.png").unwrap(); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..9f28b35d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -167,6 +167,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), + ("app.compareDialog", "Compare…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), ("app.links", "Community Links", None, "{}"), ]; @@ -541,6 +542,17 @@ impl SlideApp { "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); @@ -1127,6 +1139,8 @@ fn js_now() -> f64 { js_sys::Date::now() } +#[cfg(test)] +mod compare_tests; #[cfg(test)] mod ime_tests; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..fac96b69 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "revisions" => "Revisions", _ => "Pane", }; ui.horizontal(|ui| { @@ -50,6 +51,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), + "revisions" => revisions(app, ui), _ => {} }); }); @@ -549,6 +551,61 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let _ = Stroke::NONE; } +/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it. +fn revisions(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("review.revisions", &json!({})) else { + ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + return; + }; + let changes = v["changes"].as_array().cloned().unwrap_or_default(); + ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); + if v["byPosition"].as_bool() == Some(true) { + ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + } + ui.add_space(4.0); + if changes.is_empty() { + ui.label("No differences left."); + if ui.button("End Review").clicked() { + run(app, "review.endCompare", json!({})); + app.ui.pane = None; + } + return; + } + if ui.button(format!("Accept All ({})", changes.len())).clicked() { + run(app, "review.accept", json!({"all": true})); + } + ui.separator(); + for (i, c) in changes.iter().enumerate() { + let slide = c["slide"].as_u64().unwrap_or(0); + let (head, col) = match c["kind"].as_str().unwrap_or("") { + "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), + "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), + _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), + }; + egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(2), col); + if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() { + let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0); + run(app, "slide.go", json!({"index": (slide as usize).min(max)})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Accept").clicked() { + run(app, "review.accept", json!({"index": i})); + } + }); + }); + for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) { + ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim)); + } + }); + ui.add_space(4.0); + } +} + fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if ui.button("+ New Comment").clicked() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..e4670704 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1791,6 +1791,19 @@ fn review(app: &mut SlideApp, ui: &mut Ui) { let _ = r.on_hover_text("Translation needs a language model (not bundled)"); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); + group(ui, |ui| { + big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); + let comparing = enabled(app, "review.endCompare"); + if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { + run(app, "review.endCompare", json!({})); + if app.ui.pane.as_deref() == Some("revisions") { + app.ui.pane = None; + } + } + }); group(ui, |ui| { big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..5188f10c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,7 +187,8 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide -show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile +show (8–12 h), compare presentations beyond slide-level accept (reject, per-object accept, +markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376 agile encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set). ## UI/UX diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..f00fa766 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -30,7 +30,7 @@ The summary between ` | Transitions | 92% | | Animations | 79% | | Slide show | 66% | -| Review | 60% | +| Review | 70% | | Draw | 33% | | Undo & clipboard | 88% | | Automation (✱) | 100% | @@ -221,7 +221,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Slide show | Multi-monitor presenting | P1 | M | | Review | Comments (add, reply, resolve, delete) | P1 | D | | Review | Accessibility checker | P2 | D | -| Review | Compare presentations | P2 | M | +| Review | Compare presentations | P2 | P | | Review | Track changes / version history | P2 | M | | Draw | Ink pens, highlighter, eraser | P1 | P | | Draw | Ink to shape / text | P2 | M | From 37559f793cc3075f366b760e8d1f9eb0dae6e25d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:14:52 +0300 Subject: [PATCH 31/69] UI: Grid Options sets spacing, snap and the on-screen grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Grid Options sets the grid spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn. The canvas grid follows that spacing. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/ui-egui/src/canvas.rs | 69 ++++++++++++++---- crates/ui-egui/src/dialogs.rs | 39 ++++++++++ crates/ui-egui/src/lib.rs | 129 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/ribbon.rs | 3 + docs/gaps.md | 5 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 11 +-- 10 files changed, 247 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..7d8fc2db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: Grid Options dialog | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..4b37c2f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -135,18 +135,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } app.perf.render_ms = app.textures.last_render_ms; media_frames(app, &painter, xf); - if app.ui.gridlines { - let step = 72.0 / 2.0 * scale; - let mut x = slide_rect.min.x + step; - while x < slide_rect.max.x { - painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - x += step; - } - let mut y = slide_rect.min.y + step; - while y < slide_rect.max.y { - painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - y += step; - } + if app.ui.gridlines + && let Some(step) = grid_line_step(app.session.prefs.grid_spacing, scale) + { + draw_grid(&painter, slide_rect, step); } if app.ui.guides { let c = slide_rect.center(); @@ -697,3 +689,56 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; +/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of +/// lines (bad inputs, or so fine that a line would land on every pixel). +fn grid_line_step(spacing: f64, scale: f32) -> Option { + if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { + return None; + } + let step = (spacing as f32) * scale; + if !step.is_finite() || step < 1.0 { + return None; + } + Some(step) +} + +fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { + let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); + // A denser grid than this paints a grey slab and costs a frame. Skip it. + if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { + return; + } + let mut x = slide.min.x + step; + while x < slide.max.x { + painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); + x += step; + } + let mut y = slide.min.y + step; + while y < slide.max.y { + painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); + y += step; + } +} + +fn grid_fits(span: f32, step: f32) -> bool { + span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 +} + +#[cfg(test)] +mod tests { + use super::grid_line_step; + + #[test] + fn grid_step_follows_spacing_and_rejects_unusable_values() { + let step = grid_line_step(72.0, 0.5).unwrap_or(0.0); + assert!((step - 36.0).abs() < 1e-3, "{step}"); + assert!(grid_line_step(6.0, 1.0).is_some()); + assert!(grid_line_step(0.0, 1.0).is_none()); + assert!(grid_line_step(f64::NAN, 1.0).is_none()); + assert!(grid_line_step(1_000.0, 1.0).is_none()); + assert!(grid_line_step(6.0, 0.0).is_none()); + assert!(grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..845ca485 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -45,6 +45,7 @@ fn title(id: &str) -> &'static str { "paragraph" => "Paragraph", "about" => "About DeckCraft", "preferences" => "Preferences", + "gridOptions" => "Grid Options", "export" => "Export", "symbol" => "Symbol", "equation" => "Equation", @@ -708,6 +709,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Close"); ok } + "gridOptions" => grid_options(app, ui, d), "preferences" => { ui.label(egui::RichText::new("General").font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark; @@ -738,6 +740,43 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } +fn format_spacing(v: f64) -> String { + if !v.is_finite() { + return "6".into(); + } + if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") } +} + +/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one +/// `view.gridOptions` call; Cancel leaves the presentation as it was. +fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); + let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); + let spacing_default = format_spacing(app.session.prefs.grid_spacing); + field(ui, d, "Spacing (pt):", "spacing", &spacing_default); + ui.add_space(6.0); + ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); + let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); + if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { + ui.add_space(4.0); + ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let spacing = num(d, "spacing", app.session.prefs.grid_spacing); + match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) { + Ok(_) => return true, + Err(e) => { + d.fields.insert("error".into(), e); + return false; + } + } + } + cancel +} + fn pretty_equation(s: &str) -> String { let sup = |c: char| match c { '0' => '⁰', diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..4de17d60 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.ruler", "Ruler", None, "{on?: bool}"), ("view.gridlines", "Gridlines", Some("Shift+F9"), "{on?: bool}"), ("view.guides", "Guides", Some("Alt+F9"), "{on?: bool}"), + ("view.gridOptions", "Grid Options…", None, "{spacing?: pt, snap?: bool, smartGuides?: bool, gridlines?: bool, guides?: bool}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Zoom…", None, "{percent}"), @@ -229,6 +230,18 @@ pub struct SlideApp { pub selpane: selpane::State, } +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + impl SlideApp { pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); @@ -458,6 +471,49 @@ impl SlideApp { "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } "view.zoomIn" | "view.zoomOut" => { let cur = self.ui.zoom.unwrap_or(self.canvas_scale); let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; @@ -1305,6 +1361,79 @@ mod tests { app.run("app.home", json!({})).unwrap(); assert!(!app.backstage_open(), "with nothing open the start screen shows anyway"); } + + #[test] + fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); + let v = + app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + assert_eq!(v["spacing"], 72.0); + assert_eq!(v["snap"], false); + assert_eq!(v["smartGuides"], false); + assert_eq!(v["gridlines"], true); + assert_eq!(v["guides"], true); + assert!(app.dialog.is_none()); + assert!(app.ui.gridlines && app.ui.guides); + assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); + + app.run("view.gridOptions", json!({"snap": true})).unwrap(); + assert!(app.session.prefs.snap_to_grid); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.dialog.is_none()); + + for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { + assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); + } + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.session.prefs.snap_to_grid); + + app.run("view.gridOptions", json!({})).unwrap(); + assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + } + + #[test] + fn grid_options_dialog_lists_spacing_and_display() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Display grid on screen").is_some()); + assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); + assert!(harness.query_by_label_contains("Spacing").is_some()); + let show_grid = harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") + .next() + .unwrap_or_else(|| panic!("no grid checkbox")); + show_grid.click_accesskit(); + harness.run_steps(2); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); + assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); + assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + } + fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..ba9dfb38 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -194,6 +194,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Ruler", "view.ruler"), ("Gridlines", "view.gridlines"), ("Guides", "view.guides"), + ("Grid Options…", "view.gridOptions"), ("-", ""), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..722a3d05 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1842,6 +1842,9 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { app.ui.guides = gd; } }); + if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { + let _ = app.run("view.gridOptions", json!({})); + } let n = app.ui.notes; if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { app.ui.notes = !n; diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..4c68b1af 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; 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) 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 @@ -203,7 +203,7 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, +- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/roadmap.md b/docs/roadmap.md index 97953034..11bfc0aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options; previous: core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -80,7 +80,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as | M7 | Slide show | mostly | second-display placement, laser, record | | M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table | | M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | -| M10 | Views | done | grid options, New Window | +| M10 | Views | done | New Window | | M11 | Output | partly | vector PDF, SVG, GIF, MP4, print | | M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare | | M13 | Automation and CLI parity | done | | @@ -109,5 +109,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M10: Grid Options dialog; New Window stays open | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..cf565002 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Views: Grid Options landed; New Window remains; previous: hours to ~95% per audience added; ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -182,7 +182,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Application shell | 5 | 71% | ~55% | 8–14 | No native macOS menu bar, thin start screen (templates, recent), one window per presentation missing | [ui-parity.md](ui-parity.md) | | Files (commands) | 4 | 69% | ~50% | see file formats | Print, reuse slides, passwords, legacy and macro formats | [file-format-parity.md](file-format-parity.md) | | Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | -| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) | +| Views | 4 | 90% | ~80% | 3–5 | New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) | | Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) | | Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Views: Grid Options dialog landed (spacing, snap, display). Remaining in that row: New Window. Percentages 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..af76d104 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | 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…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, 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…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Grid Options, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -74,7 +74,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Smart guides (edges, centres, equal spacing) | yes | yes | -| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle | +| Snap to grid, grid options, drawing guides | yes | Grid Options (`view.gridOptions`): spacing, snap to grid, snap to other objects, display grid and guides; the on-screen grid uses that spacing | | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | @@ -139,15 +139,16 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Format Shape pane depth | 6–10 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | -| Grid Options, edit points UI, crop depth | 4–7 | +| Edit points UI, crop depth | 3–6 | | Multiple windows | 3–5 | | A screenshot comparison pass on the most-used tabs | 2–4 | -| **Total** | **~30–52** | +| **Total** | **~29–51** | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Grid Options dialog: spacing, snap, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | | 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 | From 4658042de837fa5952fdc0dbbc232ba1f6db0b61 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:16:49 +0300 Subject: [PATCH 32/69] Draw: Ink to Shape converts pen strokes into shapes Draw > Ink to Shape recognises each pen stroke on the slide's ink layer as a line, rectangle, oval or isosceles triangle (apex up or down) and replaces it with that preset shape, outlined in the stroke's colour and width with no fill. Strokes it can't recognise, and highlighter strokes, stay ink. The ink shape is removed once it has no strokes left. The whole conversion is one undo step. The recogniser (engine/src/inkshape.rs) is pure geometry: ends meeting, area covered within the bounding box, and spread from the fitted ellipse. Ribbon big buttons now carry an accessibility label. Co-authored-by: Cursor --- crates/engine/src/cmd/shape.rs | 85 +++++++++++ crates/engine/src/inkshape.rs | 192 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/ui-egui/src/inktoshape_tests.rs | 60 ++++++++ crates/ui-egui/src/ribbon.rs | 12 +- crates/ui-egui/src/widgets.rs | 3 + docs/parity-checklist.md | 6 +- 7 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/inkshape.rs create mode 100644 crates/ui-egui/src/inktoshape_tests.rs diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..aabac43f 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -29,6 +29,15 @@ pub fn specs() -> Vec { has_slide, freeform ), + cmd!( + "draw.inkToShape", + "Ink to Shape", + ["Draw", "Convert"], + None, + "{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles", + has_slide, + ink_to_shape + ), cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites), cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), @@ -860,3 +869,79 @@ fn freeform(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"id": id.0})) } + +/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape +/// outlined in the stroke's colour and width; the rest stays ink. One undo step. +fn ink_to_shape(s: &mut Session, _p: &Value) -> Result { + use crate::inkshape::{Recognized, recognize}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let shapes = s.doc()?.shapes(); + let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else { + return Ok(json!({"shapes": [], "kept": 0})); + }; + let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0))); + let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) }; + let found: Vec> = strokes + .iter() + .map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::>()) }) + .collect(); + if found.iter().all(Option::is_none) { + return Ok(json!({"shapes": [], "kept": strokes.len()})); + } + s.edit(|doc, sel| { + let mut new = vec![]; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) }; + let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) }; + let all = std::mem::take(strokes); + let mut kept = vec![]; + let mut made = vec![]; + for (st, r) in all.into_iter().zip(found.iter()) { + let Some(r) = r else { + kept.push(st); + continue; + }; + let (preset, xfrm) = match *r { + Recognized::Line { x0, y0, x1, y1 } => { + ("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0)) + } + Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }), + }; + made.push((preset, xfrm, st.color, st.width)); + } + let remaining = kept.len(); + *strokes = kept; + if remaining == 0 { + list.remove(pos); + } + for (preset, xfrm, color, width) in made { + let id = ShapeId(doc.alloc_id()); + let name = format!( + "{} {}", + match preset { + "line" => "Straight Connector", + "rect" => "Rectangle", + "ellipse" => "Oval", + _ => "Isosceles Triangle", + }, + id.0 + ); + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + list.push(Shape { + id, + name, + xfrm: Some(xfrm), + geom: Geom::preset(preset), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))), + kind: ShapeKind::Shape, + ..Default::default() + }); + new.push(id); + } + sel.shapes = new.clone(); + Ok(json!({"shapes": new.iter().map(|i| i.0).collect::>(), "kept": remaining})) + }) +} diff --git a/crates/engine/src/inkshape.rs b/crates/engine/src/inkshape.rs new file mode 100644 index 00000000..593bcbc1 --- /dev/null +++ b/crates/engine/src/inkshape.rs @@ -0,0 +1,192 @@ +//! Draw › Ink to Shape: recognise a hand-drawn stroke as a line, rectangle, ellipse or triangle. +//! +//! Pure geometry so it is unit-tested with synthetic strokes. A stroke is closed when its ends +//! meet; a closed stroke is classified by how much of its bounding box it covers (rectangle ≈ 1, +//! ellipse ≈ π/4, triangle ≈ ½) and, for ellipses, by how evenly it keeps its distance from the +//! centre. + +/// What a stroke was recognised as, in its bounding box `(x, y, w, h)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Recognized { + /// From `(x0, y0)` to `(x1, y1)`. + Line { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + }, + Rect { + x: f64, + y: f64, + w: f64, + h: f64, + }, + Ellipse { + x: f64, + y: f64, + w: f64, + h: f64, + }, + /// Apex at the top (`flip_v` false) or at the bottom. + Triangle { + x: f64, + y: f64, + w: f64, + h: f64, + flip_v: bool, + }, +} + +/// Strokes smaller than this (points) stay ink. +const MIN_SIZE: f64 = 6.0; +const MAX_POINTS: usize = 100_000; + +pub fn recognize(points: &[(f64, f64)]) -> Option { + let pts: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_POINTS).collect(); + if pts.len() < 2 { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in &pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + let (w, h) = (x1 - x0, y1 - y0); + let size = w.max(h); + if !size.is_finite() || size < MIN_SIZE { + return None; + } + let length: f64 = pts.windows(2).map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1)).sum(); + let (first, last) = (pts[0], pts[pts.len() - 1]); + let gap = (last.0 - first.0).hypot(last.1 - first.1); + if length <= 0.0 { + return None; + } + // Open stroke: a line if nearly straight, otherwise not a shape. + if gap > 0.25 * size { + return (gap / length > 0.92).then_some(Recognized::Line { x0: first.0, y0: first.1, x1: last.0, y1: last.1 }); + } + // Closed but flat: a line drawn back and forth isn't a shape. + if w.min(h) < 0.15 * size { + return None; + } + // Covered area (shoelace, the gap closed) over the bounding box. + let mut a2 = 0.0; + for k in 0..pts.len() { + let (p, q) = (pts[k], pts[(k + 1) % pts.len()]); + a2 += p.0 * q.1 - q.0 * p.1; + } + let cover = (a2.abs() / 2.0) / (w * h); + // Ellipse fit: distance from the centre in box-normalised units should stay near 1. + let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0); + let radial: Vec = pts.iter().map(|(x, y)| ((x - cx) / (w / 2.0)).hypot((y - cy) / (h / 2.0))).collect(); + let spread = radial.iter().map(|r| (r - 1.0).abs()).sum::() / radial.len() as f64; + if cover > 0.86 { + Some(Recognized::Rect { x: x0, y: y0, w, h }) + } else if cover > 0.66 && spread < 0.12 { + Some(Recognized::Ellipse { x: x0, y: y0, w, h }) + } else if (0.35..0.66).contains(&cover) { + // Apex: the side of the box with fewer points near it is where the triangle narrows. + let near_top = pts.iter().filter(|(_, y)| *y < y0 + h * 0.15).count(); + let near_bottom = pts.iter().filter(|(_, y)| *y > y1 - h * 0.15).count(); + Some(Recognized::Triangle { x: x0, y: y0, w, h, flip_v: near_bottom < near_top }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn poly(corners: &[(f64, f64)], per_side: usize) -> Vec<(f64, f64)> { + let mut v = vec![]; + for k in 0..corners.len() { + let (a, b) = (corners[k], corners[(k + 1) % corners.len()]); + for i in 0..per_side { + let t = i as f64 / per_side as f64; + // A little hand wobble. + let wob = ((i * 7 + k * 3) % 5) as f64 * 0.4 - 0.8; + v.push((a.0 + (b.0 - a.0) * t + wob, a.1 + (b.1 - a.1) * t - wob)); + } + } + v.push((corners[0].0 + 2.0, corners[0].1 + 1.5)); + v + } + + #[test] + fn rectangles_ellipses_triangles_and_lines() { + let r = recognize(&poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30)).unwrap(); + assert!(matches!(r, Recognized::Rect { w, h, .. } if (w - 200.0).abs() < 4.0 && (h - 100.0).abs() < 4.0), "{r:?}"); + let ell: Vec<(f64, f64)> = (0..=80) + .map(|i| { + let a = i as f64 / 80.0 * std::f64::consts::TAU; + (200.0 + 120.0 * a.cos() + (i % 3) as f64 * 0.5, 150.0 + 60.0 * a.sin()) + }) + .collect(); + assert!(matches!(recognize(&ell), Some(Recognized::Ellipse { .. })), "{:?}", recognize(&ell)); + let up = recognize(&poly(&[(200.0, 50.0), (300.0, 250.0), (100.0, 250.0)], 30)).unwrap(); + assert!(matches!(up, Recognized::Triangle { flip_v: false, .. }), "{up:?}"); + let down = recognize(&poly(&[(100.0, 50.0), (300.0, 50.0), (200.0, 250.0)], 30)).unwrap(); + assert!(matches!(down, Recognized::Triangle { flip_v: true, .. }), "{down:?}"); + let line: Vec<(f64, f64)> = (0..=40).map(|i| (10.0 + i as f64 * 5.0, 20.0 + i as f64 * 2.0 + (i % 2) as f64 * 0.6)).collect(); + assert!(matches!(recognize(&line), Some(Recognized::Line { x0, x1, .. }) if x0 < x1), "{:?}", recognize(&line)); + } + + #[test] + fn scribbles_dots_and_garbage_stay_ink() { + assert_eq!(recognize(&[]), None); + assert_eq!(recognize(&[(1.0, 1.0)]), None); + assert_eq!(recognize(&[(0.0, 0.0), (2.0, 2.0), (1.0, 3.0)]), None, "too small"); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 60.0 })).collect(); + assert_eq!(recognize(&zig), None, "an open zigzag is no line"); + assert_eq!(recognize(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)]), None); + let there_and_back: Vec<(f64, f64)> = (0..=20).chain((0..=20).rev()).map(|i| (i as f64 * 10.0, 5.0)).collect(); + assert_eq!(recognize(&there_and_back), None); + } + + #[test] + fn ink_to_shape_command_converts_and_undoes() { + use deckcraft_model::{InkStroke, Shape, ShapeId, ShapeKind}; + use serde_json::json; + let mut s = crate::Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let rect = poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 300.0 } else { 360.0 })).collect(); + let mk = |v: &[(f64, f64)]| InkStroke { + points: v.iter().map(|(x, y)| (*x, *y, 0.5)).collect(), + color: deckcraft_color::Rgba::rgb(200, 0, 0), + width: 3.0, + highlighter: false, + }; + let strokes = vec![mk(&rect), mk(&zig)]; + s.edit(|doc, sel| { + let id = ShapeId(doc.alloc_id()); + crate::shapes_mut(doc, sel).unwrap().push(Shape { + id, + name: "Ink".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { strokes }, + ..Default::default() + }); + Ok(serde_json::Value::Null) + }) + .unwrap(); + let n0 = s.doc().unwrap().shapes().len(); + let r = s.execute("draw.inkToShape", &json!({})).unwrap(); + assert_eq!(r["kept"], 1); + let sh = s.doc().unwrap().shapes(); + assert_eq!(sh.len(), n0 + 1, "ink keeps the zigzag, one rectangle added"); + let made = sh.last().unwrap(); + assert!(matches!(&made.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect")); + assert_eq!(made.line.as_ref().and_then(|l| l.width), Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), n0); + // Nothing recognisable: a no-op, not an error. Empty slide too. + s.execute("draw.inkToShape", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + assert_eq!(s.execute("draw.inkToShape", &json!({})).unwrap()["kept"], 0); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..83c0a510 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod cmd; pub mod connect; pub mod fonts; pub mod guard; +pub mod inkshape; pub mod keys; pub mod links; pub mod recovery; diff --git a/crates/ui-egui/src/inktoshape_tests.rs b/crates/ui-egui/src/inktoshape_tests.rs new file mode 100644 index 00000000..7a7ebafc --- /dev/null +++ b/crates/ui-egui/src/inktoshape_tests.rs @@ -0,0 +1,60 @@ +//! Draw › Ink to Shape driven through the ribbon: pen a rough rectangle on the slide with the +//! mouse, press the button, get a rectangle shape. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn pen_a_rectangle_then_ink_to_shape() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Draw".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Black Pen").click(); + h.run_steps(2); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + let corners = [(200.0, 150.0), (600.0, 150.0), (600.0, 400.0), (200.0, 400.0), (204.0, 152.0)]; + h.hover_at(at(200.0, 150.0)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at(200.0, 150.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + for w in corners.windows(2) { + for i in 1..=12 { + let t = i as f32 / 12.0; + let p: Pos2 = at(w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + } + h.event(egui::Event::PointerButton { pos: at(204.0, 152.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let has_ink = |h: &Harness<'_, SlideApp>| { + h.state().session.active().unwrap().shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })) + }; + assert!(has_ink(&h), "the pen drew a stroke"); + h.get_by_label("Ink to Shape").click(); + h.run_steps(3); + assert!(!has_ink(&h), "the only stroke became a shape"); + let st = h.state().session.active().unwrap(); + let sh = st.shapes().last().unwrap(); + assert!(matches!(&sh.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"), "{:?}", sh.geom); + let x = sh.xfrm.unwrap(); + assert!((x.x - 200.0).abs() < 10.0 && (x.w - 400.0).abs() < 15.0 && (x.h - 250.0).abs() < 15.0, "{x:?}"); + assert_eq!(st.selection.shapes, vec![sh.id]); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ink_to_shape.png"); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..80cf0375 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1200,8 +1200,12 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { } }); group(ui, |ui| { - let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false); - let _ = r.on_hover_text("Ink to Shape (coming soon)"); + let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); + let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); + if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { + app.session.set_tool(ToolKind::Select); + run(app, "draw.inkToShape", json!({})); + } let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); let _ = r.on_hover_text("Ink to Text (coming soon)"); let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); @@ -2420,3 +2424,7 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +#[cfg(test)] +#[path = "inktoshape_tests.rs"] +mod inktoshape_tests; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..97480599 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); + // Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape"). + let spoken = label.replace('\n', " "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken)); let painter = ui.painter_at(rect.expand(1.0)); let (main_rect, arrow_rect) = if split { (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..bd4a36ce 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -31,7 +31,7 @@ The summary between ` | Animations | 79% | | Slide show | 66% | | Review | 60% | -| Draw | 33% | +| Draw | 50% | | Undo & clipboard | 88% | | Automation (✱) | 100% | | Platforms (✱) | 67% | @@ -224,7 +224,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Review | Compare presentations | P2 | M | | Review | Track changes / version history | P2 | M | | Draw | Ink pens, highlighter, eraser | P1 | P | -| Draw | Ink to shape / text | P2 | M | +| Draw | Ink to shape / text | P2 | P | | Undo & clipboard | Unlimited undo/redo | P0 | D | | Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D | | Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | P | From 1b326e2e8729d31925e6dbdb9d8484242d51afae Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:34:30 +0300 Subject: [PATCH 33/69] Review: Thesaurus pane with a built-in English word list Review > Thesaurus (Shift+F7) opens a Thesaurus pane on the selected word, or on the word at the caret. Clicking a synonym while editing text puts it in place of that word in one undo step, and the pane then looks up the new word. You can also type a word into the pane to look it up. The word list is written for DeckCraft and has no third-party source: about 260 senses of common presentation vocabulary in engine/data/thesaurus.txt. It is embedded, so the Thesaurus also works offline and in the browser build. Lookups try simple English endings and keep the word's capitalisation. New commands: review.thesaurus (a query) and review.replaceWord. Ribbon big buttons now carry an accessibility label. Co-authored-by: Cursor --- crates/engine/data/thesaurus.txt | 264 ++++++++++++++++++++++++++ crates/engine/src/cmd/review.rs | 43 +++++ crates/engine/src/lib.rs | 1 + crates/engine/src/thesaurus.rs | 119 ++++++++++++ crates/ui-egui/src/lib.rs | 17 ++ crates/ui-egui/src/panes.rs | 41 ++++ crates/ui-egui/src/ribbon.rs | 3 +- crates/ui-egui/src/thesaurus_tests.rs | 54 ++++++ crates/ui-egui/src/widgets.rs | 3 + docs/parity-checklist.md | 6 +- 10 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 crates/engine/data/thesaurus.txt create mode 100644 crates/engine/src/thesaurus.rs create mode 100644 crates/ui-egui/src/thesaurus_tests.rs diff --git a/crates/engine/data/thesaurus.txt b/crates/engine/data/thesaurus.txt new file mode 100644 index 00000000..1916eb46 --- /dev/null +++ b/crates/engine/data/thesaurus.txt @@ -0,0 +1,264 @@ +# DeckCraft's built-in thesaurus: headword|synonym, synonym, ... +# Written for DeckCraft (no third-party word list). Lowercase; one meaning group per line; +# a headword may appear on several lines (one per sense). +ability|capability, capacity, skill, competence, talent +able|capable, competent, qualified, skilled +accept|agree to, approve, adopt, embrace +accurate|precise, exact, correct, right +achieve|accomplish, attain, reach, realize, deliver +achievement|accomplishment, success, attainment, milestone +add|include, append, attach, insert +advantage|benefit, edge, strength, asset, upside +affect|influence, impact, change, alter +aim|goal, objective, target, purpose, intent +allow|permit, enable, let, authorize +analyze|examine, study, assess, evaluate, review +answer|reply, response, solution +approach|method, strategy, way, technique, plan +area|region, field, domain, zone, sector +arrange|organize, order, sort, plan +ask|request, inquire, query, question +assess|evaluate, gauge, judge, review, appraise +assist|help, aid, support, back +attempt|try, effort, endeavor, bid +bad|poor, weak, inferior, unfavorable, negative +basic|fundamental, essential, core, elementary, simple +begin|start, commence, launch, initiate, open +benefit|advantage, gain, value, payoff, upside +best|finest, top, leading, optimal, premier +big|large, major, substantial, significant, sizable +boost|increase, raise, lift, improve, strengthen +brief|short, concise, succinct, quick +bright|vivid, brilliant, luminous, clever +build|construct, create, develop, assemble, establish +business|company, firm, enterprise, organization, venture +buy|purchase, acquire, obtain +careful|cautious, thorough, meticulous, attentive +cause|reason, source, origin, driver +change|alter, modify, adjust, revise, transform +change|shift, transition, adjustment, revision +cheap|inexpensive, affordable, low-cost, economical +check|verify, confirm, examine, inspect, review +choice|option, alternative, selection, pick +choose|select, pick, opt for, decide on +clear|plain, obvious, evident, transparent, lucid +client|customer, buyer, account, patron +close|near, nearby, adjacent +close|end, finish, conclude, shut +common|frequent, usual, typical, widespread, shared +company|business, firm, organization, enterprise, corporation +complete|finish, conclude, finalize, accomplish +complete|whole, full, entire, total +complex|complicated, intricate, involved, elaborate +concern|worry, issue, problem, risk +consider|weigh, contemplate, evaluate, think about +continue|proceed, carry on, keep going, persist, maintain +cost|price, expense, charge, outlay +create|make, build, produce, design, generate +critical|crucial, vital, essential, key, decisive +current|present, existing, ongoing, latest, up-to-date +customer|client, buyer, consumer, user +data|information, figures, facts, statistics +decide|determine, choose, resolve, settle +decrease|reduce, lower, cut, decline, drop +define|specify, describe, explain, outline +deliver|provide, supply, bring, hand over, ship +demonstrate|show, prove, illustrate, display +describe|explain, outline, portray, depict +design|plan, layout, blueprint, pattern +design|create, plan, devise, draft +develop|build, create, grow, expand, evolve +difference|distinction, contrast, gap, variation +different|distinct, various, diverse, unlike +difficult|hard, challenging, tough, demanding +discuss|talk about, debate, examine, review +easy|simple, straightforward, effortless, painless +effect|result, impact, outcome, consequence +effective|successful, efficient, productive, powerful +effort|work, attempt, endeavor, exertion +end|finish, conclusion, close, completion +enough|sufficient, adequate, ample +ensure|guarantee, make sure, secure, confirm +error|mistake, fault, flaw, bug, slip +essential|vital, crucial, key, necessary, fundamental +establish|set up, found, create, institute +estimate|forecast, projection, approximation, guess +evaluate|assess, appraise, judge, rate, review +event|occasion, function, happening, incident +example|instance, case, illustration, sample +excellent|outstanding, superb, exceptional, first-rate +expand|grow, extend, enlarge, broaden, scale +expect|anticipate, foresee, predict, await +explain|clarify, describe, illustrate, account for +fail|fall short, miss, flop, break down +fast|quick, rapid, swift, speedy +feature|characteristic, attribute, capability, function +final|last, closing, concluding, ultimate +find|discover, locate, identify, uncover +finish|complete, end, conclude, wrap up +focus|concentrate, center, target, emphasize +focus|emphasis, priority, attention, core +future|upcoming, coming, forthcoming, prospective +gain|increase, rise, improvement, profit +get|obtain, acquire, receive, gain +give|provide, offer, supply, grant +goal|aim, objective, target, purpose, ambition +good|fine, great, strong, solid, positive +great|excellent, outstanding, terrific, remarkable +grow|increase, expand, rise, develop, scale +growth|expansion, increase, development, rise +help|assist, aid, support, enable +help|assistance, aid, support, guidance +high|elevated, tall, top, strong +idea|concept, notion, thought, proposal +identify|recognize, pinpoint, detect, determine +impact|effect, influence, consequence, result +important|significant, key, major, crucial, vital +improve|enhance, upgrade, refine, strengthen, boost +include|contain, cover, incorporate, comprise +increase|raise, boost, grow, expand, lift +increase|rise, growth, gain, jump +information|data, details, facts, knowledge +innovative|inventive, creative, original, novel, pioneering +issue|problem, concern, matter, question +job|task, assignment, role, position +keep|retain, maintain, preserve, hold +key|main, central, critical, essential, principal +large|big, sizable, substantial, major, extensive +last|final, latest, previous, closing +launch|start, introduce, roll out, release, unveil +lead|guide, direct, head, steer, run +learn|discover, find out, master, study +less|fewer, smaller, reduced, lower +little|small, minor, slight, modest +look|appear, seem +look|view, glance, examine, inspect +low|minimal, modest, small, reduced +main|primary, chief, principal, central, key +maintain|keep, preserve, sustain, uphold +major|significant, important, key, principal, big +make|create, build, produce, generate +manage|run, handle, oversee, direct, lead +market|industry, sector, segment, marketplace +measure|gauge, assess, quantify, track +meet|satisfy, fulfil, reach, achieve +meeting|session, conference, discussion, review +method|approach, technique, process, procedure, way +model|framework, template, pattern, design +more|additional, extra, further, added +move|shift, transfer, relocate, transition +need|requirement, demand, necessity +need|require, call for, demand, want +new|fresh, recent, novel, modern, latest +next|following, upcoming, subsequent +number|figure, count, total, quantity, amount +objective|goal, aim, target, purpose +offer|provide, propose, extend, present +old|previous, former, legacy, outdated, existing +open|start, launch, begin, unveil +opportunity|opening, chance, prospect, possibility +option|choice, alternative, possibility, route +organize|arrange, structure, coordinate, order +outcome|result, consequence, effect, end result +overview|summary, outline, synopsis, recap +part|portion, piece, segment, section, component +partner|ally, collaborator, associate, affiliate +people|staff, team, individuals, personnel +plan|strategy, scheme, roadmap, proposal, program +plan|organize, arrange, schedule, prepare +point|aspect, detail, issue, argument +popular|well-liked, widespread, in demand, favored +possible|feasible, achievable, viable, potential +present|show, display, introduce, demonstrate +present|current, existing, ongoing +price|cost, rate, charge, fee +priority|focus, concern, precedence, goal +problem|issue, challenge, difficulty, obstacle +process|procedure, method, workflow, system +produce|make, create, generate, manufacture, yield +product|offering, item, solution, good +profit|earnings, return, gain, margin +progress|advance, headway, development, improvement +project|initiative, program, undertaking, venture +propose|suggest, recommend, put forward, offer +provide|give, supply, deliver, offer +purpose|aim, goal, objective, intent, reason +quality|standard, caliber, excellence, grade +question|query, issue, inquiry +quick|fast, rapid, speedy, swift, prompt +rapid|fast, quick, swift, speedy +reach|achieve, attain, hit, arrive at +reason|cause, rationale, motive, basis +recent|latest, new, current, fresh +recommend|suggest, advise, propose, advocate +reduce|cut, lower, decrease, lessen, trim +region|area, territory, zone, market +relevant|pertinent, applicable, related, appropriate +remove|delete, eliminate, take out, drop +replace|substitute, swap, supersede, change +report|account, summary, review, statement +require|need, demand, call for, necessitate +research|study, investigation, analysis, inquiry +resource|asset, supply, means, capacity +result|outcome, effect, consequence, finding +review|assessment, evaluation, examination, analysis +review|examine, assess, evaluate, check, revisit +revenue|income, sales, earnings, turnover +risk|danger, threat, exposure, hazard +role|part, function, position, responsibility +run|operate, manage, conduct, execute +safe|secure, protected, reliable +same|identical, equal, matching, equivalent +save|keep, store, preserve, conserve +see|view, observe, notice, spot +sell|market, offer, trade, retail +service|support, assistance, offering +share|portion, part, stake, percentage +share|distribute, spread, communicate, pass on +show|demonstrate, display, present, reveal, illustrate +significant|important, notable, considerable, major, meaningful +similar|alike, comparable, related, equivalent +simple|easy, straightforward, plain, basic +size|scale, extent, magnitude, dimensions +small|little, minor, compact, modest +solution|answer, fix, remedy, resolution +solve|resolve, fix, settle, work out +specific|particular, precise, exact, distinct +start|begin, launch, commence, kick off, initiate +step|stage, phase, measure, action +strategy|plan, approach, policy, roadmap, tactic +strong|robust, solid, powerful, sturdy +structure|framework, organization, layout, arrangement +success|achievement, accomplishment, win, triumph +successful|effective, thriving, prosperous, winning +suggest|propose, recommend, advise, imply +summary|overview, recap, synopsis, digest +support|help, back, assist, sustain +support|help, assistance, backing, aid +sure|certain, confident, positive, definite +target|goal, objective, aim +task|job, assignment, duty, activity +team|group, crew, staff, squad, unit +test|trial, check, experiment, evaluation +test|try, check, evaluate, verify +think|believe, consider, reckon, suppose +time|period, duration, while, moment +tool|instrument, utility, device, aid +total|sum, whole, aggregate, overall +trend|pattern, tendency, movement, direction +understand|grasp, comprehend, follow, realize +unique|distinctive, singular, one-of-a-kind, unmatched +update|revise, refresh, upgrade, modernize +update|revision, change, upgrade, news +use|employ, apply, utilize, leverage +useful|helpful, practical, handy, valuable +value|worth, benefit, merit, importance +various|diverse, assorted, different, several +view|opinion, perspective, outlook, position +vision|outlook, aspiration, goal, dream +way|method, approach, manner, means +weak|feeble, fragile, poor, limited +well|effectively, successfully, nicely, properly +work|labor, effort, activity, job +work|function, operate, run, succeed +year|annum, twelve months diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..bf3b3920 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -16,6 +16,16 @@ pub fn specs() -> Vec { cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), + cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus), + cmd!( + "review.replaceWord", + "Insert Synonym", + [], + None, + "{with}: replace the selected word (or the word at the caret) while editing text", + has_text, + replace_word + ), cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { @@ -264,3 +274,36 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { })?; ok() } + +/// The selected text when it lies in one paragraph, else the word at the caret. +fn word_under_caret(s: &Session) -> Option { + let st = s.doc().ok()?; + let t = st.selection.text.clone()?; + let body = super::text::body_of(st, &t)?; + let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) }; + if a.0 != b.0 { + return None; + } + let text = body.paragraphs.get(a.0)?.text(); + let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect(); + let w = w.trim().to_string(); + (!w.is_empty()).then_some(w) +} + +fn thesaurus(s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default(); + let senses = crate::thesaurus::lookup(&word); + Ok(json!({"word": word, "senses": senses})) +} + +fn replace_word(s: &mut Session, p: &Value) -> Result { + let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string(); + if with.chars().count() > 200 { + return Err(bad("review.replaceWord", "too long")); + } + let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret); + if collapsed { + s.execute("text.selectWord", &json!({}))?; + } + s.execute("text.insert", &json!({"text": with})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..398801d4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod keys; pub mod links; pub mod recovery; pub mod sample; +pub mod thesaurus; pub mod tools; use std::sync::Arc; diff --git a/crates/engine/src/thesaurus.rs b/crates/engine/src/thesaurus.rs new file mode 100644 index 00000000..10cc207d --- /dev/null +++ b/crates/engine/src/thesaurus.rs @@ -0,0 +1,119 @@ +//! Review › Thesaurus: synonyms from a small built-in English word list (`data/thesaurus.txt`), +//! looked up offline so it works in the browser build too. + +const DATA: &str = include_str!("../data/thesaurus.txt"); + +/// One sense of a word: its headword and synonyms. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Sense { + pub word: String, + pub synonyms: Vec, +} + +fn senses_of(head: &str) -> Vec { + DATA.lines() + .filter(|l| !l.starts_with('#')) + .filter_map(|l| l.split_once('|')) + .filter(|(h, _)| h.trim() == head) + .map(|(h, s)| Sense { word: h.trim().to_string(), synonyms: s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() }) + .collect() +} + +/// The senses for `word`, trying simple English endings when the word itself isn't listed +/// ("goals" → "goal", "improved" → "improve", "growing" → "grow"). Synonyms take the word's +/// capitalisation ("Goal" → "Aim", "GOAL" → "AIM"). +pub fn lookup(word: &str) -> Vec { + let w: String = word.trim().trim_matches(|c: char| !c.is_alphanumeric() && c != '-').chars().take(64).collect(); + let lower = w.to_lowercase(); + if lower.is_empty() { + return vec![]; + } + let mut tries = vec![lower.clone()]; + for (suffix, add) in [("ies", "y"), ("es", ""), ("s", ""), ("ied", "y"), ("ed", "e"), ("ed", ""), ("ing", "e"), ("ing", "")] { + if let Some(stem) = lower.strip_suffix(suffix) + && stem.chars().count() >= 2 + { + tries.push(format!("{stem}{add}")); + } + } + let Some(found) = tries.iter().map(|t| senses_of(t)).find(|v| !v.is_empty()) else { return vec![] }; + let upper = w.chars().count() > 1 && w.chars().all(|c| !c.is_lowercase()); + let title = !upper && w.chars().next().is_some_and(char::is_uppercase); + found + .into_iter() + .map(|s| Sense { + word: s.word, + synonyms: s + .synonyms + .into_iter() + .map(|x| { + if upper { + x.to_uppercase() + } else if title { + let mut c = x.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } else { + x + } + }) + .collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn senses_endings_and_case() { + let g = lookup("goal"); + assert_eq!(g.len(), 1); + assert!(g[0].synonyms.contains(&"objective".to_string())); + assert_eq!(lookup("change").len(), 2, "verb and noun senses"); + assert_eq!(lookup("Goals")[0].synonyms[0], "Aim"); + assert_eq!(lookup("GOAL")[0].synonyms[0], "AIM"); + assert_eq!(lookup("improved")[0].word, "improve"); + assert_eq!(lookup("growing")[0].word, "grow"); + assert_eq!(lookup("strategies")[0].word, "strategy"); + assert_eq!(lookup("“goal,”")[0].word, "goal", "punctuation around the word"); + assert!(lookup("").is_empty() && lookup("zzxq").is_empty() && lookup(" ").is_empty()); + assert!(lookup(&"a".repeat(10_000)).is_empty()); + } + + #[test] + fn data_is_well_formed() { + for l in DATA.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let (h, s) = l.split_once('|').unwrap_or_else(|| panic!("no `|`: {l}")); + assert_eq!(h, h.trim().to_lowercase(), "{l}"); + let syn: Vec<&str> = s.split(',').map(str::trim).collect(); + assert!(syn.len() >= 2 && syn.iter().all(|x| !x.is_empty() && *x != h), "{l}"); + let mut d = syn.clone(); + d.sort(); + d.dedup(); + assert_eq!(d.len(), syn.len(), "duplicate synonym: {l}"); + } + } + + #[test] + fn commands_look_up_and_replace_the_word_at_the_caret() { + use serde_json::json; + let mut s = crate::Session::with_new(); + let id = s.execute("insert.textBox", &json!({"rect": [50, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let r = s.execute("review.thesaurus", &json!({})).unwrap(); + assert_eq!(r["word"], "Goal"); + assert_eq!(r["senses"][0]["synonyms"][0], "Aim"); + s.execute("review.replaceWord", &json!({"with": "Aim"})).unwrap(); + let text = |s: &crate::Session| { + s.doc().unwrap().shapes().iter().find(|x| x.id.0 as u64 == id).and_then(|x| x.text.as_ref()).map(|t| t.paragraphs[0].text()).unwrap() + }; + assert_eq!(text(&s), "Our Aim is growth."); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s), "Our Goal is growth."); + assert_eq!(s.execute("review.thesaurus", &json!({"word": "growth."})).unwrap()["senses"][0]["word"], "growth"); + assert!(s.execute("review.replaceWord", &json!({"with": ""})).is_err()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..d1a82cb7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -146,6 +146,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Zoom…", None, "{percent}"), ("view.fit", "Fit to Window", None, "{}"), + ("review.thesaurusPane", "Thesaurus", Some("Shift+F7"), "{word?}: open the Thesaurus pane on the selected word"), ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), @@ -184,6 +185,8 @@ pub struct SlideApp { pub textures: textures::Textures, pub control_rx: Option>, pub dialog: Option, + /// The word looked up in the Thesaurus pane. + pub thesaurus_query: String, pub show: Option, /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, @@ -239,6 +242,7 @@ impl SlideApp { textures: textures::Textures::default(), control_rx: None, dialog: None, + thesaurus_query: String::new(), show: None, media: media::MediaHost::new(audio_out), status: None, @@ -473,6 +477,19 @@ impl SlideApp { self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); } "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } "view.pane" => { let pane = p.get("pane").and_then(Value::as_str).map(String::from); self.ui.pane = diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..0cf608e6 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "thesaurus" => "Thesaurus", _ => "Pane", }; ui.horizontal(|ui| { @@ -50,6 +51,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), + "thesaurus" => thesaurus(app, ui), _ => {} }); }); @@ -635,3 +637,42 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(8.0); } } + +/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word +/// when editing text, otherwise looks that synonym up in turn. +fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let mut q = app.thesaurus_query.clone(); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + if edit.changed() { + app.thesaurus_query = q.clone(); + } + ui.add_space(6.0); + let senses = deckcraft_engine::thesaurus::lookup(&q); + if q.trim().is_empty() { + ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + return; + } + if senses.is_empty() { + ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); + return; + } + let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); + for sense in senses { + ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); + for syn in sense.synonyms { + let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); + if r.clicked() { + if editing { + run(app, "review.replaceWord", json!({"with": syn})); + } + app.thesaurus_query = syn; + } + } + ui.add_space(6.0); + } +} + +#[cfg(test)] +#[path = "thesaurus_tests.rs"] +mod thesaurus_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..d9c9f39c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1780,8 +1780,7 @@ fn record(app: &mut SlideApp, ui: &mut Ui) { fn review(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); - let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false); - let _ = r.on_hover_text("Thesaurus (coming soon)"); + big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); }); group(ui, |ui| { big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); diff --git a/crates/ui-egui/src/thesaurus_tests.rs b/crates/ui-egui/src/thesaurus_tests.rs new file mode 100644 index 00000000..985e6401 --- /dev/null +++ b/crates/ui-egui/src/thesaurus_tests.rs @@ -0,0 +1,54 @@ +//! Review › Thesaurus driven like a person: caret in a word, open the pane, click a synonym. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn thesaurus_pane_swaps_the_word_at_the_caret() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 100, 500, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Review".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Thesaurus").click(); + h.run_steps(3); + assert_eq!(h.state().ui.pane.as_deref(), Some("thesaurus")); + assert_eq!(h.state().thesaurus_query, "Goal"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/thesaurus.png"); + } + h.get_by_label("Objective").click(); + h.run_steps(3); + let text = h + .state() + .session + .active() + .unwrap() + .shapes() + .iter() + .find(|x| x.id.0 as u64 == id) + .and_then(|x| x.text.as_ref()) + .map(|t| t.paragraphs[0].text()); + assert_eq!(text.as_deref(), Some("Our Objective is growth.")); + assert_eq!(h.state().thesaurus_query, "Objective", "the pane follows the new word"); + + // Typing a word that isn't listed says so instead of showing nothing. + h.state_mut().thesaurus_query = "zzxq".into(); + h.run_steps(2); + assert!(h.query_by_label_contains("No synonyms").is_some()); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..97480599 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); + // Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape"). + let spoken = label.replace('\n', " "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken)); let painter = ui.painter_at(rect.expand(1.0)); let (main_rect, arrow_rect) = if split { (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..fb8e3698 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -21,7 +21,7 @@ The summary between ` | Shapes | 88% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 81% | | Tables | 89% | | Charts | 65% | | SmartArt | 33% | @@ -155,7 +155,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Text direction (rotated, stacked) | P1 | D | | Text | Find and replace, replace fonts | P1 | P | | Text | Spelling check | P1 | P | -| Text | Thesaurus | P2 | M | +| Text | Thesaurus | P2 | P | | Text | AutoCorrect and smart quotes | P2 | M | | Text | Symbols and emoji | P1 | D | | Text | Equations | P2 | M | From 4ba7deb5ff19dfcc307d27643e74925a9b42a695 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:44:40 +0300 Subject: [PATCH 34/69] Text: Find and Replace across slides, tables and notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds text.find (match case, whole words; matches in shape text, table cells and speaker notes) and text.replace (one match or all, as one undo step, keeping the first run's formatting). Home gets Find and Replace buttons, Ctrl+F / Ctrl+H and Edit › Find… / Replace… open a Find and Replace dialog that steps through matches slide by slide. Co-authored-by: Cursor --- crates/engine/src/cmd/find.rs | 240 ++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/tests.rs | 26 ++++ crates/ui-egui/src/dialogs.rs | 81 ++++++++++++ crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/ribbon.rs | 6 + docs/ui-parity.md | 4 +- 8 files changed, 369 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/find.rs diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs new file mode 100644 index 00000000..4991cd1f --- /dev/null +++ b/crates/engine/src/cmd/find.rs @@ -0,0 +1,240 @@ +//! Home › Editing: Find and Replace across slide text, table cells and speaker notes. + +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.find", + "Find", + ["Home", "Editing"], + None, + "{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)", + has_doc, + find + ), + cmd!( + "text.replace", + "Replace", + ["Home", "Editing"], + None, + "{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}", + has_doc, + replace + ), + ] +} + +/// Where a text body lives on a slide. +#[derive(Clone, Copy, PartialEq, Debug)] +struct Loc { + shape: Option, + cell: Option<(usize, usize)>, +} + +/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes. +fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) { + fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) { + if depth > 64 { + return; + } + for sh in shapes { + let id = Some(u64::from(sh.id.0)); + if let Some(t) = &mut sh.text { + f(Loc { shape: id, cell: None }, t); + } + match &mut sh.kind { + ShapeKind::Table(t) => { + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text); + } + } + } + ShapeKind::Group { children, .. } => rec(children, depth + 1, f), + _ => {} + } + } + } + rec(&mut slide.shapes, 0, f); + f(Loc { shape: None, cell: None }, &mut slide.notes); +} + +struct Opts { + pat: Vec, + case: bool, + whole: bool, +} + +impl Opts { + fn from(p: &Value, cmd: &str) -> Result { + let pat: Vec = str_param(p, "text").unwrap_or("").chars().collect(); + if pat.is_empty() { + return Err(bad(cmd, "missing `text`")); + } + Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) }) + } + fn eq(&self, a: char, b: char) -> bool { + a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase())) + } + /// Non-overlapping matches as char ranges. + fn matches(&self, text: &[char]) -> Vec<(usize, usize)> { + let n = self.pat.len(); + let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_'); + let mut out = vec![]; + let mut i = 0; + while i + n <= text.len() { + let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b)) + && (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n)))); + if hit { + out.push((i, i + n)); + i += n; + } else { + i += 1; + } + } + out + } +} + +fn para_chars(p: &Paragraph) -> Vec { + p.runs.iter().flat_map(|r| r.text.chars()).collect() +} + +/// Matches that lie wholly in plain text runs (never inside a field, equation or break). +fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut off = 0; + for r in &p.runs { + let n = r.text.chars().count(); + spans.push((off, off + n, matches!(r.kind, RunKind::Text))); + off += n; + } + o.matches(¶_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect() +} + +/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting. +fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) { + let mut off = 0; + let mut placed = false; + for r in &mut p.runs { + let chars: Vec = r.text.chars().collect(); + let (s, e) = (off, off + chars.len()); + off = e; + if e <= a || s >= b { + continue; + } + let keep_head: String = chars[..a.saturating_sub(s)].iter().collect(); + let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect(); + r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") }; + placed = true; + } + if p.runs.len() > 1 { + p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text))); + } +} + +fn find(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.find")?; + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + let mut sl = (**sl).clone(); + bodies(&mut sl, &mut |loc, t| { + for (pi, para) in t.paragraphs.iter().enumerate() { + for (a, b) in para_matches(para, &o) { + out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b})); + } + } + }); + } + Ok(Value::Array(out)) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.replace")?; + let with = str_param(p, "with").unwrap_or("").to_string(); + let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> { + let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`"))); + let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))); + Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?)) + }); + let at = at.transpose()?; + let count = s.edit(|doc, _| { + let mut count = 0; + for (i, slp) in doc.slides.iter_mut().enumerate() { + if at.is_some_and(|a| a.0 != i) { + continue; + } + let mut sl = (**slp).clone(); + let mut changed = 0; + bodies(&mut sl, &mut |loc, t| { + if at.is_some_and(|a| a.1 != loc) { + return; + } + for (pi, para) in t.paragraphs.iter_mut().enumerate() { + if at.is_some_and(|a| a.2 != pi) { + continue; + } + for (a, b) in para_matches(para, &o).into_iter().rev() { + if at.is_some_and(|x| x.3 != a) { + continue; + } + splice(para, a, b, &with); + changed += 1; + } + } + }); + if changed > 0 { + *slp = Arc::new(sl); + count += changed; + } + } + if count == 0 { + return Err(bad("text.replace", "no matches")); + } + Ok(count) + })?; + Ok(json!({"replaced": count})) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::{Run, RunProps}; + + fn opts(t: &str, case: bool, whole: bool) -> Opts { + Opts { pat: t.chars().collect(), case, whole } + } + + #[test] + fn matching_honours_case_and_whole_words() { + let t: Vec = "Cat cat category".chars().collect(); + assert_eq!(opts("cat", false, false).matches(&t).len(), 3); + assert_eq!(opts("cat", true, false).matches(&t).len(), 2); + assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]); + } + + #[test] + fn splice_across_runs_keeps_the_first_runs_format() { + let bold = RunProps { bold: Some(true), ..Default::default() }; + let mut p = Paragraph { + runs: vec![ + Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text }, + Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text }, + ], + ..Default::default() + }; + splice(&mut p, 1, 5, "ola"); + assert_eq!(para_chars(&p).iter().collect::(), "Hola world"); + assert_eq!(p.runs[0].text, "Hola"); + assert_eq!(p.runs[0].props, bold); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..7e0b7e36 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ pub mod arrange; pub mod design; pub mod edit; pub mod file; +pub mod find; pub mod format; pub mod insert; pub mod inspect; @@ -139,6 +140,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(transition::specs()); v.extend(animation::specs()); v.extend(table::specs()); + v.extend(find::specs()); v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..e02bc740 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,29 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn find_and_replace_across_shapes_and_notes() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old plan, old team\nboldly"})).unwrap(); + s.execute("slide.notes", &json!({"text": "Discuss the old plan"})).unwrap(); + let q = json!({"text": "old"}); + let found = s.execute("text.find", &q).unwrap(); + let n = found.as_array().unwrap().len(); + assert!(n >= 3, "{found}"); + let whole = s.execute("text.find", &json!({"text": "old", "wholeWords": true, "matchCase": true})).unwrap(); + assert_eq!(whole.as_array().unwrap().len(), n - 2, "Old and boldly are not whole lower-case matches"); + + let first = found[0].clone(); + assert_eq!((first["para"].as_u64(), first["start"].as_u64()), (Some(0), Some(0))); + s.execute("text.replace", &json!({"text": "old", "with": "New", "at": first})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); + + let r = s.execute("text.replace", &json!({"text": "old", "with": "new", "wholeWords": true})).unwrap(); + assert_eq!(r["replaced"].as_u64(), Some(n as u64 - 2)); + let text = s.doc().unwrap().doc.slides[0].shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::>().join("|"); + assert!(text.contains("New plan, new team\nboldly"), "{text}"); + assert!(s.execute("text.replace", &json!({"text": "zebra", "with": "x"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..24e5264d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -49,6 +49,7 @@ fn title(id: &str) -> &'static str { "symbol" => "Symbol", "equation" => "Equation", "spelling" => "Spelling", + "find" => "Find and Replace", "accessibility" => "Accessibility Checker", "language" => "Language", "customShow" => "Custom Shows", @@ -509,6 +510,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "find" => find_dialog(app, ui, d), "spelling" => { let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); @@ -829,3 +831,82 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. +fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let replace_mode = d.get("replace", "0") == "1"; + field(ui, d, "Find what:", "text", ""); + if replace_mode { + field(ui, d, "Replace with:", "with", ""); + } + let case = check(ui, d, "Match case", "matchCase", false); + let whole = check(ui, d, "Find whole words only", "wholeWords", false); + let text = d.get("text", ""); + let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); + let mut close = false; + let mut find_next = false; + ui.add_space(6.0); + ui.horizontal(|ui| { + let has = !text.is_empty(); + if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + find_next = true; + } + if replace_mode { + if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); + if let Some(at) = cur { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + p["at"] = at; + if app.session.execute("text.replace", &p).is_ok() { + let n = d.get("next", "1").parse::().unwrap_or(1); + d.fields.insert("next".into(), n.saturating_sub(1).to_string()); + } + } + find_next = true; + } + if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + let msg = match app.session.execute("text.replace", &p) { + Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), + Err(_) => format!("DeckCraft can't find “{text}”."), + }; + d.fields.insert("status".into(), msg); + d.fields.remove("cur"); + } + } else if ui.button("Replace…").clicked() { + d.fields.insert("replace".into(), "1".into()); + } + if ui.button("Close").clicked() { + close = true; + } + }); + if find_next { + let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.remove("cur"); + } else { + let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); + let m = &list[n]; + let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + d.fields.insert("cur".into(), m.to_string()); + d.fields.insert("next".into(), (n + 1).to_string()); + let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + d.fields.insert( + "status".into(), + format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + ); + } + } + let status = d.get("status", ""); + if !status.is_empty() { + ui.add_space(4.0); + ui.label(status); + } + close +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..9ac7f431 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -168,6 +168,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), + ("app.find", "Find…", Some("Cmd+F"), "{}"), + ("app.replace", "Replace…", Some("Cmd+H"), "{}"), ("app.links", "Community Links", None, "{}"), ]; @@ -541,6 +543,12 @@ impl SlideApp { "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..ce1e2b39 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -61,10 +61,8 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); return true; } - (F, true, false, false) => { - app.palette = Some((String::new(), 0)); - return true; - } + (F, true, false, false) => "app.find", + (H, true, false, false) => "app.replace", _ => return false, }; let _ = app.run(id, json!({})); @@ -176,7 +174,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Duplicate", "edit.duplicate"), ("Delete Slide", "slide.delete"), ("-", ""), - ("Find…", "app.palette"), + ("Find…", "app.find"), + ("Replace…", "app.replace"), ], ), ( diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..a1e57fca 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -998,6 +998,12 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + }); + }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); }); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..8d3d2dba 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | 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…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, 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…) | 28 (`crates/ui-egui/src/dialogs.rs`): Find and Replace, Table, Slide Size, 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 | ## Direct manipulation @@ -137,7 +137,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been |---|---| | Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 | | Format Shape pane depth | 6–10 | -| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | +| Missing dialogs (Font, Bullets and Numbering, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Grid Options, edit points UI, crop depth | 4–7 | | Multiple windows | 3–5 | From b763579d94a1cfc6fba3a7a50c3ee8ae78ac5ffb Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:55:59 +0300 Subject: [PATCH 35/69] View: movable drawing guides saved with the presentation View > Guides used to paint two fixed centre lines. Guides now belong to the presentation (DrawGuide, read from and written to p:guideLst in viewProps.xml, in eighths of a point), so they survive a save and reopen in PowerPoint. - Turning Guides on gives a deck without guides PowerPoint's two centre guides. - A guide can be dragged anywhere away from shapes. It moves in whole points, unless Alt is held or snapping is off, and shows its distance from the centre while it moves. - A guide dropped off the slide is deleted. - The slide context menu's Grid and Guides submenu has Add Vertical Guide and Add Horizontal Guide. - While guides are shown, moving and resizing shapes snaps to them. New commands guide.add, guide.move and guide.delete are undoable; guide.list is a query. Co-authored-by: Cursor --- crates/engine/src/cmd/arrange.rs | 85 +++++++++++++++++++++++++++ crates/engine/src/cmd/guides_tests.rs | 76 ++++++++++++++++++++++++ crates/engine/src/tools.rs | 64 ++++++++++++++++++++ crates/model/src/defaults.rs | 1 + crates/model/src/lib.rs | 12 ++++ crates/pptx/src/read/mod.rs | 14 +++++ crates/pptx/src/write/mod.rs | 25 +++++++- crates/ui-egui/src/canvas.rs | 82 ++++++++++++++++++++++---- crates/ui-egui/src/guides_tests.rs | 69 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 8 ++- 10 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/cmd/guides_tests.rs create mode 100644 crates/ui-egui/src/guides_tests.rs diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 0c91bbaa..fb2c8c53 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -9,6 +9,18 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!( + "guide.add", + "Add Guide", + ["View", "Show", "Guides"], + None, + "{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none", + has_doc, + guide_add + ), + cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move), + cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete), + cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list), cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)), cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)), cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)), @@ -338,3 +350,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +/// Guides are kept on the slide; a file can hold at most this many. +const MAX_GUIDES: usize = 200; + +fn guide_limit(s: &Session, vertical: bool) -> Result { + let size = s.doc()?.doc.slide_size; + Ok(if vertical { size.width } else { size.height }) +} + +fn guide_add(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "defaults", false) { + if !s.doc()?.doc.guides.is_empty() { + return Ok(json!({"added": 0})); + } + let size = s.doc()?.doc.slide_size; + return s.edit(|doc, _| { + doc.guides = vec![ + deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 }, + deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 }, + ]; + Ok(json!({"added": 2})) + }); + } + let vertical = bool_or(p, "vertical", true); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit); + if s.doc()?.doc.guides.len() >= MAX_GUIDES { + return Err(bad("guide.add", "too many guides")); + } + s.edit(|doc, _| { + doc.guides.push(deckcraft_model::DrawGuide { vertical, pos }); + Ok(json!({"index": doc.guides.len() - 1})) + }) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?; + if i >= s.doc()?.doc.guides.len() { + return Err(bad(cmd, "no such guide")); + } + Ok(i) +} + +fn guide_move(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.move")?; + let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit); + s.edit(|doc, _| { + if let Some(g) = doc.guides.get_mut(i) { + g.pos = pos; + } + Ok(json!({"index": i, "pos": pos})) + }) +} + +fn guide_delete(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.delete")?; + s.edit(|doc, _| { + if i < doc.guides.len() { + doc.guides.remove(i); + } + ok() + }) +} + +fn guide_list(s: &mut Session, _p: &Value) -> Result { + Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default()) +} + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/engine/src/cmd/guides_tests.rs b/crates/engine/src/cmd/guides_tests.rs new file mode 100644 index 00000000..3211b6bb --- /dev/null +++ b/crates/engine/src/cmd/guides_tests.rs @@ -0,0 +1,76 @@ +//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and +//! the PPTX round trip. + +use serde_json::json; + +use crate::Session; +use crate::tools::{Mods, PointerEvent, PointerKind}; + +fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0 }).unwrap(); +} + +#[test] +fn add_move_delete_and_undo() { + let mut s = Session::with_new(); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none"); + let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap(); + assert_eq!(i, 2); + s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide"); + assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err()); + assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err()); + s.execute("guide.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 3); +} + +#[test] +fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + // Hidden guides can't be grabbed: the press starts a marquee instead. + ev(&mut s, PointerKind::Down, 300.0, 50.0); + ev(&mut s, PointerKind::Up, 300.0, 50.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0); + s.tool.guides_visible = true; + ev(&mut s, PointerKind::Down, 301.0, 50.0); + ev(&mut s, PointerKind::Drag, 350.4, 60.0); + assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging"); + ev(&mut s, PointerKind::Up, 350.4, 60.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0); + assert_eq!(s.tool.guide_drag, None); + + // A shape dragged near the guide snaps its left edge onto it. + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap(); + s.prefs.snap_to_grid = false; + ev(&mut s, PointerKind::Down, 140.0, 230.0); + ev(&mut s, PointerKind::Drag, 200.0, 230.0); + ev(&mut s, PointerKind::Drag, 388.0, 231.0); + ev(&mut s, PointerKind::Up, 388.0, 231.0); + let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap(); + assert!((x.x - 350.0).abs() < 0.01, "{x:?}"); + + // Dropped off the slide: gone, and undo brings it back. + ev(&mut s, PointerKind::Down, 350.0, 10.0); + ev(&mut s, PointerKind::Drag, -40.0, 10.0); + ev(&mut s, PointerKind::Up, -40.0, 10.0); + assert!(s.doc().unwrap().doc.guides.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 1); +} + +#[test] +fn guides_round_trip_through_pptx() { + let mut s = Session::with_new(); + s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap(); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert_eq!(back.guides, s.doc().unwrap().doc.guides); + let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap(); + assert!(none.guides.is_empty()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..282be6ea 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -123,6 +123,10 @@ enum Drag { start: Point, add: bool, }, + /// Dragging drawing guide `index`. + Guide { + index: usize, + }, Create { start: Point, }, @@ -178,6 +182,12 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. + pub guides_visible: bool, + /// The guide being dragged and where it is now (slide points); `None` once dropped. + pub guide_drag: Option<(usize, f64)>, + /// The drawing guide under the pointer, for the cursor. + pub guide_hover: Option, } impl ToolState { @@ -286,6 +296,8 @@ impl Session { return Ok(Value::Null); } self.tool.hover = self.hit_test(p, tol); + self.tool.guide_hover = + if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); if line_tool { self.update_sites(p, tol, None); @@ -451,6 +463,14 @@ impl Session { let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); } } + None if self.guide_at(p, tol).is_some() => { + // Away from every shape a press on a guide picks the guide up. + if let Some(index) = self.guide_at(p, tol) { + let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); + self.tool.drag = Some(Drag::Guide { index }); + self.tool.guide_drag = Some((index, pos)); + } + } None => { let add = mods.shift || mods.cmd; self.select(|_, sel| { @@ -470,6 +490,13 @@ impl Session { fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { + Drag::Guide { index } => { + let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); + let pos = if vertical { p.x } else { p.y }; + // Guides move in whole points unless snapping is off, as in PowerPoint. + let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; + self.tool.guide_drag = Some((index, pos)); + } Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { let mut d = p - start; if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { @@ -668,6 +695,18 @@ impl Session { self.tool.glue = None; let mut out = Value::Null; match drag { + Some(Drag::Guide { index }) => { + // Dropped off the slide: the guide is deleted. + let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); + let size = self.doc()?.doc.slide_size; + let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); + let limit = if vertical { size.width } else { size.height }; + out = if pos < 0.0 || pos > limit { + self.execute("guide.delete", &json!({"index": index}))? + } else { + self.execute("guide.move", &json!({"index": index, "pos": pos}))? + }; + } Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { // Plain click on text: caret at the click. let st = self.doc()?; @@ -942,6 +981,22 @@ impl Session { }) } + /// The drawing guide within `tol` of `p` (only while guides are shown). + pub fn guide_at(&self, p: Point, tol: f64) -> Option { + if !self.tool.guides_visible { + return None; + } + let st = self.active()?; + st.doc + .guides + .iter() + .enumerate() + .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) + .filter(|(_, d)| *d <= tol.max(2.0)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to /// apply and the guides to draw. fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { @@ -960,6 +1015,15 @@ impl Session { (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), (size.height, Rect::new(0.0, size.height, size.width, size.height)), ]; + if self.tool.guides_visible { + for g in &st.doc.guides { + if g.vertical { + xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); + } else { + ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); + } + } + } for sh in st.shapes() { if exclude.contains(&sh.id) || sh.hidden { continue; diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8432ce4a..3a7a4d3c 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -513,6 +513,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen next_id: next.max(256), embedded_fonts: vec![], table_styles: vec![], + guides: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..b884cdf2 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -843,11 +843,23 @@ pub struct Presentation { /// Table styles defined in the file (`ppt/tableStyles.xml`). #[serde(skip_serializing_if = "Vec::is_empty")] pub table_styles: Vec, + /// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } +/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal +/// one from its top. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawGuide { + pub vertical: bool, + pub pos: f64, +} + impl Default for Presentation { fn default() -> Self { defaults::new_presentation(None) diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..0b64fb86 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -357,6 +357,20 @@ pub fn import(bytes: &[u8]) -> Result { p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); } } + // Drawing guides (eighths of a point). + if let Some(r) = pres_part.rels.first_of("viewProps") + && let Some(d) = pkg.xml(&r.target) + && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) + { + p.guides = lst + .children_named("guide") + .filter_map(|g| { + let pos = g.attr("pos").and_then(|v| v.parse::().ok()).unwrap_or(0.0) / 8.0; + pos.is_finite().then_some(deckcraft_model::DrawGuide { vertical: g.attr("orient") != Some("horz"), pos }) + }) + .take(1000) + .collect(); + } // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..03fad19b 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -511,7 +511,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { pres.rels.add("viewProps", "viewProps.xml", false); pres.rels.add("tableStyles", "tableStyles.xml", false); x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p)); - x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); + x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml(p)); let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids); @@ -767,13 +767,34 @@ fn pres_props_xml(p: &Presentation) -> Vec { w.finish() } -fn view_props_xml() -> Vec { +fn view_props_xml(p: &Presentation) -> Vec { let mut w = W::new(); root_open(&mut w, "p:viewPr", A::new()); w.open0("p:normalViewPr"); w.empty("p:restoredLeft", A::new().a("sz", 15620)); w.empty("p:restoredTop", A::new().a("sz", 94660)); w.close("p:normalViewPr"); + if !p.guides.is_empty() { + // Drawing guides; `pos` is in eighths of a point (576 per inch). + w.open0("p:slideViewPr"); + w.open0("p:cSldViewPr"); + w.open0("p:cViewPr"); + w.open0("p:scale"); + w.empty("a:sx", A::new().a("n", 100).a("d", 100)); + w.empty("a:sy", A::new().a("n", 100).a("d", 100)); + w.close("p:scale"); + w.empty("p:origin", A::new().a("x", 0).a("y", 0)); + w.close("p:cViewPr"); + w.open0("p:guideLst"); + for g in p.guides.iter().take(1000) { + let pos = (g.pos * 8.0).round().clamp(-1.0e9, 1.0e9) as i64; + let a = if g.vertical { A::new().a("pos", pos) } else { A::new().a("orient", "horz").a("pos", pos) }; + w.empty("p:guide", a); + } + w.close("p:guideLst"); + w.close("p:cSldViewPr"); + w.close("p:slideViewPr"); + } w.empty("p:gridSpacing", A::new().a("cx", 76200).a("cy", 76200)); w.close("p:viewPr"); w.finish() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..7f69d902 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -148,16 +148,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { y += step; } } + app.session.tool.guides_visible = app.ui.guides; if app.ui.guides { - let c = slide_rect.center(); - painter.line_segment( - [pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); - painter.line_segment( - [pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); + draw_guides(app, &painter, xf, slide_rect, &t); } if app.ui.ruler { ruler(&painter, avail, slide_rect, scale, &t); @@ -366,6 +359,54 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } } +/// The presentation's drawing guides; the one being dragged at its new place, labelled with its +/// distance from the slide's centre in points. +fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let size = st.doc.slide_size; + let drag = app.session.tool.guide_drag; + for (i, g) in st.doc.guides.iter().enumerate() { + let (pos, active) = match drag { + Some((di, p)) if di == i => (p, true), + _ => (g.pos, app.session.tool.guide_hover == Some(i)), + }; + let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; + let stroke = Stroke::new(1.0, col); + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(pos, 0.0)).x; + (pos2(x, slide.min.y), pos2(x, slide.max.y)) + } else { + let y = xf.to_screen(Point::new(0.0, pos)).y; + (pos2(slide.min.x, y), pos2(slide.max.x, y)) + }; + // Dashed, like PowerPoint's guides. + let len = (b - a).length(); + let dir = (b - a) / len.max(1.0); + let mut d = 0.0; + while d < len { + painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); + d += 7.0; + } + if drag.is_some_and(|(di, _)| di == i) { + let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; + let off = pos - centre; + let side = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => " left of centre", + (true, _, true) => " right of centre", + (false, true, _) => " above centre", + (false, _, true) => " below centre", + _ => " (centre)", + }; + let label = format!("{:.0} pt{side}", off.abs()); + let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; + let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); + let r = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(r, CornerRadius::same(3), t.card); + painter.galley(at, galley, t.text); + } + } +} + fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); @@ -413,7 +454,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { } } (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, - _ => egui::CursorIcon::Default, + _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { + Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Default, + }, }; if resp.hovered() { ui.ctx().set_cursor_icon(cursor); @@ -682,6 +727,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); if ui.button("Reset Slide").clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); @@ -697,3 +755,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/ui-egui/src/guides_tests.rs b/crates/ui-egui/src/guides_tests.rs new file mode 100644 index 00000000..4180e503 --- /dev/null +++ b/crates/ui-egui/src/guides_tests.rs @@ -0,0 +1,69 @@ +//! Drawing guides driven with the mouse: View › Guides adds the centre guides, dragging one +//! moves it (with its distance from the centre shown), dropping it off the slide deletes it. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn turn_guides_on_drag_one_and_drop_it_off_the_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().run("view.guides", json!({"on": true})).unwrap(); + h.run_steps(2); + let guides = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.guides.clone(); + assert_eq!(guides(&h).len(), 2, "the two centre guides"); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + + // Grab the vertical guide away from the horizontal one and drag it left. + let start = at(480.0, 100.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(480.0 - 18.0 * i as f32, 100.0))); + h.run_steps(1); + } + assert_eq!(h.state().session.tool.guide_drag, Some((0, 300.0))); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/guides.png"); + } + h.event(egui::Event::PointerButton { pos: at(300.0, 100.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h)[0].pos, 300.0); + + // Off the top of the slide with the horizontal guide: deleted. + let g = at(700.0, 270.0); + h.hover_at(g); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: g, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(700.0, 270.0 - 30.0 * i as f32))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: at(700.0, -30.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h).len(), 1); + assert!(guides(&h)[0].vertical); + + // Hidden guides stay in the file but aren't drawn or grabbed. + h.state_mut().run("view.guides", json!({"on": false})).unwrap(); + h.run_steps(2); + assert!(!h.state().session.tool.guides_visible); + assert_eq!(guides(&h).len(), 1); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..cb0e7b31 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -457,7 +457,13 @@ impl SlideApp { "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } "view.zoomIn" | "view.zoomOut" => { let cur = self.ui.zoom.unwrap_or(self.canvas_scale); let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; From 8a36a6990e2e800bdedcf5c82220daaf64406c3b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:13:17 +0300 Subject: [PATCH 36/69] View: indent and tab markers on the ruler While you edit text, the horizontal ruler now shows the text area's span and the indent and tab markers of the paragraph with the caret. - First-line indent: the top triangle. - Hanging indent: the bottom triangle. Dragging it moves the left indent while the first line stays put. - Left indent: the box under the hanging triangle. Dragging it moves both. - Tab stops are drawn as left, centre, right and decimal marks. - A click on the ruler adds a left tab, dragging a tab moves it, and dragging a tab down off the ruler removes it. Every change goes through a command (format.paragraph or the new format.tabs). Each is one undo step and applies to the selected paragraphs, and text editing goes on throughout. format.ruler is a new query that reports the text area's position and the paragraph's resolved indents and tabs. Rotated and vertical text show no markers. Co-authored-by: Cursor --- crates/engine/src/cmd/format.rs | 92 ++++++++++++++++ crates/engine/src/cmd/ruler_tests.rs | 59 +++++++++++ crates/ui-egui/src/canvas.rs | 153 +++++++++++++++++++++++++++ crates/ui-egui/src/ruler_tests.rs | 85 +++++++++++++++ docs/parity-checklist.md | 8 +- 5 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/ruler_tests.rs create mode 100644 crates/ui-egui/src/ruler_tests.rs diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..8581cb5d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -139,6 +139,16 @@ pub fn specs() -> Vec { has_text_or_shapes, paragraph ), + cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler), + cmd!( + "format.tabs", + "Tabs…", + ["Home", "Paragraph"], + None, + "{add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", + has_text_or_shapes, + tabs + ), cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), cmd!( "format.direction", @@ -682,3 +692,85 @@ fn state(s: &mut Session, _p: &Value) -> Result { "painter": s.painter.is_some(), })) } + +/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, +/// and where its text area sits on the slide. +fn ruler(s: &mut Session, _p: &Value) -> Result { + let props = current_para(s); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; + let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { + (false, Some(sh)) => { + let ctx = super::text::ctx_for(&st.doc, &st.selection); + let x = match &ctx { + Some(c) => resolve::xfrm(c, sh), + None => sh.xfrm.unwrap_or_default(), + }; + let body = match &ctx { + Some(c) => resolve::body(c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); + (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) + } + _ => (0.0, 0.0, 0.0, true), + }; + let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + Ok(json!({ + "x": x, "y": y, "width": w, "rotated": rotated, + "marginLeft": props.margin_left.unwrap_or(0.0), + "indent": props.indent.unwrap_or(0.0), + "tabs": tabs, + "defaultTab": props.default_tab.unwrap_or(72.0), + })) +} + +const MAX_TABS: usize = 32; + +fn tabs(s: &mut Session, p: &Value) -> Result { + let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); + let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => "l", + }; + let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); + let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); + let remove = fin(p.get("remove").and_then(Value::as_f64)); + let clear = bool_or(p, "clear", false); + let default_tab = fin(p.get("defaultTab").and_then(Value::as_f64)).filter(|v| *v >= 1.0); + if add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { + return Err(bad("format.tabs", "nothing to change")); + } + // The stops as the caret's paragraph shows them (inherited ones included), so editing a + // placeholder's tab that came from its layout works like editing one set on the paragraph. + let base = current_para(s).tabs.unwrap_or_default(); + let near = |a: f64, b: f64| (a - b).abs() < 0.5; + let mut stops = if clear { vec![] } else { base }; + if let Some(at) = remove { + stops.retain(|t| !near(t.pos, at)); + } + if let Some((from, to)) = mv + && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) + { + t.pos = to; + } + if let Some((pos, align)) = add { + stops.retain(|t| !near(t.pos, pos)); + stops.push(deckcraft_model::text::TabStop { pos, align }); + } + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + stops.dedup_by(|a, b| near(a.pos, b.pos)); + stops.truncate(MAX_TABS); + format_para(s, &move |para| { + para.props.tabs = Some(stops.clone()); + if let Some(d) = default_tab { + para.props.default_tab = Some(d); + } + }) +} + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs new file mode 100644 index 00000000..1d23154e --- /dev/null +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -0,0 +1,59 @@ +//! The ruler's data and tab-stop editing. + +use serde_json::json; + +use crate::Session; + +fn editing() -> (Session, u64) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "a\tb"})).unwrap(); + (s, id) +} + +#[test] +fn ruler_reports_the_text_area_and_indents() { + let (mut s, _) = editing(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}"); + assert_eq!(r["rotated"], false); + assert_eq!(r["tabs"].as_array().unwrap().len(), 0); + s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0))); + s.execute("text.exit", &json!({})).unwrap(); + assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text"); +} + +#[test] +fn tabs_add_move_remove_clear_and_undo() { + let (mut s, _) = editing(); + let tabs = |s: &mut Session| -> Vec<(f64, String)> { + s.execute("format.ruler", &json!({})).unwrap()["tabs"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string())) + .collect() + }; + s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap(); + s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted"); + s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced"); + s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap(); + s.execute("format.tabs", &json!({"remove": 72.2})).unwrap(); + assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tabs(&mut s).len(), 2); + s.execute("format.tabs", &json!({"clear": true})).unwrap(); + assert!(tabs(&mut s).is_empty()); + assert!(s.execute("format.tabs", &json!({})).is_err()); + assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); + for i in 0..100 { + s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); + } + assert!(tabs(&mut s).len() <= 32); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..073f9f41 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -161,6 +161,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } if app.ui.ruler { ruler(&painter, avail, slide_rect, scale, &t); + ruler_markers(app, ui, &painter, avail, xf, &t); } pointer(app, ui, &resp, xf); overlays(app, ui, &painter, xf, &t); @@ -366,6 +367,154 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } } +/// What is being dragged on the ruler. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RulerDrag { + /// First-line indent (the top triangle). + First, + /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. + Hanging, + /// Left indent (the box under the hanging triangle): moves both together. + Left, + /// The tab stop that was at this position (pt from the text area's left). + Tab(f64), +} + +/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, +/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the +/// ruler removes it. +fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { + if app.session.active().is_none_or(|d| d.selection.text.is_none()) { + return; + } + let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; + if info["rotated"].as_bool().unwrap_or(true) { + return; + } + let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); + let tabs: Vec<(f64, String)> = info["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) + .unwrap_or_default(); + let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); + let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; + let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; + // The text area's span, outlined on the ruler. + let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); + p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); + + let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag()); + let id = ui.id().with("ruler_drag"); + let mut drag: Option = ui.data(|d| d.get_temp::(id)); + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + if resp.drag_started() + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; + let y = at.y - top.min.y; + drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { + Some(RulerDrag::Tab(*pos)) + } else if y < 6.0 && near(ml + ind) { + Some(RulerDrag::First) + } else if (6.0..11.0).contains(&y) && near(ml) { + Some(RulerDrag::Hanging) + } else if y >= 11.0 && near(ml) { + Some(RulerDrag::Left) + } else { + None + }; + ui.data_mut(|d| match drag { + Some(v) => { + d.insert_temp(id, v); + } + None => { + d.remove::(id); + } + }); + } + // Where each marker is drawn: the dragged one follows the pointer. + let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); + let (mut first, mut left) = (ml + ind, ml); + match live { + Some((RulerDrag::First, q, _)) => first = q, + Some((RulerDrag::Hanging, q, _)) => left = q, + Some((RulerDrag::Left, q, _)) => { + first = q + ind; + left = q; + } + _ => {} + } + let col = t.text_dim; + let tri = |x: f32, y0: f32, down: bool| { + let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); + }; + tri(sx(first), top.min.y + 1.0, true); + tri(sx(left), top.min.y + 6.0, false); + p.rect( + Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), + CornerRadius::ZERO, + t.card, + Stroke::new(1.0, col), + egui::StrokeKind::Inside, + ); + for (pos, align) in &tabs { + let (q, gone) = match live { + Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), + _ => (*pos, false), + }; + if gone { + continue; + } + let (x, y) = (sx(q), top.max.y - 2.0); + let st = Stroke::new(1.5, t.text); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align.as_str() { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, t.text); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } + } + } + if drag.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Commit on release; a click on bare ruler adds a left tab. + if resp.drag_stopped() { + if let (Some(d), Some(at)) = (drag, pointer) { + let q = to_q(at.x); + let r = match d { + RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), + RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), + RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), + RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), + RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), + }; + if let Err(e) = r { + app.set_status(e); + } + } + ui.data_mut(|d| d.remove::(id)); + } else if resp.clicked() + && let Some(at) = resp.interact_pointer_pos() + && at.x >= area.min.x + && at.x <= area.max.x + { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": "l"}})); + } +} + fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); @@ -697,3 +846,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs new file mode 100644 index 00000000..fd12d206 --- /dev/null +++ b/crates/ui-egui/src/ruler_tests.rs @@ -0,0 +1,85 @@ +//! The ruler's indent and tab markers driven with the mouse while editing text. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn drag(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=8 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, SlideApp>, at: Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value { + h.state_mut().session.execute("format.ruler", &json!({})).unwrap() +} + +#[test] +fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Name\tRole\tTeam"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + // The horizontal ruler runs along the top of the canvas. + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + + // First-line indent triangle (top) to 36 pt. + drag(&mut h, Pos2::new(rx(0.0), top + 3.0), Pos2::new(rx(36.0), top + 3.0)); + let r = ruler(&mut h); + assert!((r["indent"].as_f64().unwrap() - 36.0).abs() <= 1.0, "{r}"); + assert_eq!(r["marginLeft"].as_f64(), Some(0.0)); + + // Hanging triangle (middle) to 18 pt: the first line stays at 36. + drag(&mut h, Pos2::new(rx(0.0), top + 8.0), Pos2::new(rx(18.0), top + 8.0)); + let r = ruler(&mut h); + let (ml, ind) = (r["marginLeft"].as_f64().unwrap(), r["indent"].as_f64().unwrap()); + assert!((ml - 18.0).abs() <= 1.0 && (ml + ind - 36.0).abs() <= 1.0, "{r}"); + + // Click on the ruler: a left tab; drag it along; drag it off to remove it. + click(&mut h, Pos2::new(rx(150.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + let pos = r["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 150.0).abs() <= 1.0); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(220.0), top + 12.0)); + let pos = ruler(&mut h)["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 220.0).abs() <= 1.0, "{pos}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler.png"); + } + assert!(h.state().session.active().unwrap().selection.text.is_some(), "still editing the text"); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); + assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..a76ca062 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,12 +3,12 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 80%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 38 | 21 | 6 | 75% | +| P1 | 65 | 39 | 20 | 6 | 75% | | P2 | 54 | 13 | 15 | 26 | 38% | | Area | Parity | @@ -16,7 +16,7 @@ The summary between ` | Application shell | 77% | | Files | 71% | | Slides | 90% | -| Views | 90% | +| Views | 95% | | Masters & themes | 85% | | Shapes | 88% | | Format | 83% | @@ -86,7 +86,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Views | Notes Page | P1 | D | | Views | Reading View | P1 | D | | Views | Zoom, fit to window | P0 | D | -| Views | Rulers, gridlines, guides | P1 | P | +| Views | Rulers, gridlines, guides | P1 | D | | Views | Smart guides | P1 | D | | Views | Grayscale / black and white view | P2 | D | | Views | New window / arrange windows | P2 | M | From b89c9cafd7c4fc2c1082910a8c593653a38b8cb0 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:21:42 +0300 Subject: [PATCH 37/69] UI: preview slide animations on the editor canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animations › Preview and Animation Pane › Play All play the current slide on the canvas instead of opening the slide show. Escape or a click stops playback. Transitions › Preview still opens the show. Co-authored-by: Cursor --- README.md | 2 +- ROADMAP.md | 10 +- crates/ui-egui/src/canvas.rs | 26 ++-- crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/lib.rs | 222 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/panes.rs | 10 +- crates/ui-egui/src/preview.rs | 38 ++++++ crates/ui-egui/src/ribbon.rs | 11 +- crates/ui-egui/src/show.rs | 21 +--- crates/ui-egui/src/textures.rs | 25 +++- docs/animation-parity.md | 8 +- docs/architecture.md | 4 +- docs/control-protocol.md | 4 +- docs/gaps.md | 10 +- docs/parity-checklist.md | 10 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 11 +- docs/ui-parity.md | 5 +- 18 files changed, 356 insertions(+), 71 deletions(-) create mode 100644 crates/ui-egui/src/preview.rs diff --git a/README.md b/README.md index f25aef2d..a3e96a89 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --con ## Roadmap -DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core +DeckCraft is in **alpha**. It covers **80% of PowerPoint's features by breadth** (92% of the core ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated). See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next, [docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment, diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..a2bc98c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (animation preview on the canvas; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -14,7 +14,7 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av | | Value | Kind | |---|---|---| -| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | +| **Feature breadth** | **80%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | | Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands | | **Ready for real work** | **~55%** | estimated: weighted dimensions below | | Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) | @@ -45,7 +45,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b | Dimension | Ready | Hours to full | Doc | |---|---:|---|---| -| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) | +| Features (breadth 80% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) | | UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) | | File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) | | Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) | @@ -75,7 +75,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b | Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) | | Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) | | Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) | -| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) | +| Animations | 85% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) | | Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) | | Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) | | Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) | @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Breadth 79% → 80%, Animations 79% → 85% | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..f5e30035 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -80,7 +80,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let _ = pinch_pos; } else if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { + } else if wheel.y.abs() > 18.0 && !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { // Wheel over a fitted slide pages through slides (like PowerPoint). let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" }; let gate = ui.id().with("wheel_gate"); @@ -111,6 +111,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { }; crate::media::gif_repaint(ui.ctx(), &gifs); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); + let anim = app.anim_preview_draw(); let st = app.session.active(); // One texture pixel in screen points, and the canvas in pixels from the slide's corner. let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32); @@ -119,7 +120,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { Some((Target::Slides, d)) => { let idx = d.selection.slide; let doc = d.doc.clone(); - app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×) + let frame = anim.as_ref().map(|(tl, t)| (tl, *t)); + app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, frame) } Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible), Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible), @@ -163,11 +165,13 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ruler(&painter, avail, slide_rect, scale, &t); } pointer(app, ui, &resp, xf); - overlays(app, ui, &painter, xf, &t); - ime_output(app, ui, xf); - media_bar(app, ui, xf); - gif_buttons(app, ui, xf, &gifs); - context_menu(app, &resp); + if !app.anim_preview_active() { + overlays(app, ui, &painter, xf, &t); + ime_output(app, ui, xf); + media_bar(app, ui, xf); + gif_buttons(app, ui, xf, &gifs); + context_menu(app, &resp); + } } /// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's @@ -367,6 +371,14 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { + if app.anim_preview_active() { + // A click on the slide ends the preview, then selects as usual. + if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) { + app.stop_anim_preview(); + } else { + return; + } + } let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d912..a554f025 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -76,6 +76,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value { "home": app.backstage_open(), "backstage": app.backstage.as_ref().map(|b| b.page.id()), "showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})), + "animPreview": app.anim_preview_report().map(|(slide, time, duration)| json!({"slide": slide, "time": time, "duration": duration})), "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::>(), "status": app.status.as_ref().map(|s| s.0.clone()), "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, @@ -134,6 +135,10 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.key" => { let Some(k) = s("key") else { return err("missing `key`") }; + if app.anim_preview_active() && app.show.is_none() && matches!(k.to_ascii_lowercase().as_str(), "escape" | "esc") { + app.stop_anim_preview(); + return ok(json!({"preview": false})); + } if let Some(show) = app.show.as_ref() && !show.ended && let Some(key) = key_from(k) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..ec7985aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod icons; pub mod media; pub mod menus; pub mod panes; +mod preview; pub mod ribbon; pub mod selpane; pub mod show; @@ -154,6 +155,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("show.start", "Slide Show", Some("F5"), "{from?: index}"), ("show.presenter", "Presenter View", None, "{on?: bool}"), ("show.end", "End Show", None, "{}"), + ("animation.preview", "Preview Animations", None, "{on?: bool}"), ("app.home", "Home", None, "{on?}"), ("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"), ("app.closePresentation", "Close", Some("Cmd+W"), "{}"), @@ -177,6 +179,18 @@ pub struct Perf { pub fps: f64, } +/// Playhead for the in-canvas animation preview. +struct AnimPreview { + uid: u64, + slide: usize, + /// `Arc` address of the slide when preview started. An edit replaces the slide and stops playback. + slide_ptr: usize, + /// egui time when the first frame latched the clock (`None` until then). + started: Option, + elapsed: f64, + timeline: deckcraft_anim::Timeline, +} + pub struct SlideApp { pub session: Session, pub services: Services, @@ -185,6 +199,8 @@ pub struct SlideApp { pub control_rx: Option>, pub dialog: Option, pub show: Option, + /// Current slide's animations playing on the editor canvas (not the full-screen show). + anim_preview: Option, /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, pub status: Option<(String, f64)>, @@ -240,6 +256,7 @@ impl SlideApp { control_rx: None, dialog: None, show: None, + anim_preview: None, media: media::MediaHost::new(audio_out), status: None, toast: None, @@ -502,6 +519,13 @@ impl SlideApp { self.show = None; self.media.stop_owned(media::Owner::Show); } + "animation.preview" => { + if p.get("on").and_then(Value::as_bool) == Some(false) { + self.anim_preview = None; + return Ok(json!({"playing": false})); + } + return self.start_anim_preview(); + } "app.home" => { let on = self.flag(p, self.backstage_open()); if on { @@ -555,12 +579,91 @@ impl SlideApp { } pub fn start_show(&mut self, from: usize, reading: bool) { + self.anim_preview = None; self.media.stop_owned(media::Owner::Editor); if let Some(d) = self.session.active() { self.show = Some(show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading)); } } + /// The current slide's animations are playing on the editor canvas. + pub fn anim_preview_active(&self) -> bool { + self.anim_preview.is_some() + } + + /// `(slide, seconds played, sequence length)` while a preview is running. + pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { + self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) + } + + pub fn stop_anim_preview(&mut self) { + self.anim_preview = None; + } + + /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. + pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { + self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) + } + + /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence + /// (plus a short hold) has finished. + pub fn tick_anim_preview(&mut self, now: f64) -> bool { + let Some(p) = self.anim_preview.as_ref() else { return false }; + let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); + let alive = self.show.is_none() + && self.session.active().is_some_and(|d| { + d.uid == uid + && d.selection.target == deckcraft_engine::Target::Slides + && d.selection.slide == slide + && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) + }); + if !alive { + self.anim_preview = None; + return false; + } + let Some(p) = self.anim_preview.as_mut() else { return false }; + let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; + let start = *p.started.get_or_insert(now); + let elapsed = (now - start).max(0.0); + if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { + self.anim_preview = None; + return false; + } + p.elapsed = elapsed; + true + } + + fn start_anim_preview(&mut self) -> Result { + let (uid, slide, ptr, timeline) = { + let Some(d) = self.session.active() else { + return Err("no presentation is open".into()); + }; + if d.selection.target != deckcraft_engine::Target::Slides { + return Err("animation preview plays the current slide".into()); + } + let slide = d.selection.slide; + let Some(slide_arc) = d.doc.slides.get(slide) else { + return Err("no slide to preview".into()); + }; + let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; + let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); + (d.uid, slide, ptr, timeline) + }; + if timeline.is_empty() { + self.anim_preview = None; + self.set_status("This slide has no animations"); + return Ok(json!({"playing": false})); + } + if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { + self.ui.view = ViewMode::Normal; + } + self.close_backstage(); + let duration = timeline.total_duration(); + self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, elapsed: 0.0, timeline }); + self.set_status("Previewing animations"); + Ok(json!({"playing": true, "slide": slide, "duration": duration})) + } + fn pick_and_open(&mut self) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick("open") { @@ -909,6 +1012,10 @@ impl SlideApp { } } egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { + self.anim_preview = None; + continue; + } let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { continue; @@ -936,6 +1043,7 @@ impl SlideApp { let t = theme::Tokens::get(&ctx); self.tick_media(&ctx); if self.show.is_some() { + self.anim_preview = None; show::ui(self, ui); self.perf.frame_ms = now_ms() - t0; return; @@ -948,6 +1056,10 @@ impl SlideApp { ribbon::show(self, ui); status::status_bar(self, ui); } + let now = ui.input(|i| i.time); + if self.tick_anim_preview(now) { + ctx.request_repaint(); + } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { if self.backstage.is_some() { // File tab: the Backstage view replaces everything below the title bar. @@ -1408,4 +1520,114 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + fn redder(px: [u8; 4]) -> i32 { + px[0] as i32 - px[1].max(px[2]) as i32 + } + + /// A fade entrance is hidden at the start of the preview clock and opaque once it has played. + #[test] + fn preview_hides_an_entrance_until_it_plays() { + let mut session = Session::with_new(); + let text = session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 220.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("text.set", &json!({"id": text, "text": "Alpha\nBeta"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + session.execute("animation.options", &json!({"textBuild": "byParagraph"})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let shape = deckcraft_model::ShapeId(text as u32); + let para = |time: f64, i: usize| preview::shape_state_at(&tl, shape, time).paras.into_iter().find(|(p, _)| *p == i).map(|(_, s)| s).unwrap(); + assert!(!para(0.0, 0).visible && !para(0.0, 1).visible, "entrances have not started"); + let mid = para(0.25, 0); + assert!(mid.visible && mid.opacity > 0.05 && mid.opacity < 0.95, "{mid:?}"); + assert!(!para(0.25, 1).visible, "the next paragraph waits for its step"); + + session.execute("animation.clear", &json!({})).unwrap(); + let id = session.execute("shape.insert", &json!({"preset": "rect", "rect": [400.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("shape.fill", &json!({"id": id, "color": "#CC2200"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let (w, h) = (doc.slide_size.width.round().max(1.0) as u32, doc.slide_size.height.round().max(1.0) as u32); + let paint = |time: Option| { + let f = |sid: deckcraft_model::ShapeId| preview::shape_state_at(&tl, sid, time.unwrap_or(0.0)); + let state: Option<&dyn Fn(deckcraft_model::ShapeId) -> deckcraft_render::ShapeState> = if time.is_some() { Some(&f) } else { None }; + let opts = deckcraft_render::RenderOpts { scale: 1.0, size: Some((w, h)), state, ..Default::default() }; + deckcraft_render::render_slide(&doc, 0, &opts).pixel(460, 340) + }; + let rest = paint(None); + let start = paint(Some(0.0)); + let end = paint(Some(tl.total_duration() + 0.05)); + assert!(redder(rest) > 40, "the shape is filled at rest {rest:?}"); + assert!(redder(start) < redder(rest) / 2, "hidden at the first preview frame {start:?} vs {rest:?}"); + assert!(redder(end) > 40, "visible again once the sequence has played {end:?}"); + } + + #[test] + fn animation_preview_plays_on_the_canvas_then_stops() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let none = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(none["playing"], false); + assert!(harness.state().status.as_ref().is_some_and(|s| s.0.contains("no animations"))); + assert!(harness.state().show.is_none()); + + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 280.0, 180.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + harness.state_mut().session.execute("animation.set", &json!({"effect": "fade", "duration": 400})).unwrap(); + let started = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(started["playing"], true); + assert!(harness.state().show.is_none(), "preview stays on the editor canvas"); + harness.run_steps(1); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!(elapsed < 0.02, "the clock latches on the first frame, got {elapsed}"); + let renders = harness.state().textures.canvas_renders; + harness.run_steps(6); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!((0.2..0.5).contains(&elapsed), "{elapsed}"); + assert!(harness.state().textures.canvas_renders > renders, "each preview frame redraws the slide"); + assert!(harness.state().show.is_none()); + + harness.key_press(egui::Key::Escape); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "Escape stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + harness.state_mut().run("animation.preview", json!({"on": false})).unwrap(); + assert!(harness.state().anim_preview.is_none()); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + let centre = harness.state().slide_rect.unwrap().center(); + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(2); + assert!(harness.state().anim_preview.is_none(), "a click on the slide stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "leaving the slide stops the preview"); + + harness.state_mut().session.execute("slide.previous", &json!({})).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + // 400 ms effect plus the hold. + harness.run_steps(30); + assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence"); + assert!(harness.state().show.is_none()); + } } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..1be647f9 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -465,13 +465,9 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect(); let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); ui.horizontal(|ui| { - if ui.button("▶ Play All").clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - s.autoplay = true; - } + let playing = app.anim_preview_active(); + if ui.button(if playing { "■ Stop" } else { "▶ Play All" }).clicked() { + run(app, "animation.preview", json!({"on": !playing})); } }); ui.separator(); diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs new file mode 100644 index 00000000..432a004b --- /dev/null +++ b/crates/ui-egui/src/preview.rs @@ -0,0 +1,38 @@ +//! In-canvas animation preview: the current slide's effects played on the editor canvas +//! (Animations ▸ Preview, Animation Pane ▸ Play All). The slide show uses the same mapping. + +use deckcraft_anim::Timeline; +use deckcraft_geom::Vec2; +use deckcraft_model::ShapeId; +use deckcraft_render::{ParaState, ShapeState}; + +/// How long the finished sequence stays on the canvas before the editor draws the slide at rest. +pub const HOLD_SECS: f64 = 0.45; + +/// Shape and paragraph state when `step` main steps are done and the current step has played `t` seconds. +pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { + let a = tl.state(id, None, step, t); + ShapeState { + visible: a.visible, + opacity: a.opacity, + offset: Vec2::new(a.offset_x, a.offset_y), + scale: (a.scale_x, a.scale_y), + rotate: a.rotate, + clip: a.clip, + tint: a.tint, + paras: tl + .paragraph_targets(id) + .into_iter() + .map(|p| { + let a = tl.state(id, Some(p), step, t); + (p, ParaState { visible: a.visible, opacity: a.opacity, offset: Vec2::new(a.offset_x, a.offset_y) }) + }) + .collect(), + } +} + +/// State on the play-everything clock (preview and Play All). +pub fn shape_state_at(tl: &Timeline, id: ShapeId, time: f64) -> ShapeState { + let (step, t) = tl.locate(time); + shape_state(tl, id, step, t) +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..833742f4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1523,13 +1523,10 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { s.animations.iter().find(|a| a.shape == *id).cloned() }); group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - s.autoplay = true; - } + let playing = app.anim_preview_active(); + let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { + run(app, "animation.preview", json!({})); } }); group(ui, |ui| { diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..9a96b7a9 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -4,7 +4,7 @@ use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline}; use deckcraft_model::text::Hyperlink; use deckcraft_model::{Presentation, ShapeId}; -use deckcraft_render::{ParaState, RenderOpts, ShapeState}; +use deckcraft_render::{RenderOpts, ShapeState}; use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; use serde_json::json; @@ -180,24 +180,7 @@ impl Show { } fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { - let a = tl.state(id, None, step, t); - ShapeState { - visible: a.visible, - opacity: a.opacity, - offset: deckcraft_geom::Vec2::new(a.offset_x, a.offset_y), - scale: (a.scale_x, a.scale_y), - rotate: a.rotate, - clip: a.clip, - tint: a.tint, - paras: tl - .paragraph_targets(id) - .into_iter() - .map(|p| { - let a = tl.state(id, Some(p), step, t); - (p, ParaState { visible: a.visible, opacity: a.opacity, offset: deckcraft_geom::Vec2::new(a.offset_x, a.offset_y) }) - }) - .collect(), - } + crate::preview::shape_state(tl, id, step, t) } fn fit(avail: Rect, doc: &Presentation) -> Rect { diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..f1f121d9 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -79,6 +79,14 @@ fn same(a: &Option>, b: &Option>) -> bool { } } +/// Mix the preview playhead into the canvas cache key. Zero means "no preview", so a playhead of +/// 0s still misses the at-rest texture (entrances start hidden). +fn anim_key(anim: Option<(&deckcraft_anim::Timeline, f64)>) -> u64 { + let Some((_, t)) = anim else { return 0 }; + let ms = if t.is_finite() { (t * 1000.0).clamp(0.0, 1.0e12) as u64 } else { 0 }; + ms.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15) +} + fn threads() -> u16 { if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } } @@ -100,6 +108,7 @@ impl Textures { extra: u64, grayscale: bool, gif_times: &[(ShapeId, f64)], + anim: Option<(&deckcraft_anim::Timeline, f64)>, ) -> Option<(TextureHandle, Region)> { let slide = p.slides.get(index)?.clone(); let master = master_of(p, &slide); @@ -122,7 +131,7 @@ impl Textures { master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), size, edit, - extra: extra ^ grayscale as u64, + extra: extra ^ grayscale as u64 ^ anim_key(anim), want, }; if let Some(e) = &self.canvas @@ -132,8 +141,18 @@ impl Textures { } let t0 = crate::now_ms(); let render = |region: Region| { - let opts = - RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() }; + let state_of = |id: ShapeId| anim.map(|(tl, time)| crate::preview::shape_state_at(tl, id, time)).unwrap_or_default(); + let state: Option<&dyn Fn(ShapeId) -> deckcraft_render::ShapeState> = if anim.is_some() { Some(&state_of) } else { None }; + let opts = RenderOpts { + scale, + edit, + gif_times, + threads: threads(), + offset: region.offset(), + size: Some(region.size()), + state, + ..Default::default() + }; deckcraft_render::render_slide_drawn(p, index, &opts) }; let (mut img, drawn) = render(want); diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 8ec5d0f4..659d3736 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Animations ▸ Preview plays the current slide on the editor canvas; previous: major, transitions measured frame by frame) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -72,7 +72,8 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Animation Painter | yes | yes (#54) | | Animation Pane: list, reorder, remove | yes | yes | | Animation Pane: advanced timeline (bars you drag) | yes | **no** | -| Preview in the editor (current effect, from selected) | yes | partly | +| Preview on the editor canvas (the current slide) | yes | yes | +| Preview from the selected effect | yes | no | | Motion path editing (edit points, reverse, lock) | yes | partly (no edit points) | | Multiple animations on one object | yes | yes | | Round trip of the timing tree | yes | flattened to the pane list on read, rebuilt on write (`every_animation_effect_survives`) | @@ -81,7 +82,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Work | Hours | |---|---| -| Animation Pane timeline, preview from selected, effect dialog depth | 5–8 | +| Animation Pane timeline, preview from the selected effect, effect dialog depth | 4–7 | | The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 | | Rewind and other effect options on round trip (#77) | 1–2 | | Transition sounds | 1–2 | @@ -93,5 +94,6 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open | | 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..4c8dde04 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,8 @@ effects project cards through a perspective camera and `anim::layer_mesh` subdiv textures stay straight; soft edges are per-corner opacities. The show renders the slides once per transition and only composites cached textures each frame; `render::composite_transition` is the CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape -comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and +comes from `anim::timeline` each frame. Animations ▸ Preview plays that same state on the editor +canvas for the current slide, then draws the slide at rest. PDF export renders each slide at print resolution and overlays an invisible real-text layer with embedded font subsets. ## File I/O @@ -78,5 +79,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Animation preview draws the current slide's timeline on the editor canvas | | 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 | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 07741fde..fb5c2c1a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -15,10 +15,10 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | | `document.inspect` | — | Slides, sections, layouts, shapes, selection | -| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) | +| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | | `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | -| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | +| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show; Escape stops the canvas animation preview) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | | `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..f592b7f6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview plays on the editor canvas; previous: minor, 3-D bevel, camera and face lighting rendered) · **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 @@ -26,7 +26,7 @@ revision history. | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | | 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) | | 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) | -| 10 | Animation Pane: no timeline, thin preview; 64 of ~190 effects | Feature | B (pane) | 10–16 | [animation-parity.md](animation-parity.md) | +| 10 | Animation Pane: no timeline; preview is the whole slide, not from the selected effect; 64 of ~190 effects | Feature | B (pane) | 10–16 | [animation-parity.md](animation-parity.md) | | 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) | | 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) | | 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -157,7 +157,10 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 10–16 h. ### Animation Pane, effect count and preview -Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. +Animations ▸ Preview and the pane's Play All play the current slide's animations on the editor +canvas (Escape or a click stops them). Still open: the advanced timeline, starting from the +selected effect, and most of the effect gallery. Detail in +[animation-parity.md](animation-parity.md). 10–16 h; the pane's timeline is a **beta blocker**. ### Notes and Handout masters - **Evidence:** checklist row missing; PPTX writes a notes master but there's no view to edit it, @@ -256,6 +259,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..92afd204 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,14 +1,14 @@ # PowerPoint feature checklist -> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 80%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 38 | 21 | 6 | 75% | +| P1 | 65 | 39 | 20 | 6 | 75% | | P2 | 54 | 13 | 15 | 26 | 38% | | Area | Parity | @@ -28,7 +28,7 @@ The summary between ` | Pictures | 48% | | Media | 67% | | Transitions | 92% | -| Animations | 79% | +| Animations | 85% | | Slide show | 66% | | Review | 60% | | Draw | 33% | @@ -208,7 +208,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Animations | By-paragraph text builds | P1 | D | | Animations | Triggers | P2 | D | | Animations | Animation Painter | P2 | D | -| Animations | Preview in editor | P1 | P | +| Animations | Preview in editor | P1 | D | | Slide show | Full-screen show from start / current | P0 | D | | Slide show | Navigation (keys, click, go to slide, blank screen) | P0 | D | | Slide show | Presenter view (notes, next slide, timer) | P0 | P | diff --git a/docs/roadmap.md b/docs/roadmap.md index 97953034..c3a4efc9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (canvas animation preview; beta item 9 keeps the timeline; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -46,7 +46,7 @@ Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` | 6 | Localization infrastructure + zh-Hans, es, fr, de, ja (with IME) | 27–46 | Localization 8 → ~40% | | 7 | Font weights (#69), image paste (#53), Tab in text (#78) | 6–11 | Features | | 8 | Charts: data grid, elements | 6–10 | Features | -| 9 | Animation Pane timeline, preview, effect options | 5–8 | Features | +| 9 | Animation Pane timeline, preview from the selected effect, effect options | 4–7 | Features | | 10 | Presenter view on the right display, timer controls | 3–5 | Hardware, Slide show | | 11 | Native macOS menu bar, Format Shape pane depth, start screen and window chrome fixes | 13–22 | UI/UX 60 → 75% | | 12 | Performance baseline and the obvious fixes | 4–8 | Performance | @@ -109,5 +109,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Canvas animation preview landed; beta item 9 keeps the timeline and preview from the selected effect | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..c2961170 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (Preview in editor re-scored done; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -11,7 +11,7 @@ number. | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 | +| **Feature breadth** (weighted checklist) | **80%** over 191 features (P0 92%, P1 75%, P2 36%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md); Preview in editor re-scored 2026-10-13 | | Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists | | **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) | | **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) | @@ -64,7 +64,7 @@ The weights are what those users actually depend on, written down so the total c | Dimension | Weight | Ready | Hours to full | Doc | Evidence | |---|---:|---:|---|---|---| -| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 79% measured. Depth lags: 37 of 191 rows are partial, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) | +| Features (breadth × depth) | 30% | ~62% | 140–240 | [parity-checklist.md](parity-checklist.md), [animation-parity.md](animation-parity.md), [slideshow-parity.md](slideshow-parity.md) | Breadth 80% measured. Depth lags: partial rows remain, and users found depth bugs in rows scored done within two days (#53, #60, #69, #70, #74, #77, #78) | | File formats | 20% | ~58% | 60–110 | [file-format-parity.md](file-format-parity.md) | PPTX reads and writes every main part and keeps unknown objects; files open in PowerPoint without repair. But it has never met a corpus of real decks, EMF/WMF/SVG pictures don't decode, comments/media/rewind settings are lost on round trip, and there is no `.ppt`, `.pptm`, `.odp`, video or GIF export | | UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 | | Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) | @@ -194,14 +194,14 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Pictures | 7 | 48% | ~45% | 12–20 | SVG/EMF/WMF, clipboard paste on Windows/Linux (#53), artistic effects, remove background, compress | [file-format-parity.md](file-format-parity.md) | | Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, icons and 3-D models, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) | | Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) | -| Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | +| Animations | 6 | 85% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview from the selected effect, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | | Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | | Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | | Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | | Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) | | Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) | | Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) | -| **Total (feature areas)** | 110 | **79%** | **~62%** | **~140–240** | | | +| **Total (feature areas)** | 110 | **80%** | **~62%** | **~140–240** | | | ## What works today (evidence) @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Preview in editor scored done: breadth 79% → 80%, Animations area 79% → 85% | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..31869d5d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview on the editor canvas; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -61,7 +61,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | -| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | +| Animation Pane | list + advanced timeline | list, reorder, timing, play the current slide on the canvas; no timeline | | Comments pane (threads) | yes | yes | | 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…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, 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 | @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas | | 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 | From 4b2548c99b2ff73830727430e632a80f494e920e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:22:16 +0300 Subject: [PATCH 38/69] Text: Tabs dialog and the ruler's tab selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format > Tabs opens from Home > Line Spacing > Tabs…, from the Paragraph dialog, or by double-clicking a tab stop on the ruler. It lists the selected paragraphs' tab stops (inches) with their alignment. Set adds or replaces a stop at a position with Left, Center, Right or Decimal alignment, Clear removes one, Clear All removes them all, and you can change the default tab spacing. OK applies everything in one undo step through format.tabs, which now takes `set`. Cancel changes nothing. The ruler's corner has PowerPoint's tab selector. A click cycles left, center, right and decimal, and a click on the ruler then sets that kind of stop. Presses on the rulers no longer reach the slide, so they don't end text editing. Co-authored-by: Cursor --- crates/engine/src/cmd/format.rs | 12 +++- crates/engine/src/cmd/ruler_tests.rs | 3 + crates/ui-egui/src/canvas.rs | 77 +++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 92 ++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/src/ruler_tests.rs | 75 +++++++++++++++++++++++ docs/parity-checklist.md | 6 +- 7 files changed, 243 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 8581cb5d..0251e6ab 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -145,7 +145,7 @@ pub fn specs() -> Vec { "Tabs…", ["Home", "Paragraph"], None, - "{add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", + "{set?: [{pos, align?}] (replaces all), add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", has_text_or_shapes, tabs ), @@ -738,16 +738,22 @@ fn tabs(s: &mut Session, p: &Value) -> Result { let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); let remove = fin(p.get("remove").and_then(Value::as_f64)); + let set: Option> = p.get("set").and_then(Value::as_array).map(|a| { + a.iter() + .take(MAX_TABS * 4) + .filter_map(|t| Some(deckcraft_model::text::TabStop { pos: fin(t.get("pos").and_then(Value::as_f64))?, align: align_of(t).to_string() })) + .collect() + }); let clear = bool_or(p, "clear", false); let default_tab = fin(p.get("defaultTab").and_then(Value::as_f64)).filter(|v| *v >= 1.0); - if add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { + if set.is_none() && add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { return Err(bad("format.tabs", "nothing to change")); } // The stops as the caret's paragraph shows them (inherited ones included), so editing a // placeholder's tab that came from its layout works like editing one set on the paragraph. let base = current_para(s).tabs.unwrap_or_default(); let near = |a: f64, b: f64| (a - b).abs() < 0.5; - let mut stops = if clear { vec![] } else { base }; + let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; if let Some(at) = remove { stops.retain(|t| !near(t.pos, at)); } diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs index 1d23154e..95c87c6c 100644 --- a/crates/engine/src/cmd/ruler_tests.rs +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -56,4 +56,7 @@ fn tabs_add_move_remove_clear_and_undo() { s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); } assert!(tabs(&mut s).len() <= 32); + s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap(); + assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped"); + assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0)); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 073f9f41..f29f62f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -467,25 +467,29 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect if gone { continue; } - let (x, y) = (sx(q), top.max.y - 2.0); - let st = Stroke::new(1.5, t.text); - p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); - match align.as_str() { - "r" => { - p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); - } - "ctr" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - } - "dec" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, t.text); - } - _ => { - p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); - } - } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); if drag.is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } @@ -511,7 +515,37 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect && at.x >= area.min.x && at.x <= area.max.x { - let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": "l"}})); + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } } } @@ -519,6 +553,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } let p = xf.to_slide(pos); let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; let primary = ui.input(|i| i.pointer.primary_pressed()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..dd4089c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", "about" => "About DeckCraft", "preferences" => "Preferences", "export" => "Export", @@ -388,6 +389,12 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { field(ui, d, "Spacing before (pt):", "before", "0"); field(ui, d, "Spacing after (pt):", "after", "0"); field(ui, d, "Line spacing (lines):", "lines", "1.0"); + ui.add_space(4.0); + if ui.button("Tabs…").clicked() { + app.dialog = None; + *d = Dialog::new("tabs"); + return false; + } let (ok, cancel) = buttons(ui, "OK"); if ok { run( @@ -398,6 +405,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -829,3 +837,87 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; + +/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a +/// list (inches, as PowerPoint's dialog) and applied together with OK. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("stops") { + let r = app.session.execute("format.ruler", &json!({})).unwrap_or_default(); + let stops: Vec = r["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) + .unwrap_or_default(); + d.fields.insert("stops".into(), stops.join("|")); + d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); + } + let mut stops: Vec<(f64, String)> = d + .get("stops", "") + .split('|') + .filter_map(|x| { + let (p, a) = x.split_once(':')?; + Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) + }) + .collect(); + field(ui, d, "Default tab stops (in):", "default", "1"); + ui.add_space(6.0); + ui.label("Tab stops:"); + let mut remove = None; + egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { + if stops.is_empty() { + ui.label("(none)"); + ui.end_row(); + } + for (i, (pos, align)) in stops.iter().enumerate() { + ui.label(format!("{pos:.2}\"")); + ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); + if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + stops.remove(i); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Tab stop position (in):"); + let mut v = d.get("newpos", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)); + d.fields.insert("newpos".into(), v); + }); + let cur = d.get("newalign", "l"); + ui.horizontal(|ui| { + ui.label("Alignment:"); + for (k, label) in TAB_KINDS { + if ui.radio(cur == k, label).clicked() { + d.fields.insert("newalign".into(), k.into()); + } + } + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) + { + stops.retain(|(q, _)| (q - p).abs() > 0.005); + stops.push((p, d.get("newalign", "l"))); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.fields.insert("newpos".into(), String::new()); + } + if ui.button("Clear All").clicked() { + stops.clear(); + } + }); + d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); + let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; + if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { + app.set_status(e); + } + } + ok || cancel +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..3df4a781 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -594,6 +594,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { if ui.button("Line Spacing Options…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); egui::Popup::menu(&r).show(|ui| { diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs index fd12d206..7c313563 100644 --- a/crates/ui-egui/src/ruler_tests.rs +++ b/crates/ui-egui/src/ruler_tests.rs @@ -83,3 +83,78 @@ fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); } + +fn editing_harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Item\t9.50"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + let corner = h.state().canvas_rect.unwrap().min + egui::vec2(8.0, 8.0); + assert!(h.query_by_label("Left Tab").is_some()); + for _ in 0..3 { + click(&mut h, corner); + } + assert!(h.query_by_label("Decimal Tab").is_some()); + click(&mut h, Pos2::new(rx(200.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"][0]["align"], "dec", "{r}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler_decimal.png"); + } +} + +#[test] +fn tabs_dialog_sets_clears_and_applies_on_ok() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("tabs")); + h.run_steps(3); + assert!(h.query_by_label_contains("0.50\"").is_some(), "the existing stop is listed"); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("newpos".into(), "2".into()); + d.fields.insert("default".into(), "0.5".into()); + h.run_steps(1); + h.get_by_label("Center").click(); + h.run_steps(2); + h.get_by_label("Set").click(); + h.run_steps(2); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/tabs_dialog.png"); + } + // Nothing changes until OK. + assert_eq!(ruler(&mut h)["tabs"].as_array().unwrap().len(), 1); + h.query_all_by_label("Clear").next().expect("a Clear button per stop").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!((r["tabs"][0]["pos"].as_f64(), r["tabs"][0]["align"].as_str()), (Some(144.0), Some("ctr"))); + assert_eq!(r["defaultTab"].as_f64(), Some(36.0)); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ruler(&mut h)["tabs"][0]["pos"].as_f64(), Some(36.0), "one undo step"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a76ca062..50e56824 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 14 | 14 | 26 | 39% | | Area | Parity | |---|---| @@ -21,7 +21,7 @@ The summary between ` | Shapes | 88% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 81% | | Tables | 89% | | Charts | 65% | | SmartArt | 33% | @@ -148,7 +148,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Bullets and numbering | P0 | D | | Text | Picture bullets, custom bullet symbols | P2 | P | | Text | List levels (Tab / Shift+Tab) | P0 | D | -| Text | Tab stops | P2 | P | +| Text | Tab stops | P2 | D | | Text | Columns | P2 | D | | Text | Vertical anchor, margins, wrap | P0 | D | | Text | Autofit (shrink on overflow, resize shape) | P0 | D | From 1a0ffc6413c61594ab2d57e3e745bedfcbf28e7b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:56:23 +0300 Subject: [PATCH 39/69] Draw: pen pressure scales ink width A pen reading of 1 is the full pen width. A lighter touch draws a thinner stroke, and a zero reading stays visible at 8% of the pen. A mouse, a missing or non-finite reading, and a stroke whose samples are all the old placeholder 0.5 still draw at the pen width. PPTX keeps one width per stroke: the mean of the samples for a pressured stroke, the pen width for a legacy one. Tilt is not read. Co-authored-by: Cursor --- ROADMAP.md | 4 ++- crates/engine/src/cmd/selection_tests.rs | 2 +- crates/engine/src/tests.rs | 41 +++++++++++++++++----- crates/engine/src/tools.rs | 23 +++++++++++-- crates/mcp/src/headless.rs | 3 +- crates/model/src/lib.rs | 21 +++++++++++- crates/model/src/tests.rs | 13 +++++++ crates/pptx/src/write/shapes.rs | 11 +++++- crates/pptx/tests/roundtrip.rs | 35 +++++++++++++++++-- crates/render/src/lib.rs | 29 +++++++++------- crates/render/src/tests.rs | 43 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 29 ++++++++++++++-- crates/ui-egui/src/control.rs | 7 ++-- docs/control-protocol.md | 2 +- docs/gaps.md | 5 +-- docs/hardware-parity.md | 9 ++--- docs/roadmap.md | 10 +++--- docs/target-app-parity.md | 7 ++-- 18 files changed, 244 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..49390d16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: pen pressure scales ink width; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: pen pressure scales ink width | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/selection_tests.rs b/crates/engine/src/cmd/selection_tests.rs index 11d99b7c..69bcc3c8 100644 --- a/crates/engine/src/cmd/selection_tests.rs +++ b/crates/engine/src/cmd/selection_tests.rs @@ -201,7 +201,7 @@ fn locked_objects_are_picked_on_the_canvas_but_not_dragged() { s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); s.execute("edit.select", &json!({"ids": []})).unwrap(); let n = s.doc().unwrap().history.undo.len(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // A: x 20..120, y 50..130. s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap(); assert_eq!(selected(&s), vec![a], "a click selects a locked object"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..09a0f0d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -126,7 +126,7 @@ fn align_and_distribute() { fn pointer_draw_move_resize() { let mut s = session(); s.set_tool(ToolKind::Shape { preset: "rect".into() }); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 100.0, 100.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 200.0, 180.0)).unwrap(); let r = s.pointer(ev(PointerKind::Up, 200.0, 180.0)).unwrap(); @@ -157,7 +157,7 @@ fn pointer_draw_move_resize() { #[test] fn click_into_placeholder_starts_editing() { let mut s = session(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 480.0, 200.0)).unwrap(); s.pointer(ev(PointerKind::Up, 480.0, 200.0)).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); @@ -290,7 +290,7 @@ fn drawing_a_line_between_shapes_glues_it() { let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.deselect", &json!({})).unwrap(); s.tool.kind = tools::ToolKind::Shape { preset: "straightConnector1".into() }; - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None }; // Right site of the ellipse (200,150) → left site of the rect (400,150), a little off. s.pointer(ev(tools::PointerKind::Down, 202.0, 151.0)).unwrap(); s.pointer(ev(tools::PointerKind::Drag, 300.0, 150.0)).unwrap(); @@ -323,7 +323,7 @@ fn freeform_tools_and_command() { s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().shapes().len(), before + 1); - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None }; // Polygon: click, click, click, click on the start → closed, filled. s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] { @@ -376,7 +376,7 @@ fn double_click_in_table_uses_grown_row_heights() { for row in 0..3 { s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap(); } - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0))); } @@ -580,7 +580,7 @@ fn shapes_off_the_slide_can_be_selected_and_moved() { // Inserting off the slide keeps the box where it was asked for. assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0); s.execute("edit.select", &json!({"ids": []})).unwrap(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // Hover and click wholly off the slide. assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off)); s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); @@ -698,9 +698,9 @@ fn ime_composition_is_kept_by_a_key_a_click_or_another_command() { assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); s.compose_text("녕", None).unwrap(); - s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing"); - s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_none(), "a click settles it"); assert_eq!(title_text(&s), "안녕"); @@ -835,3 +835,28 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() { + let mut s = session(); + s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 }); + let ev = |kind, x, y, pressure| tools::PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure }; + s.pointer(ev(PointerKind::Down, 40.0, 80.0, Some(0.2))).unwrap(); + s.pointer(ev(PointerKind::Drag, 80.0, 80.0, Some(1.0))).unwrap(); + s.pointer(ev(PointerKind::Drag, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Up, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Down, 40.0, 160.0, None)).unwrap(); + s.pointer(ev(PointerKind::Drag, 100.0, 160.0, Some(f32::NAN))).unwrap(); + s.pointer(ev(PointerKind::Up, 100.0, 160.0, None)).unwrap(); + let got = ink_pressures(&s); + assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]); +} + +fn ink_pressures(s: &Session) -> Vec> { + let st = s.doc().unwrap(); + let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else { + return vec![]; + }; + let deckcraft_model::ShapeKind::Ink { strokes } = &sh.kind else { return vec![] }; + strokes.iter().map(|stroke| stroke.points.iter().map(|p| p.2).collect()).collect() +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..915eb440 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -40,6 +40,10 @@ pub struct PointerEvent { pub mods: Mods, /// Hit tolerance in points (≈ 4 screen px at the current zoom). pub tol: f64, + /// Pen pressure `0..=1` when the device reported one. `None` is a mouse: the stroke + /// is stored at full pen width. + #[serde(default)] + pub pressure: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -178,6 +182,8 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// Pressure from the pointer event being handled, if the device reported one. + pressure: Option, } impl ToolState { @@ -267,6 +273,7 @@ impl Session { pub fn pointer(&mut self, ev: PointerEvent) -> Result { let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 }; let p = Point::new(ev.x, ev.y); + self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); if !(p.x.is_finite() && p.y.is_finite()) { return Ok(Value::Null); } @@ -355,8 +362,9 @@ impl Session { if mode == "eraser" { return self.erase_at(p, tol); } - self.tool.drag = Some(Drag::Ink { points: vec![(p.x, p.y, 0.5)] }); - self.tool.ink_preview = vec![(p.x, p.y, 0.5)]; + let sample = self.ink_sample(p); + self.tool.drag = Some(Drag::Ink { points: vec![sample] }); + self.tool.ink_preview = vec![sample]; return Ok(Value::Null); } ToolKind::Select => {} @@ -653,7 +661,7 @@ impl Session { self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); } Drag::Ink { mut points } => { - points.push((p.x, p.y, 0.5)); + points.push(self.ink_sample(p)); self.tool.ink_preview = points.clone(); self.tool.drag = Some(Drag::Ink { points }); } @@ -1040,6 +1048,15 @@ impl Session { nx } + /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. + fn ink_sample(&self, p: Point) -> (f64, f64, f32) { + let pressure = match self.tool.pressure { + Some(v) if v.is_finite() => v.clamp(0.0, 1.0), + _ => 1.0, + }; + (p.x, p.y, pressure) + } + fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 512e7cc4..2f126af0 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -88,7 +88,8 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?; let mods = e.get("mods").map(mods_from).unwrap_or(base); - last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0 }).map_err(|e| e.to_string())?; + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0, pressure }).map_err(|e| e.to_string())?; } self.session.ui_requests.clear(); Ok(json!({"result": last, "selection": self.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())})) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..a4f7a098 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -249,7 +249,8 @@ pub struct MediaClip { pub height: u32, } -/// One ink stroke (Draw tab): points in slide coordinates with pressure. +/// One ink stroke (Draw tab): points in slide coordinates with pressure (`0..=1`, +/// where `1` is [`InkStroke::width`]). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct InkStroke { @@ -259,6 +260,24 @@ pub struct InkStroke { pub highlighter: bool, } +/// True when every sample is the placeholder `0.5` written before a device reported pressure. +/// Those strokes were drawn at [`InkStroke::width`] and still are. +pub fn ink_pressure_is_legacy(points: &[(f64, f64, f32)]) -> bool { + !points.is_empty() && points.iter().all(|p| (p.2 - 0.5).abs() <= 1.0e-3) +} + +/// Width of one ink sample. `pressure` of `1` is the pen width. A legacy flat stroke +/// (`legacy`) draws at the pen width. A missing reading draws at the pen width too. +/// A light touch stays visible (at least 8% of the pen). +pub fn ink_sample_width(pen: f64, pressure: f32, legacy: bool) -> f64 { + let pen = if pen.is_finite() { pen.abs() } else { 0.0 }; + if legacy { + return pen; + } + let p = if pressure.is_finite() { pressure.clamp(0.08, 1.0) } else { 1.0 }; + pen * f64::from(p) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] pub enum ShapeKind { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 2c3b4ced..4b8589d0 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -231,3 +231,16 @@ fn sibling_lists_and_parents_reach_into_groups() { assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); } + +#[test] +fn ink_width_follows_pressure_and_keeps_the_old_placeholder() { + assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)])); + assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)])); + assert!(!ink_pressure_is_legacy(&[])); + assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9); + let floor = ink_sample_width(8.0, 0.0, false); + assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}"); + assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9); +} diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..836f9f5c 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -408,6 +408,15 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { return None; } let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0)); + // PPTX keeps one line width. A pressured stroke exports at its mean sample width; + // a legacy flat `0.5` stroke stays at the pen width. + let legacy = deckcraft_model::ink_pressure_is_legacy(&st.points); + let width = if legacy { + st.width + } else { + let n = st.points.len().max(1) as f64; + st.points.iter().map(|p| deckcraft_model::ink_sample_width(st.width, p.2, false)).sum::() / n + }; let mut d = String::new(); for (k, (px, py)) in pts.iter().enumerate() { d.push_str(if k == 0 { "M " } else { "L " }); @@ -426,7 +435,7 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { line: Some(deckcraft_model::Line { cap: Some(deckcraft_model::style::LineCap::Round), join: Some(deckcraft_model::style::LineJoin::Round), - ..deckcraft_model::Line::solid(c, st.width.max(0.25)) + ..deckcraft_model::Line::solid(c, width.max(0.25)) }), ..Default::default() }) diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..3b3cd5de 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -487,9 +487,40 @@ fn pictures_media_and_ink_round_trip() { assert_eq!((m.trim_start_ms, m.fade_in_ms), (500, 250)); assert!(q.media(m.media).is_some_and(|x| x.data.starts_with(b"\x00\x00\x00\x18ftyp"))); assert!(m.poster.is_some()); - // Ink comes back as freeform lines in a group. + // Ink comes back as freeform lines in a group. A flat 0.5 stroke keeps the pen width. let ink = by_name(&q.slides[2], "Ink"); - assert!(matches!(&ink.kind, ShapeKind::Group { children, .. } if children.len() == 1 && matches!(children[0].geom, Geom::Custom { .. }))); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("ink is not a group: {:?}", ink.kind) }; + assert_eq!(children.len(), 1); + assert!(matches!(children[0].geom, Geom::Custom { .. })); + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + assert!((w - 3.0).abs() < 0.05, "a flat 0.5 stroke keeps the pen width, got {w}"); +} + +#[test] +fn pressured_ink_exports_at_its_mean_width() { + let mut p = Presentation::default(); + let id = ShapeId(p.alloc_id()); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + slide.shapes.push(Shape { + id, + name: "Ink".into(), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![InkStroke { + points: vec![(10.0, 10.0, 1.0), (40.0, 10.0, 0.5), (80.0, 10.0, 0.0)], + color: Rgba::rgb(0, 0, 0), + width: 10.0, + highlighter: false, + }], + }, + ..Default::default() + }); + let q = round(&p); + let ink = by_name(&q.slides[0], "Ink"); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("{:?}", ink.kind) }; + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + // Full, half, and the 8% floor: (10 + 5 + 0.8) / 3. + assert!((w - (10.0 + 5.0 + 0.8) / 3.0).abs() < 0.05, "{w}"); } #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..5f243e9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -844,24 +844,29 @@ impl Renderer { } ShapeKind::Ink { strokes } => { for sk in strokes { - let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; - for (px, py, _) in &sk.points { + let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); + let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; + for (px, py, pr) in &sk.points { + let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); } - let mut bp = BezPath::new(); - for (i, (px, py, _)) in sk.points.iter().enumerate() { - if i == 0 { - bp.move_to((*px, *py)); - } else { - bp.line_to((*px, *py)); - } + let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); + if pts.len() < 2 { + continue; } let (base, rel) = self.line_space(parent, depth); - bp.apply_affine(rel); ctx.set_transform(base); ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); - ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); + for pair in pts.windows(2) { + let mut bp = BezPath::new(); + bp.move_to((pair[0].0, pair[0].1)); + bp.line_to((pair[1].0, pair[1].1)); + bp.apply_affine(rel); + let a = deckcraft_model::ink_sample_width(pen, pair[0].2, legacy); + let b = deckcraft_model::ink_sample_width(pen, pair[1].2, legacy); + ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + } } } _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..175b9a54 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -35,6 +35,49 @@ fn shape_fill_uses_theme_accent() { assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); } +#[test] +fn ink_pressure_tapers_the_stroke_and_a_flat_half_stays_full_width() { + let opts = RenderOpts { scale: 1.0, ..Default::default() }; + let tapered = render_slide(&ink_line(1.0, 0.12), 0, &opts); + let flat = render_slide(&ink_line(0.5, 0.5), 0, &opts); + let full = render_slide(&ink_line(1.0, 1.0), 0, &opts); + let heavy = dark_in(&tapered, 50, 140, 70, 130); + let light = dark_in(&tapered, 280, 360, 70, 130); + assert!(heavy > light * 3, "a firm point covers more of the slide than a light one ({heavy} vs {light})"); + let (a, b) = (dark_in(&flat, 40, 370, 70, 130), dark_in(&full, 40, 370, 70, 130)); + assert!(a > 100 && (a as i32 - b as i32).unsigned_abs() * 10 < a as u32, "the old constant 0.5 still draws at the pen width ({a} vs {b})"); +} + +fn ink_line(left: f32, right: f32) -> Presentation { + let points = (0..=40) + .map(|i| { + let pressure = if i < 20 { left } else { right }; + (40.0 + f64::from(i) * 8.0, 100.0, pressure) + }) + .collect(); + let sh = Shape { + id: ShapeId(7), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![deckcraft_model::InkStroke { points, color: deckcraft_color::Rgba::rgb(0, 0, 0), width: 24.0, highlighter: false }], + }, + ..Default::default() + }; + deck_with(vec![sh]) +} + +fn dark_in(img: &Image, x0: u32, x1: u32, y0: u32, y1: u32) -> usize { + let mut n = 0; + for y in y0..y1 { + for x in x0..x1 { + if img.pixel(x, y)[0] < 200 { + n += 1; + } + } + } + n +} + #[test] fn text_draws_ink() { let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..2041acda 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,7 +371,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; let p = xf.to_slide(pos); - let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; + let pressure = reported_pressure(ui); + let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; let primary = ui.input(|i| i.pointer.primary_pressed()); if resp.hovered() && !app.session.tool.dragging() { let _ = app.session.pointer(ev(PointerKind::Move)); @@ -581,13 +582,35 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To if app.session.tool.ink_preview.len() > 1 && let ToolKind::Ink { color, width, mode } = &app.session.tool.kind { - let pts: Vec = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + let pts = &app.session.tool.ink_preview; + let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); let c = theme::to_color32(*color); let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; - painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + for pair in pts.windows(2) { + let a = deckcraft_engine::model::ink_sample_width(*width, pair[0].2, legacy); + let b = deckcraft_engine::model::ink_sample_width(*width, pair[1].2, legacy); + let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; + let p0 = xf.to_screen(Point::new(pair[0].0, pair[0].1)); + let p1 = xf.to_screen(Point::new(pair[1].0, pair[1].1)); + painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); + } } } +/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. +fn reported_pressure(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), phase, .. } + if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => + { + Some(f.clamp(0.0, 1.0)) + } + _ => None, + }) + }) +} + fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { p.circle_filled(c, 6.0, t.handle_fill); p.add(egui::Shape::Path(egui::epaint::PathShape { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d912..2672c37d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -3,8 +3,8 @@ //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): //! - `engine.execute {command, params}` / `ui.menu.invoke {command, params}`: run any command (engine or UI) //! - `engine.commands`: engine + UI commands with enablement; `document.inspect`; `ui.inspect` -//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y}], mods}`: drive the active tool in -//! slide points (same path as the mouse) +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, pressure?}], mods}`: drive the active tool in +//! slide points (same path as the mouse; `pressure` is `0..=1` when a pen reported one) //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: keyboard through the engine //! - `ui.tool.select {tool, preset?}` //! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}` / `ui.drag {x, y, toX, toY, steps?}`: real egui pointer @@ -125,7 +125,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> return err("pointer events need x and y"); }; let mods = e.get("mods").map(mods_from).unwrap_or(base); - match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64 }) { + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64, pressure }) { Ok(v) => last = v, Err(e) => return err(e), } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 07741fde..7db87ec9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `document.inspect` | — | Slides, sections, layouts, shapes, selection | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | -| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?, pressure?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse. `pressure` is `0..=1` for a pen; omit it for a mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..ccbe7e95 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt, pinch and trackpad gestures remain; 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) 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 @@ -217,7 +217,7 @@ Detail in [hardware-parity.md](hardware-parity.md). or wgpu path). - **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy FilmCraft's hardware decode). -- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h. +- **Pen pressure scales ink width** when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat `0.5` still draws at the pen width). Tilt is not read. No pinch-zoom or other trackpad gestures. 2–4 h. ## Localization @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..2ebbfa20 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt remains; previous: minor, IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -44,7 +44,7 @@ because it mostly follows from hardware use. | Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes | | Mouse and trackpad scroll | yes | yes | | Pinch to zoom, two-finger gestures | yes | no | -| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | +| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | pressure scales the stroke when the pointer reports it (a mouse or a missing reading draws at the pen width); tilt is not read | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | | Dictation | yes (macOS) | no | @@ -63,13 +63,14 @@ outlines every frame (#67, fix open), and the decoders run in software. | GPU slide rasterization (vello's GPU/hybrid path) | 12–20 | | Hardware video decode (copy FilmCraft's VideoToolbox / Media Foundation / VA-API paths) | 8–14 | | Windows GPU startup robustness: backend probing, software fallback (#40, #59) | 4–8 (counted under Stability) | -| Pen pressure, pinch zoom | 3–6 | +| Pen tilt, pinch zoom, trackpad gestures | 2–5 | | Benchmark suite (`cargo xtask bench`) and a first performance pass | 6–12 (Performance dimension) | -| **Hardware total** | **~26–45** | +| **Hardware total** | **~25–44** | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 97953034..b8f3e986 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (after beta: pen pressure scales ink width, tilt remains; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -60,9 +60,10 @@ reference renders, a Windows tester with the affected GPUs, native speakers to r SmartArt editing, chartex chart types, the remaining animation effects and motion paths, Notes and Handout masters, edit points, text warp and 3-D, proofing dictionaries, Zoom, recording, video and -GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization and hardware video -decode, the other 21 languages with RTL mirroring, a larger original template gallery, and local AI -features (owner decision on models). Itemized with hours in [gaps.md](gaps.md). +GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization, hardware video +decode, pen tilt (pressure already scales ink width) and pinch zoom, the other 21 languages with +RTL mirroring, a larger original template gallery, and local AI features (owner decision on +models). Itemized with hours in [gaps.md](gaps.md). ## Milestones @@ -109,5 +110,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..3fa6283e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (pen pressure scales ink; percentages unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -69,7 +69,7 @@ The weights are what those users actually depend on, written down so the total c | UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 | | Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) | | Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) | -| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom | +| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen tilt, no pinch zoom | | Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps | | Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist | | Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes vs PowerPoint's large template gallery; no add-ins or VBA (out of scope by policy) | @@ -197,7 +197,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | | Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | | Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | -| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | +| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen tilt, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | | Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) | | Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) | | Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Draw and hardware blurbs: pen pressure scales ink width when reported; percentages and hour ranges 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 | From 68ab3a4839e01e77caf96a39a7f8e21fd24f6f95 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:41:28 +0300 Subject: [PATCH 40/69] UI: Font dialog sets character formatting in one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format.fontDialog applies family, size, bold, italic, underline, colour, strikethrough, baseline, caps and spacing together, so one undo restores them. With no fields it asks the UI to open the dialog, seeded from the selection. Home › Font, the shape menu and Cmd+T open it. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/engine/src/cmd/format.rs | 337 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/dialogs.rs | 212 ++++++++++++++- crates/ui-egui/src/font_dialog_tests.rs | 65 +++++ crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/ribbon.rs | 3 + docs/architecture.md | 7 +- docs/gaps.md | 8 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 11 +- 11 files changed, 642 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/font_dialog_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..15729ba9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Font dialog; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Font dialog | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..f1c3921d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -69,6 +69,15 @@ pub fn specs() -> Vec { spacing ), cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear), + cmd!( + "format.fontDialog", + "Font…", + ["Home", "Font"], + Some("Cmd+T"), + "{family?, size?: pt, bold?: bool, italic?: bool, underline?: none|sng|dbl|heavy|dotted|dash|wavy, color?, strike?: none|sng|dbl, baseline?: none|super|sub|, caps?: none|small|all, spacing?: pt} — with none of these, opens the Font dialog", + has_text_or_shapes, + font_dialog + ), cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow), cmd!( "format.textOutline", @@ -309,6 +318,213 @@ fn spacing(s: &mut Session, p: &Value) -> Result { let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); format_run(s, &move |r| r.spacing = Some(v)) } + +/// Character formatting applied together, so the Font dialog is one undo step. +struct FontEdits { + family: Option, + size: Option, + bold: Option, + italic: Option, + underline: Option, + color: Option, + strike: Option, + baseline: Option, + caps: Option, + spacing: Option, +} + +impl FontEdits { + fn is_empty(&self) -> bool { + self.family.is_none() + && self.size.is_none() + && self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.color.is_none() + && self.strike.is_none() + && self.baseline.is_none() + && self.caps.is_none() + && self.spacing.is_none() + } + + fn write(&self, r: &mut RunProps) { + if let Some(f) = &self.family { + r.font = Some(f.clone()); + } + if let Some(v) = self.size { + r.size = Some(v); + } + if let Some(v) = self.bold { + r.bold = Some(v); + } + if let Some(v) = self.italic { + r.italic = Some(v); + } + if let Some(u) = &self.underline { + r.underline = Some(u.clone()); + } + if let Some(c) = &self.color { + r.fill = Some(Fill::solid(c.clone())); + } + if let Some(v) = self.strike { + r.strike = Some(v); + } + if let Some(v) = self.baseline { + r.baseline = Some(v); + } + if let Some(v) = self.caps { + r.caps = Some(v); + } + if let Some(v) = self.spacing { + r.spacing = Some(v); + } + } +} + +fn font_bool(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), + } +} + +fn font_str<'a>(p: &'a Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), + } +} + +fn font_edits(p: &Value) -> Result { + let family = match font_str(p, "family")? { + None => None, + Some(s) => { + let t = s.trim(); + if t.is_empty() || t.chars().count() > 200 { + return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); + } + Some(t.to_string()) + } + }; + let size = match p.get("size") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; + Some(n.clamp(1.0, 4000.0)) + } + }; + let underline = match font_str(p, "underline")? { + None => None, + Some(s) => { + if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { + return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); + } + Some(s.to_string()) + } + }; + let color = + if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; + let strike = match font_str(p, "strike")? { + None => None, + Some("none") => Some(Strike::None), + Some("sng" | "single") => Some(Strike::Single), + Some("dbl" | "double") => Some(Strike::Double), + Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), + }; + let baseline = match p.get("baseline") { + None => None, + Some(Value::String(s)) => match s.as_str() { + "none" => Some(0.0), + "super" | "superscript" => Some(0.3), + "sub" | "subscript" => Some(-0.25), + _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }, + Some(Value::Number(n)) => { + let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; + Some(f.clamp(-1.0, 1.0)) + } + Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }; + let caps = match font_str(p, "caps")? { + None => None, + Some("none") => Some(Caps::None), + Some("small") => Some(Caps::Small), + Some("all") => Some(Caps::All), + Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), + }; + let spacing = match p.get("spacing") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; + Some(n.clamp(-100.0, 400.0)) + } + }; + Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) +} + +/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. +fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { + use deckcraft_model::ColorBase; + let mut o = serde_json::Map::new(); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().to_string(), json!(v)); + } + } + match &c.base { + ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), + ColorBase::Rgb { rgb } => { + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + Value::Object(o) + } + ColorBase::Scheme { slot } => { + o.insert("scheme".into(), json!(slot.xml_name())); + Value::Object(o) + } + ColorBase::Preset { name } => json!(name), + ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), + } +} + +fn font_dialog_params(s: &Session) -> Value { + let r = current_run(s); + let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); + let baseline = r.baseline.unwrap_or(0.0); + let mut v = json!({ + "family": family, + "size": r.size.unwrap_or(18.0), + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), + "strike": match r.strike { + Some(Strike::Double) => "dbl", + Some(Strike::Single) => "sng", + _ => "none", + }, + "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, + "caps": match r.caps { + Some(Caps::Small) => "small", + Some(Caps::All) => "all", + _ => "none", + }, + "spacing": r.spacing.unwrap_or(0.0), + }); + if let Some(Fill::Solid { color }) = &r.fill { + v["color"] = color_command_value(color); + } + v +} + +fn font_dialog(s: &mut Session, p: &Value) -> Result { + let edits = font_edits(p)?; + if edits.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); + return ok(); + } + format_run(s, &|r| edits.write(r)) +} fn clear(s: &mut Session, _p: &Value) -> Result { format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) } @@ -682,3 +898,124 @@ fn state(s: &mut Session, _p: &Value) -> Result { "painter": s.painter.is_some(), })) } + +#[cfg(test)] +mod tests { + use deckcraft_model::text::{Caps, Strike}; + use deckcraft_model::{ColorBase, Fill, SchemeSlot}; + use serde_json::json; + + use super::*; + use crate::{Session, UiRequest}; + + fn shape() -> Session { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hello"})).unwrap(); + s + } + + fn props(s: &Session) -> RunProps { + s.doc() + .unwrap() + .selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .unwrap() + .props + .clone() + } + + #[test] + fn font_dialog_applies_character_formatting_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.fontDialog", + &json!({ + "family": "Liberation Serif", + "size": 28, + "bold": true, + "italic": false, + "underline": "wavy", + "color": "#112233", + "strike": "dbl", + "baseline": "super", + "caps": "small", + "spacing": 3 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Font…"); + let r = props(&s); + assert_eq!(r.font.as_deref(), Some("Liberation Serif")); + assert_eq!(r.size, Some(28.0)); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.italic, Some(false)); + assert_eq!(r.underline.as_deref(), Some("wavy")); + assert!(matches!(r.fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Rgb { .. }))); + assert_eq!(r.strike, Some(Strike::Double)); + assert_eq!(r.baseline, Some(0.3)); + assert_eq!(r.caps, Some(Caps::Small)); + assert_eq!(r.spacing, Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert!(r.font.is_none() && r.size.is_none() && r.bold.is_none() && r.underline.is_none() && r.fill.is_none()); + assert_eq!(r.strike, None); + assert_eq!(r.caps, None); + } + + #[test] + fn font_dialog_changes_only_the_fields_it_is_given() { + let mut s = shape(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("format.fontDialog", &json!({"size": 22, "color": {"scheme": "accent2", "lumMod": 50000}})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.size, Some(22.0)); + assert!(r.font.is_none() && r.italic.is_none()); + let Some(Fill::Solid { color }) = r.fill else { panic!("solid fill") }; + assert!(matches!(color.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 })); + assert!(color.mods.iter().any(|m| m.xml_name() == "lumMod")); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert!(r.size.is_none() && r.fill.is_none()); + } + + #[test] + fn font_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + s.execute("format.italic", &json!({"on": true})).unwrap(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.fontDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "font"); + assert_eq!(params["italic"], true); + assert_eq!(params["bold"], false); + assert!(params["family"].as_str().is_some_and(|f| !f.is_empty())); + assert!(params["size"].as_f64().is_some_and(|n| n > 0.0)); + } + + #[test] + fn font_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("format.fontDialog", &json!({"underline": "sparkle"})).unwrap_err(); + assert!(err.to_string().contains("underline"), "{err}"); + assert!(s.execute("format.fontDialog", &json!({"size": "large"})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"bold": 1})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"family": " "})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"color": "not-a-color"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert!(props(&s).underline.is_none()); + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err()); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..9959882d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -635,6 +635,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("text.edit", json!({})); ui.close(); } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..7348b5bd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,4 +1,4 @@ -//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the +//! Dialogs (Insert Table, Slide Size, Font, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; @@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "font" => "Font", "about" => "About DeckCraft", "preferences" => "Preferences", "export" => "Export", @@ -382,6 +383,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "font" => font_dialog(app, ui, d), "paragraph" => { field(ui, d, "Before text (in):", "left", "0"); field(ui, d, "Special — hanging (in):", "hang", "0"); @@ -829,3 +831,211 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +fn seed_once(d: &mut Dialog, key: &str, val: String) { + d.fields.entry(key.to_string()).or_insert(val); +} + +fn json_text(v: Option<&Value>, default: &str) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.as_f64().map(|x| crate::widgets::fmt_num(x, "")).unwrap_or_else(|| default.to_string()), + Some(Value::Bool(true)) => "1".into(), + Some(Value::Bool(false)) => "0".into(), + _ => default.to_string(), + } +} + +fn color_label(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(o) => o.get("scheme").or_else(|| o.get("rgb")).and_then(Value::as_str).unwrap_or("").to_string(), + _ => String::new(), + } +} + +fn seed_font_fields(d: &mut Dialog) { + seed_once(d, "family", json_text(d.params.get("family"), "")); + seed_once(d, "size", json_text(d.params.get("size"), "18")); + seed_once(d, "bold", json_text(d.params.get("bold"), "0")); + seed_once(d, "italic", json_text(d.params.get("italic"), "0")); + seed_once(d, "underline", json_text(d.params.get("underline"), "none")); + seed_once(d, "strike", json_text(d.params.get("strike"), "none")); + seed_once(d, "baseline", json_text(d.params.get("baseline"), "none")); + seed_once(d, "caps", json_text(d.params.get("caps"), "none")); + seed_once(d, "spacing", json_text(d.params.get("spacing"), "0")); + let shown = d.params.get("color").map(color_label).unwrap_or_default(); + seed_once(d, "color", shown.clone()); + seed_once(d, "colorSeed", shown); +} + +fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, ""); + let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); + egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(cur == *v, *l).clicked() { + d.fields.insert((*key).into(), (*v).into()); + } + } + }); +} + +fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { + let mut v = d.get(key, ""); + let l = ui.label(label); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id); + d.fields.insert(key.into(), v.clone()); + v +} + +fn finite_pt(text: &str) -> Option { + text.trim().trim_end_matches("pt").trim().parse::().ok().filter(|v| v.is_finite()) +} + +/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together. +fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_font_fields(d); + let mut fams = crate::ribbon::font_families(app); + let family = d.get("family", ""); + if !family.is_empty() && !fams.iter().any(|f| f == &family) { + fams.insert(0, family.clone()); + } + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui( + ui, + |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for f in &fams { + if ui.selectable_label(family == *f, f).clicked() { + d.fields.insert("family".into(), f.clone()); + } + } + }); + }, + ); + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Size", "size", 64.0); + ui.add_space(16.0); + check(ui, d, "Bold", "bold", false); + check(ui, d, "Italic", "italic", false); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + choice( + ui, + d, + "font_dialog_underline", + "underline", + &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], + ); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "Color", "color", 110.0); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); + ui.add_space(8.0); + ui.label("Caps"); + choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + let mut spacing = d.get("spacing", "0"); + let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); + let mut picked = None; + egui::ComboBox::from_id_salt("font_dialog_spacing") + .selected_text(match spacing.as_str() { + "-3" => "Very tight", + "-1.5" => "Tight", + "0" => "Normal", + "3" => "Loose", + "6" => "Very loose", + other => other, + }) + .width(120.0) + .show_ui(ui, |ui| { + for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { + if ui.selectable_label(spacing == v, l).clicked() { + picked = Some(v.to_string()); + } + } + }); + let _ = edited; + if let Some(v) = picked { + d.fields.insert("spacing".into(), v); + } else { + d.fields.insert("spacing".into(), spacing); + } + }); + let bold = d.get("bold", "0") == "1"; + let italic = d.get("italic", "0") == "1"; + let underline = d.get("underline", "none"); + let strike = d.get("strike", "none"); + let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; + let mut sample = egui::RichText::new("AaBbCcDd").size(shown); + if bold { + sample = sample.strong(); + } + if italic { + sample = sample.italics(); + } + if underline != "none" { + sample = sample.underline(); + } + if strike != "none" { + sample = sample.strikethrough(); + } + ui.add_space(6.0); + ui.label(sample); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let family = d.get("family", ""); + let family = family.trim(); + if family.is_empty() { + app.set_status("Choose a font"); + return false; + } + let Some(size) = finite_pt(&d.get("size", "")) else { + app.set_status("Font size must be a number"); + return false; + }; + let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { + app.set_status("Character spacing must be a number"); + return false; + }; + let mut params = json!({ + "family": family, + "size": size, + "bold": d.get("bold", "0") == "1", + "italic": d.get("italic", "0") == "1", + "underline": d.get("underline", "none"), + "strike": d.get("strike", "none"), + "baseline": d.get("baseline", "none"), + "caps": d.get("caps", "none"), + "spacing": spacing, + }); + let color = d.get("color", ""); + let seed = d.get("colorSeed", ""); + if color == seed { + if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) { + params["color"] = c.clone(); + } + } else if !color.trim().is_empty() { + params["color"] = json!(color.trim()); + } + let _ = app.run("format.fontDialog", params); + } + ok || cancel +} + +#[cfg(test)] +#[path = "font_dialog_tests.rs"] +mod font_dialog_tests; diff --git a/crates/ui-egui/src/font_dialog_tests.rs b/crates/ui-egui/src/font_dialog_tests.rs new file mode 100644 index 00000000..06ccfc02 --- /dev/null +++ b/crates/ui-egui/src/font_dialog_tests.rs @@ -0,0 +1,65 @@ +//! The Font dialog: Home › Font › Font… and Cmd+T open it, OK applies one undo step, Cancel does not. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + // A selected shape, not a caret: the dialog then formats the text that is already there. + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn bold_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.bold) +} + +#[test] +fn font_button_applies_bold_from_the_dialog() { + let mut h = harness(); + h.get_by_label("Font…").click(); + // The opening frame ignores the click that summoned the window; the next one is live. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font")); + // A pointer click on this checkbox does not register through kittest's per-event frames; + // the accessibility click does, and it is the same toggle a pointer click runs. + h.get_by_label("Bold").click_accesskit(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(bold_of(&h), Some(true)); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Font…"); +} + +#[test] +fn command_t_opens_the_font_dialog_and_cancel_keeps_the_text() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T); + h.run_steps(1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font"), "status {:?}", h.state().status); + h.get_by_label("Cancel").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(bold_of(&h), None); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..c294278f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -222,6 +222,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ( "Format", vec![ + ("Font…", "format.fontDialog"), ("Bold", "format.bold"), ("Italic", "format.italic"), ("Underline", "format.underline"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..d67aaf0a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -502,6 +502,9 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.color", json!({"color": cref_param(&c)})); } }); + if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + run(app, "format.fontDialog", json!({})); + } }); }); }); diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..c844d460 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog, 227 commands, 28 dialogs; previous: minor, 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 @@ -20,8 +20,8 @@ nothing below L6 uses egui, eframe, winit or rfd. | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | | L4 | `pdf` | PDF export through krilla | -| 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, 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 (227 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 @@ -78,5 +78,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 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 | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..4368c0d4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, 3-D bevel, camera and face lighting rendered) · **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 @@ -203,8 +203,9 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. +- **Dialogs**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline, + colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets + and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..a31d0964 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -239,7 +239,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline text, AutoRecover. -- **Automation:** 226 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. - **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09). @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..6362973b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** 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 (Font dialog; previous: minor, text editing keys observed in PowerPoint for Windows, #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 @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | 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…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, 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…) | 28 (`crates/ui-egui/src/dialogs.rs`): Font, Table, Slide Size, 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 | ## Direct manipulation @@ -91,8 +91,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). ## Keyboard shortcuts Every common PowerPoint for Mac shortcut is bound (checklist row done): new slide, duplicate, -group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, show from -start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag +group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, the Font +dialog (Cmd+T), show from start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag precision without snapping, F6 pane cycling, ribbon KeyTips (Windows). ### Text editing keys @@ -137,7 +137,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been |---|---| | Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 | | Format Shape pane depth | 6–10 | -| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | +| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Grid Options, edit points UI, crop depth | 4–7 | | Multiple windows | 3–5 | @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T | | 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 | From e4022e67025505e732747e8dd5e87a6c7e60357c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:59:40 +0300 Subject: [PATCH 41/69] UI: Paragraph dialog sets alignment, indents and spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format.paragraphDialog applies alignment, first-line and hanging indents, spacing and line spacing in one undo step. With no fields it opens the dialog from the Home › Paragraph launcher. Tabs… opens a standalone Tabs dialog and returns to Paragraph. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/engine/src/cmd/format.rs | 362 ++++++++++++++++++- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/dialogs.rs | 356 +++++++++++++++++- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/paragraph_dialog_tests.rs | 88 +++++ crates/ui-egui/src/ribbon.rs | 5 +- docs/architecture.md | 7 +- docs/gaps.md | 10 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 9 +- 11 files changed, 816 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/src/paragraph_dialog_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 15729ba9..63e7e18e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Font dialog; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Paragraph dialog; previous: trivial, Font dialog; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step. Breadth stays 79% | | 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% | | 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 | @@ -151,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Paragraph dialog | | 2026-10-14 | trivial | Progress log: Font dialog | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index f1c3921d..c6dec0e0 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -3,7 +3,7 @@ use deckcraft_model::edit as ed; use deckcraft_model::resolve; -use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TextDir}; +use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir}; use deckcraft_model::{Effects, Fill, Line, Shape}; use serde_json::{Value, json}; @@ -148,6 +148,24 @@ pub fn specs() -> Vec { has_text_or_shapes, paragraph ), + cmd!( + "format.paragraphDialog", + "Paragraph…", + ["Home", "Paragraph"], + None, + "{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog", + has_text_or_shapes, + paragraph_dialog + ), + cmd!( + "format.tabs", + "Tabs…", + ["Home", "Paragraph"], + None, + "{set?: [{pos: pt, align: l|ctr|r|dec}], defaultTab?: pt} — with neither, opens the Tabs dialog", + has_text_or_shapes, + tabs + ), cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), cmd!( "format.direction", @@ -758,6 +776,229 @@ fn paragraph(s: &mut Session, p: &Value) -> Result { }) } +const PARA_DIALOG_KEYS: &[&str] = + &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; + +fn para_dialog_has_fields(p: &Value) -> bool { + PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) +} + +fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; + Ok(Some(n)) + } + } +} + +fn clamp_pt(v: f64) -> f64 { + v.clamp(0.0, 1584.0) +} + +fn parse_align(cmd: &str, s: &str) -> Result { + Ok(match s { + "left" | "l" => Align::Left, + "center" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), + }) +} + +fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "left", + Align::Center => "center", + Align::Right => "right", + Align::Justify => "justify", + Align::JustLow => "justLow", + Align::Distributed => "distributed", + } +} + +fn spacing_parts(s: Option) -> (&'static str, f64) { + match s { + Some(Spacing::Pct(v)) => ("pct", v), + Some(Spacing::Pts(v)) => ("pts", v), + None => ("pts", 0.0), + } +} + +fn paragraph_dialog_params(s: &Session) -> Value { + let pp = current_para(s); + let indent = pp.indent.unwrap_or(0.0); + let (special, by) = if indent > 0.05 { + ("first", indent) + } else if indent < -0.05 { + ("hanging", -indent) + } else { + ("none", 0.0) + }; + let (line_kind, line_value) = match pp.line_spacing { + Some(Spacing::Pts(v)) => ("exactly", v), + Some(Spacing::Pct(v)) => ("multiple", v), + None => ("multiple", 1.0), + }; + let (before_kind, before) = spacing_parts(pp.space_before); + let (after_kind, after) = spacing_parts(pp.space_after); + let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({ + "align": align_name(pp.align.unwrap_or(Align::Left)), + "indentLeft": pp.margin_left.unwrap_or(0.0), + "indentRight": pp.margin_right.unwrap_or(0.0), + "special": special, + "specialBy": by, + "spaceBefore": before, + "spaceBeforeKind": before_kind, + "spaceAfter": after, + "spaceAfterKind": after_kind, + "lineKind": line_kind, + "lineValue": line_value, + "tabs": tabs, + "defaultTab": pp.default_tab.unwrap_or(72.0), + }) +} + +fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { + if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } +} + +fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.paragraphDialog"; + if !para_dialog_has_fields(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); + return ok(); + } + let align = match p.get("align") { + None => None, + Some(Value::String(a)) => Some(parse_align(CMD, a)?), + Some(_) => return Err(bad(CMD, "`align` must be a string")), + }; + let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); + let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); + let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); + let indent = match p.get("special") { + None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), + Some(Value::String(kind)) => { + let by = special_by.unwrap_or(0.0); + Some(match kind.as_str() { + "none" => 0.0, + "first" | "firstLine" => by, + "hanging" | "hang" => -by, + _ => return Err(bad(CMD, "`special` must be none, first or hanging")), + }) + } + Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), + }; + let space_before = finite_key(p, CMD, "spaceBefore")?; + let space_after = finite_key(p, CMD, "spaceAfter")?; + let before_kind = str_param(p, "spaceBeforeKind"); + let after_kind = str_param(p, "spaceAfterKind"); + for kind in [before_kind, after_kind].into_iter().flatten() { + if !matches!(kind, "pts" | "pct") { + return Err(bad(CMD, "spacing kind must be pts or pct")); + } + } + let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); + let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); + format_para(s, &move |para| { + if align.is_some() { + para.props.align = align; + } + if indent_left.is_some() { + para.props.margin_left = indent_left; + } + if indent_right.is_some() { + para.props.margin_right = indent_right; + } + if indent.is_some() { + para.props.indent = indent; + } + if let Some(v) = space_before { + para.props.space_before = Some(write_spacing(before_kind, v)); + } + if let Some(v) = space_after { + para.props.space_after = Some(write_spacing(after_kind, v)); + } + if let Some(v) = line_mult { + para.props.line_spacing = Some(Spacing::Pct(v)); + } + if let Some(v) = line_pt { + para.props.line_spacing = Some(Spacing::Pts(v)); + } + }) +} + +fn tab_align(s: &str) -> Option<&'static str> { + Some(match s { + "l" | "left" => "l", + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => return None, + }) +} + +fn tabs_params(s: &Session) -> Value { + let pp = current_para(s); + let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) +} + +fn tabs(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.tabs"; + let set = p.get("set"); + let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); + if set.is_none() && default_tab.is_none() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); + return ok(); + } + let stops = match set { + None => None, + Some(Value::Array(items)) => { + if items.len() > 64 { + return Err(bad(CMD, "`set` has more than 64 tab stops")); + } + let mut out: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let Some(obj) = item.as_object() else { + return Err(bad(CMD, format!("tab stop {i} must be an object"))); + }; + let pos = obj + .get("pos") + .and_then(Value::as_f64) + .filter(|n| n.is_finite()) + .map(clamp_pt) + .ok_or_else(|| bad(CMD, format!("tab stop {i} needs a finite `pos`")))?; + let align = match obj.get("align").and_then(Value::as_str) { + None => "l", + Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, + }; + if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { + *prev = TabStop { pos, align: align.to_string() }; + } else { + out.push(TabStop { pos, align: align.to_string() }); + } + } + out.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + Some(out) + } + Some(_) => return Err(bad(CMD, "`set` must be an array")), + }; + format_para(s, &move |para| { + if let Some(stops) = &stops { + para.props.tabs = Some(stops.clone()); + } + if let Some(v) = default_tab { + para.props.default_tab = Some(v); + } + }) +} + fn anchor(s: &mut Session, p: &Value) -> Result { let a = match str_param(p, "anchor").unwrap_or("top") { "middle" | "center" | "ctr" => Anchor::Middle, @@ -901,7 +1142,7 @@ fn state(s: &mut Session, _p: &Value) -> Result { #[cfg(test)] mod tests { - use deckcraft_model::text::{Caps, Strike}; + use deckcraft_model::text::{Align, Caps, ParaProps, Spacing, Strike}; use deckcraft_model::{ColorBase, Fill, SchemeSlot}; use serde_json::json; @@ -1018,4 +1259,121 @@ mod tests { s.execute("edit.deselect", &json!({})).unwrap(); assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err()); } + + fn para(s: &Session) -> ParaProps { + s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).and_then(|t| t.paragraphs.first()).unwrap().props.clone() + } + + #[test] + fn paragraph_dialog_applies_alignment_indent_and_spacing_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.paragraphDialog", + &json!({ + "align": "justify", + "indentLeft": 36.0, + "indentRight": 18.0, + "special": "hanging", + "specialBy": 18.0, + "spaceBefore": 6.0, + "spaceAfter": 8.0, + "lineSpacing": 1.5 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…"); + let p = para(&s); + assert_eq!(p.align, Some(Align::Justify)); + assert_eq!(p.margin_left, Some(36.0)); + assert_eq!(p.margin_right, Some(18.0)); + assert_eq!(p.indent, Some(-18.0)); + assert_eq!(p.space_before, Some(Spacing::Pts(6.0))); + assert_eq!(p.space_after, Some(Spacing::Pts(8.0))); + assert_eq!(p.line_spacing, Some(Spacing::Pct(1.5))); + s.execute("edit.undo", &json!({})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote"); + assert!(p.margin_left.is_none() && p.indent.is_none() && p.space_before.is_none() && p.line_spacing.is_none()); + } + + #[test] + fn paragraph_dialog_first_line_and_exact_spacing_replace_only_those_fields() { + let mut s = shape(); + s.execute("format.paragraphDialog", &json!({"align": "right"})).unwrap(); + s.execute("format.paragraphDialog", &json!({"special": "first", "specialBy": 27.0, "lineSpacingPt": 20.0})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Right)); + assert_eq!(p.indent, Some(27.0)); + assert_eq!(p.line_spacing, Some(Spacing::Pts(20.0))); + assert!(p.margin_left.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).align, Some(Align::Right)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn paragraph_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.paragraphDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "paragraph"); + assert_eq!(params["align"], "center", "a new shape's text is centred"); + assert_eq!(params["special"], "none"); + assert_eq!(params["lineKind"], "multiple"); + assert!(params["lineValue"].as_f64().is_some_and(|v| v > 0.0)); + } + + #[test] + fn paragraph_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + assert!(s.execute("format.paragraphDialog", &json!({"align": "sparkle"})).unwrap_err().to_string().contains("align")); + assert!(s.execute("format.paragraphDialog", &json!({"special": "middle"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"indentLeft": "wide"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"lineSpacing": "double"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"spaceBeforeKind": "lines", "spaceBefore": 4.0})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert_eq!(para(&s).align, Some(Align::Center)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.tabs", + &json!({"set": [{"pos": 144.0, "align": "center"}, {"pos": 72.0, "align": "dec"}, {"pos": 72.02, "align": "right"}], "defaultTab": 36.0}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Tabs…"); + let p = para(&s); + let tabs = p.tabs.unwrap(); + assert_eq!(tabs.len(), 2, "stops within 0.05 pt collapse"); + assert_eq!(tabs[0].pos, 72.02); + assert_eq!(tabs[0].align, "r"); + assert_eq!(tabs[1].align, "ctr"); + assert_eq!(p.default_tab, Some(36.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(para(&s).tabs.is_none() && para(&s).default_tab.is_none()); + + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "tabs"); + assert!(params["tabs"].as_array().is_some_and(|a| a.is_empty())); + assert!(s.execute("format.tabs", &json!({"set": [{"pos": 10.0, "align": "sparkle"}]})).is_err()); + assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9959882d..0e22789c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -639,6 +639,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("format.fontDialog", json!({})); ui.close(); } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7348b5bd..70924563 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,4 +1,4 @@ -//! Dialogs (Insert Table, Slide Size, Font, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the +//! Dialogs (Insert Table, Slide Size, Font, Paragraph, Tabs, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; @@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", "font" => "Font", "about" => "About DeckCraft", "preferences" => "Preferences", @@ -75,8 +76,17 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { ui.set_min_width(340.0); close = body(app, ui, &mut d); }); - if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if open && !close && !escape { app.dialog = Some(d); + } else if app.dialog.is_none() + && let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned() + { + // Tabs… opened from Paragraph: closing it (OK, Cancel, Esc or the window) returns there. + let id = d.params.get("returnId").and_then(Value::as_str).unwrap_or("paragraph"); + let mut back = Dialog::new(id); + back.params = ret; + app.dialog = Some(back); } } @@ -384,22 +394,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "font" => font_dialog(app, ui, d), - "paragraph" => { - field(ui, d, "Before text (in):", "left", "0"); - field(ui, d, "Special — hanging (in):", "hang", "0"); - field(ui, d, "Spacing before (pt):", "before", "0"); - field(ui, d, "Spacing after (pt):", "after", "0"); - field(ui, d, "Line spacing (lines):", "lines", "1.0"); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "format.paragraph", - json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}), - ); - } - ok || cancel - } + "paragraph" => paragraph_dialog(app, ui, d), + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -1036,6 +1032,330 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } +fn pt_text(v: Option<&Value>, default: f64) -> String { + match v.and_then(Value::as_f64).filter(|n| n.is_finite()) { + Some(n) => crate::widgets::fmt_num(n, ""), + None => crate::widgets::fmt_num(default, ""), + } +} + +fn inches_text(v: Option<&Value>) -> String { + crate::widgets::fmt_num(v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) / 72.0, "") +} + +fn seed_paragraph_fields(d: &mut Dialog) { + seed_once(d, "align", json_text(d.params.get("align"), "left")); + seed_once(d, "before", inches_text(d.params.get("indentLeft"))); + seed_once(d, "after", inches_text(d.params.get("indentRight"))); + seed_once(d, "special", json_text(d.params.get("special"), "none")); + seed_once(d, "by", inches_text(d.params.get("specialBy"))); + seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); + seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); +} + +fn paragraph_return_params(d: &mut Dialog) -> Value { + let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0; + let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0; + let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0; + let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0); + let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0); + let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); + let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); + let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); + let v = json!({ + "align": d.get("align", "left"), + "indentLeft": before, + "indentRight": after, + "special": d.get("special", "none"), + "specialBy": by, + "spaceBefore": space_before, + "spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfter": space_after, + "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "lineKind": d.get("lineKind", "multiple"), + "lineValue": line_value, + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + }); + v +} + +fn line_preset_label(kind: &str, value: &str) -> &'static str { + if kind == "exactly" { + "Exactly" + } else { + match value { + "1" => "Single", + "1.5" => "1.5 lines", + "2" => "Double", + _ => "Multiple", + } + } +} + +/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together. +fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_paragraph_fields(d); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + choice( + ui, + d, + "paragraph_align", + "align", + &[ + ("left", "Left"), + ("center", "Center"), + ("right", "Right"), + ("justify", "Justify"), + ("justLow", "Justify low"), + ("distributed", "Distributed"), + ], + ); + }); + ui.label(egui::RichText::new("Indentation").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Before text", "before", 64.0); + ui.label("in"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "After text", "after", 64.0); + ui.label("in"); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "By", "by", 64.0); + ui.label("in"); + }); + ui.label(egui::RichText::new("Spacing").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); + ui.label("pt"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); + ui.label("pt"); + }); + let mut picked: Option<(&'static str, Option<&'static str>)> = None; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let kind = d.get("lineKind", "multiple"); + let value = d.get("lineValue", "1"); + egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + for (label, k, v) in [ + ("Single", "multiple", Some("1")), + ("1.5 lines", "multiple", Some("1.5")), + ("Double", "multiple", Some("2")), + ("Multiple", "multiple", None), + ("Exactly", "exactly", None), + ] { + if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + picked = Some((k, v)); + } + } + }); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "At", "lineValue", 64.0); + }); + if let Some((kind, value)) = picked { + d.fields.insert("lineKind".into(), kind.into()); + if let Some(v) = value { + d.fields.insert("lineValue".into(), v.into()); + } else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) { + d.fields.insert("lineValue".into(), "18".into()); + } else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) { + d.fields.insert("lineValue".into(), "1".into()); + } + } + let mut open_tabs = false; + ui.horizontal(|ui| { + if ui.button("Tabs…").clicked() { + open_tabs = true; + } + }); + let (ok, cancel) = buttons(ui, "OK"); + if open_tabs { + let mut tabs = Dialog::new("tabs"); + tabs.params = json!({ + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + "returnId": "paragraph", + "returnParams": paragraph_return_params(d), + }); + app.dialog = Some(tabs); + return true; + } + if ok { + let Some(before_in) = finite_pt(&d.get("before", "")) else { + app.set_status("Indent before text must be a number of inches"); + return false; + }; + let Some(after_in) = finite_pt(&d.get("after", "")) else { + app.set_status("Indent after text must be a number of inches"); + return false; + }; + let Some(by_in) = finite_pt(&d.get("by", "")) else { + app.set_status("Special indent must be a number of inches"); + return false; + }; + let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { + app.set_status("Spacing before must be a number"); + return false; + }; + let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { + app.set_status("Spacing after must be a number"); + return false; + }; + let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { + app.set_status("Line spacing must be a number"); + return false; + }; + let mut params = json!({ + "align": d.get("align", "left"), + "indentLeft": before_in * 72.0, + "indentRight": after_in * 72.0, + "special": d.get("special", "none"), + "specialBy": by_in * 72.0, + "spaceBefore": space_before, + "spaceAfter": space_after, + "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + }); + if d.get("lineKind", "multiple") == "exactly" { + params["lineSpacingPt"] = json!(line_value); + } else { + params["lineSpacing"] = json!(line_value); + } + if app.run("format.paragraphDialog", params).is_err() { + return false; + } + } + ok || cancel +} + +fn tab_rows(v: &Value) -> Vec<(f64, String)> { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|t| { + let pos = t.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite())?; + let align = t.get("align").and_then(Value::as_str).unwrap_or("l").to_string(); + Some((pos, align)) + }) + .collect() + }) + .unwrap_or_default() +} + +fn tab_label(align: &str) -> &'static str { + match align { + "ctr" => "Center", + "r" => "Right", + "dec" => "Decimal", + _ => "Left", + } +} + +/// Standalone Tabs dialog (the ruler dialog from the open tabs work is not on main). +/// OK applies the stops in one step. Opened from Paragraph, closing returns there. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(420.0); + if d.params.get("editing").is_none() { + d.params["edit"] = d.params.get("tabs").cloned().unwrap_or_else(|| json!([])); + d.params["editing"] = json!(true); + } + seed_once(d, "pos", "0.5".to_string()); + seed_once(d, "stopAlign", "l".to_string()); + seed_once(d, "defaultTab", inches_text(d.params.get("defaultTab"))); + seed_once(d, "sel", String::new()); + let rows = tab_rows(d.params.get("edit").unwrap_or(&Value::Null)); + ui.label("Tab stop position"); + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + if rows.is_empty() { + ui.label(egui::RichText::new("No custom stops").weak()); + } + for (i, (pos, align)) in rows.iter().enumerate() { + let label = format!("{} in {}", crate::widgets::fmt_num(pos / 72.0, ""), tab_label(align)); + let on = d.get("sel", "") == i.to_string(); + if ui.selectable_label(on, label).clicked() { + d.fields.insert("sel".into(), i.to_string()); + d.fields.insert("pos".into(), crate::widgets::fmt_num(pos / 72.0, "")); + d.fields.insert("stopAlign".into(), align.clone()); + } + } + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Position", "pos", 64.0); + ui.label("in"); + ui.add_space(8.0); + choice(ui, d, "tabs_align", "stopAlign", &[("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]); + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(inches) = finite_pt(&d.get("pos", "")) + { + let pos = (inches * 72.0).clamp(0.0, 1584.0); + let align = d.get("stopAlign", "l"); + let mut rows = tab_rows(d.params.get("edit").unwrap_or(&Value::Null)); + let sel = d.get("sel", "").parse::().ok(); + if let Some(i) = sel.filter(|i| *i < rows.len()) { + rows[i] = (pos, align); + } else if rows.len() < 64 { + rows.push((pos, align)); + d.fields.insert("sel".into(), (rows.len() - 1).to_string()); + } + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.params["edit"] = json!(rows.iter().map(|(p, a)| json!({"pos": p, "align": a})).collect::>()); + } + if ui.button("Clear").clicked() + && let Some(i) = d.get("sel", "").parse::().ok() + { + let mut rows = tab_rows(d.params.get("edit").unwrap_or(&Value::Null)); + if i < rows.len() { + rows.remove(i); + d.params["edit"] = json!(rows.iter().map(|(p, a)| json!({"pos": p, "align": a})).collect::>()); + d.fields.insert("sel".into(), String::new()); + } + } + if ui.button("Clear All").clicked() { + d.params["edit"] = json!([]); + d.fields.insert("sel".into(), String::new()); + } + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Default", "defaultTab", 64.0); + ui.label("in"); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let Some(default_in) = finite_pt(&d.get("defaultTab", "")) else { + app.set_status("Default tab stops must be a number of inches"); + return false; + }; + let set: Vec = + tab_rows(d.params.get("edit").unwrap_or(&Value::Null)).into_iter().map(|(p, a)| json!({"pos": p, "align": a})).collect(); + if app.run("format.tabs", json!({"set": set, "defaultTab": (default_in * 72.0).clamp(1.0, 1584.0)})).is_err() { + return false; + } + let applied = d.params.get("edit").cloned().unwrap_or_else(|| json!([])); + let default_pt = (default_in * 72.0).clamp(1.0, 1584.0); + if let Some(ret) = d.params.get_mut("returnParams") { + ret["tabs"] = applied; + ret["defaultTab"] = json!(default_pt); + } + } + ok || cancel +} + #[cfg(test)] #[path = "font_dialog_tests.rs"] mod font_dialog_tests; + +#[cfg(test)] +#[path = "paragraph_dialog_tests.rs"] +mod paragraph_dialog_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c294278f..82ec182d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -223,6 +223,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { "Format", vec![ ("Font…", "format.fontDialog"), + ("Paragraph…", "format.paragraphDialog"), ("Bold", "format.bold"), ("Italic", "format.italic"), ("Underline", "format.underline"), diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs new file mode 100644 index 00000000..cba8fe53 --- /dev/null +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -0,0 +1,88 @@ +//! The Paragraph dialog: Home › Paragraph › Paragraph… opens it, OK applies one undo step, +//! and Tabs… edits stops then returns to Paragraph. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn align_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.align.map(|a| a.xml().to_string())) +} + +fn tabs_of(h: &Harness<'_, SlideApp>) -> Vec<(f64, String)> { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.tabs.clone()) + .unwrap_or_default() + .into_iter() + .map(|t| (t.pos, t.align)) + .collect() +} + +#[test] +fn paragraph_button_aligns_text_in_one_undo() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph")); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("OK").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(align_of(&h).as_deref(), Some("r")); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…"); +} + +#[test] +fn tabs_from_paragraph_applies_a_stop_and_returns() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("Tabs…").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.params["editing"] = json!(true); + d.params["edit"] = json!([{"pos": 144.0, "align": "ctr"}]); + } + h.get_by_label("OK").click_accesskit(); + // The Tabs OK frame restores Paragraph; the next frame fills its fields. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph"), "status {:?}", h.state().status); + assert_eq!(h.state().dialog.as_ref().unwrap().fields.get("align").map(String::as_str), Some("right")); + assert_eq!(tabs_of(&h), vec![(144.0, "ctr".into())]); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Tabs…"); + h.get_by_label("Cancel").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote"); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d67aaf0a..5fc6f0c1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -595,7 +595,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } if ui.button("Line Spacing Options…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + run(app, "format.paragraphDialog", json!({})); } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); @@ -636,6 +636,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } }); + if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { + run(app, "format.paragraphDialog", json!({})); + } }); }); }); diff --git a/docs/architecture.md b/docs/architecture.md index c844d460..6397b721 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font 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:** minor (Paragraph and Tabs dialogs, 229 commands, 29 dialogs; previous: minor, Font dialog) · **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 @@ -20,8 +20,8 @@ nothing below L6 uses egui, eframe, winit or rfd. | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | | L4 | `pdf` | PDF export through krilla | -| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (227 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 | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (229 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, 29 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 @@ -78,6 +78,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paragraph and Tabs dialogs; command registry 229; 29 dialogs | | 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 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 | diff --git a/docs/gaps.md b/docs/gaps.md index 4368c0d4..42795059 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; 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 (Paragraph dialog; previous: minor, Font dialog) · **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 @@ -203,9 +203,10 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline, - colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets - and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h. +- **Dialogs**: 29 dialogs vs PowerPoint's ~60. The Font dialog and the Paragraph dialog are in + (Paragraph: alignment, first-line and hanging indents, spacing before/after, line spacing, and a + Tabs dialog, each OK one undo step). Still missing: Bullets and Numbering, Find/Replace, Replace + Fonts, Action Settings, Print, Insert Object…. 4–8 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -257,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paragraph dialog sets alignment, indentation, spacing and line spacing in one step; its Tabs… button opens a standalone Tabs dialog | | 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | | 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-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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a31d0964..efcc3684 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -239,7 +239,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline text, AutoRecover. -- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 229 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. - **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09). @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged | | 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6362973b..ac0da780 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; 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 (Paragraph dialog; previous: minor, Font dialog) · **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 @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | 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`): Font, Table, Slide Size, 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…) | 29 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -82,7 +82,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes | -| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | +| Rulers with indent markers and tab stops | yes | rulers yes; tab stops are set in the Tabs dialog, not on the ruler | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | | Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | @@ -137,7 +137,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been |---|---| | Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 | | Format Shape pane depth | 6–10 | -| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 | +| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 4–8 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Grid Options, edit points UI, crop depth | 4–7 | | Multiple windows | 3–5 | @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog | | 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T | | 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) | From c583480010e2e565b0b174c28252bdeae1b53226 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:27:57 +0300 Subject: [PATCH 42/69] Startup: survive damaged settings, font folders, recovery folders and small texture limits - ui.json / prefs.json are read through a new settings module: regular files only, 4 MiB cap, parse errors logged, values sanitized (a saved zoom of 1e30 aborted start-up, 1e4 panicked in egui); writes are atomic. - The canvas and the control protocol clamp stray zooms; the status bar shows the zoom in use. - The canvas texture is cut to the GPU's max texture side (zoom 400% on a 2048 limit panicked). - The system font scan opens only regular files of sane size (a FIFO named *.ttf in ~/.fonts hung start-up), with depth and entry caps. - AutoRecover skips non-regular entries and reopens at most 32 per start, newest first. Co-authored-by: Cursor --- apps/deckcraft/src/main.rs | 26 ++--- crates/engine/src/lib.rs | 23 +++++ crates/engine/src/recovery.rs | 63 ++++++++++-- crates/fonts/src/fontdb.rs | 45 ++++++++- crates/fonts/src/lib.rs | 26 +++++ crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/lib.rs | 38 +++++++- crates/ui-egui/src/settings.rs | 170 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/status.rs | 2 +- crates/ui-egui/src/textures.rs | 37 ++++++- 11 files changed, 402 insertions(+), 37 deletions(-) create mode 100644 crates/ui-egui/src/settings.rs diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bb..49b55cec 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -52,24 +52,16 @@ fn config_dir() -> Option { } } -fn prefs_path() -> Option { - config_dir().map(|b| b.join("ui.json")) -} - fn load_prefs(app: &mut SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() - && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) - { + let Some(dir) = config_dir() else { return }; + let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir); + if let Some(ui) = ui { app.ui = ui; } - if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) - && let Ok(bytes) = std::fs::read(&p) - && let Ok(prefs) = serde_json::from_slice::(&bytes) - { + if let Some(prefs) = prefs { app.session.prefs = prefs; } } @@ -78,14 +70,8 @@ fn save_prefs(app: &SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() { - let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { - let _ = std::fs::write(&p, bytes); - } - if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { - let _ = std::fs::write(p.with_file_name("prefs.json"), bytes); - } + if let Some(dir) = config_dir() { + deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..7d23a42c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -269,6 +269,29 @@ impl Default for Prefs { } } +impl Prefs { + /// The preferences with every value in a range the editor can use: `prefs.json` is a file + /// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo + /// limit) must not break editing or exhaust memory. Out-of-range numbers take their + /// defaults or the nearest usable value. + pub fn sanitized(mut self) -> Prefs { + let d = Prefs::default(); + let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.grid_spacing = + if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing }; + self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset); + self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT); + self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes); + if self.author.chars().count() > 256 { + self.author = self.author.chars().take(256).collect(); + } + self + } +} + +/// The most undo steps [`Prefs::undo_limit`] can keep. +pub const MAX_UNDO_LIMIT: usize = 10_000; + /// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 30daa81e..391a0374 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -25,6 +25,20 @@ pub fn default_dir() -> Option { std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("deckcraft/recovery")) } +/// Presentations reopened per start; the rest stay on disk for the next one, so a folder +/// full of entries can't stall start-up. +pub const MAX_REOPEN: usize = 32; +/// Largest entry read back. +const MAX_ENTRY_BYTES: u64 = 2 << 30; +const MAX_META_BYTES: u64 = 64 << 10; + +/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an +/// entry belongs would block start-up forever. +fn read_regular(p: &Path, max: u64) -> Option> { + let m = std::fs::metadata(p).ok()?; + (m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten() +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json"))) } @@ -78,11 +92,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") { continue; } + if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) { + continue; + } let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::().ok()) else { continue }; // Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none. - let meta = std::fs::read_to_string(p.with_extension("json")) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) + let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES) + .and_then(|t| serde_json::from_slice::(&t).ok()) .filter(Value::is_object) .unwrap_or(json!({})); out.push((uid, meta)); @@ -92,12 +108,17 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { } /// Reopen every entry as an unsaved presentation and remove the entries (this session writes its -/// own). Unreadable entries are left in place. Returns the opened documents' indices. +/// own), the newest [`MAX_REOPEN`] first. Unreadable entries and those past the cap are left in place. Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result> { let mut opened = Vec::new(); - for (uid, meta) in list(dir) { + let mut entries = list(dir); + entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid))); + for (uid, meta) in entries { + if opened.len() >= MAX_REOPEN { + break; + } let (doc, _) = files(dir, uid); - let Ok(bytes) = std::fs::read(&doc) else { continue }; + let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue }; let Ok(mut d) = deckcraft_format::load(&bytes) else { continue }; deckcraft_format::repair(&mut d); let path = meta.get("path").and_then(Value::as_str).map(str::to_string); @@ -162,4 +183,34 @@ mod tests { assert_eq!(v.as_array().map(Vec::len), Some(1)); let _ = std::fs::remove_dir_all(&dir); } + + /// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked + /// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start. + #[cfg(unix)] + #[test] + fn hostile_recovery_folders_dont_stall_start_up() { + let dir = temp("recovery-hostile"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + save(&s, &dir).unwrap(); + let (good, _) = files(&dir, s.doc().unwrap().uid); + for i in 1..=(MAX_REOPEN as u64 + 3) { + std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap(); + } + for name in ["5.deckcraft", "6.json"] { + let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status(); + } + std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let d = dir.clone(); + std::thread::spawn(move || { + let mut s2 = Session::new(); + let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0)); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung"); + assert_eq!(n, MAX_REOPEN); + assert_eq!(list(&dir).len(), 5, "the rest wait for the next start"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5425c022..059db7f1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -444,6 +444,22 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { .collect() } +/// Folder depth below a font folder that the system scan descends. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_DEPTH: usize = 12; +/// Directory entries the system scan looks at in all. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_ENTRIES: usize = 200_000; +/// The largest font file read (the biggest CJK collections are a few hundred MB). +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_FONT_FILE: u64 = 512 << 20; + +/// A regular file of a size a font can have. +#[cfg(not(target_arch = "wasm32"))] +fn font_file_ok(meta: &std::fs::Metadata) -> bool { + meta.is_file() && meta.len() > 12 && meta.len() <= MAX_FONT_FILE +} + /// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. /// Empty on wasm. pub fn system_font_dirs() -> Vec { @@ -804,24 +820,41 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut found = Vec::new(); - let mut stack = self.font_dirs.clone(); + let mut stack: Vec<(std::path::PathBuf, usize)> = self.font_dirs.iter().map(|d| (d.clone(), 0)).collect(); // Each folder once, however links lead back to it. let mut visited = std::collections::HashSet::new(); - while let Some(d) = stack.pop() { + let mut entries = 0usize; + while let Some((d, depth)) = stack.pop() { if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { continue; } let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { + // A link to `/` or a vast tree mustn't turn start-up into a disk crawl. + entries += 1; + if entries > MAX_SCAN_ENTRIES { + log::warn!("font scan stopped after {MAX_SCAN_ENTRIES} entries"); + stack.clear(); + break; + } let p = e.path(); - if p.is_dir() { - stack.push(p); + // Follows links: what matters is what the entry leads to. + let Ok(meta) = std::fs::metadata(&p) else { continue }; + if meta.is_dir() { + if depth < MAX_SCAN_DEPTH { + stack.push((p, depth + 1)); + } continue; } let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } + // Only regular files: opening a FIFO named like a font blocks until something + // writes to it (start-up hung), and a device never ends. + if !font_file_ok(&meta) { + continue; + } for (family, style) in file_face_names(&p) { found.push(CatalogEntry { family, style, path: p.clone() }); } @@ -845,7 +878,9 @@ impl FontDb { }; let mut any = false; for p in paths { - if let Ok(data) = std::fs::read(&p) { + if std::fs::metadata(&p).is_ok_and(|m| font_file_ok(&m)) + && let Ok(data) = std::fs::read(&p) + { any |= self.add_font(data) > 0; } } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 978661bb..4c6759e4 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -299,4 +299,30 @@ mod tests { assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); } } + + /// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them + /// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and + /// still finds the real font beside them. + #[cfg(unix)] + #[test] + fn the_font_scan_skips_fifos_folders_and_link_loops() { + let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); + let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); + std::fs::create_dir(dir.join("folder.ttf")).unwrap(); + std::fs::write(dir.join("empty.otf"), b"").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let scan_dir = dir.clone(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(vec![scan_dir]); + let _ = tx.send(db.load_system_fonts()); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); + assert!(n >= 1, "the real font was found"); + assert!(fifo || n >= 1); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..c7290b35 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -41,7 +41,8 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32); let margin = 28.0; let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05); - let scale = app.ui.zoom.unwrap_or(fit); + // A zoom from outside the editor's own controls (settings, the control protocol) is kept in range. + let scale = app.ui.zoom.filter(|z| z.is_finite() && *z > 0.0).map_or(fit, |z| z.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); let size = vec2(sw * scale, sh * scale); let mut r = Rect::from_center_size(avail.center(), size); if size.x > avail.width() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d912..704de448 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -229,7 +229,11 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> }; } if let Some(z) = p.get("zoom") { - app.ui.zoom = z.as_f64().map(|v| v as f32); + app.ui.zoom = z + .as_f64() + .map(|v| v as f32) + .filter(|v| v.is_finite() && *v > 0.0) + .map(|v| v.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); } if let Some(d) = p.get("dark").and_then(Value::as_bool) { let _ = app.run("view.dark", json!({"on": d})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..b40daf7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod menus; pub mod panes; pub mod ribbon; pub mod selpane; +pub mod settings; pub mod show; pub mod sorter; pub mod status; @@ -131,6 +132,41 @@ impl Default for UiState { } } +/// The editor's zoom range (1 = 100 %). +pub const ZOOM_RANGE: std::ops::RangeInclusive = 0.1..=4.0; + +impl UiState { + /// The state with every value usable: `ui.json` is a file on disk, and a damaged or + /// hand-edited one must not stop the window from opening. A zoom of 10,000 % asked the + /// renderer for a texture larger than memory at start-up; panel sizes, scales and names are + /// brought into range the same way, and the recent list is cut to its usual length. + pub fn sanitized(mut self) -> UiState { + let d = UiState::default(); + let within = |v: f32, lo: f32, hi: f32, default: f32| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.zoom = self.zoom.filter(|z| z.is_finite() && *z > 0.0).map(|z| z.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end())); + self.sorter_zoom = within(self.sorter_zoom, 0.5, 3.0, d.sorter_zoom); + self.ui_scale = within(self.ui_scale, 0.5, 3.0, d.ui_scale); + self.notes_height = within(self.notes_height, 0.0, 2000.0, d.notes_height); + self.thumbs_width = within(self.thumbs_width, 60.0, 1000.0, d.thumbs_width); + self.pane_width = within(self.pane_width, 240.0, 520.0, d.pane_width); + let short = |s: &String| s.len() <= 64; + if !short(&self.tab) { + self.tab = d.tab; + } + if !short(&self.format_tab) { + self.format_tab = d.format_tab; + } + self.pane = self.pane.filter(short); + self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); + self.recent.dedup(); + self.recent.truncate(RECENT_MAX); + self + } +} + +/// How many recent files File › Open and Home list. +pub const RECENT_MAX: usize = 12; + /// UI-only commands (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.normal", "Normal", None, "{}"), @@ -575,7 +611,7 @@ impl SlideApp { self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(12); + self.ui.recent.truncate(RECENT_MAX); self.ui.view = ViewMode::Normal; Ok(()) } diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs new file mode 100644 index 00000000..9a1b8b62 --- /dev/null +++ b/crates/ui-egui/src/settings.rs @@ -0,0 +1,170 @@ +//! The desktop app's settings files, `ui.json` ([`UiState`]) and `prefs.json` +//! ([`deckcraft_engine::Prefs`]), in the per-user settings folder. They are files on disk that a +//! crash, a full disk or a hand edit can leave damaged, so reading never fails and never trusts +//! them: an unreadable, oversized or malformed file gives the defaults, and the values of a +//! well-formed one are brought into range ([`UiState::sanitized`], `Prefs::sanitized`). Writing +//! goes through a temporary file and a rename, so a crash mid-write leaves the old file. + +use std::path::Path; + +use deckcraft_engine::Prefs; + +use crate::UiState; + +pub const UI_FILE: &str = "ui.json"; +pub const PREFS_FILE: &str = "prefs.json"; +/// Larger settings files are not settings. +pub const MAX_BYTES: u64 = 4 << 20; + +/// The JSON in `path`, when it is a regular file of a sane size that holds a `T`. +fn read(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_BYTES { + log::warn!("{}: not a settings file; using the defaults", path.display()); + return None; + } + let bytes = std::fs::read(path).ok()?; + match serde_json::from_slice(&bytes) { + Ok(v) => Some(v), + Err(e) => { + log::warn!("{}: {e}; using the defaults", path.display()); + None + } + } +} + +/// The saved UI state and preferences in `dir` (each `None` when missing or unusable). +pub fn load(dir: &Path) -> (Option, Option) { + (read::(&dir.join(UI_FILE)).map(UiState::sanitized), read::(&dir.join(PREFS_FILE)).map(Prefs::sanitized)) +} + +/// Write `bytes` to `path` through a temporary file beside it. +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) +} + +/// Save both files into `dir` (created when missing). Failures are logged, not fatal. +pub fn save(dir: &Path, ui: &UiState, prefs: &Prefs) { + if let Err(e) = std::fs::create_dir_all(dir) { + log::warn!("{}: {e}; settings not saved", dir.display()); + return; + } + for (name, json) in [(UI_FILE, serde_json::to_vec_pretty(ui)), (PREFS_FILE, serde_json::to_vec_pretty(prefs))] { + let path = dir.join(name); + if let Err(e) = json.map_err(std::io::Error::other).and_then(|b| write_atomic(&path, &b)) { + log::warn!("{}: {e}", path.display()); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("deckcraft-settings-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn damaged_settings_files_give_the_defaults() { + let d = dir("damaged"); + let cases: [&[u8]; 6] = [b"", b"{", b"\xFF\xFE garbage", b"[1, 2, 3]", b"{\"zoom\": \"big\"}", b"null"]; + for bytes in cases { + std::fs::write(d.join(UI_FILE), bytes).unwrap(); + std::fs::write(d.join(PREFS_FILE), bytes).unwrap(); + let (ui, prefs) = load(&d); + // Prefs has no `zoom`, so that case is a valid (default) Prefs. + assert!(ui.is_none(), "{:?}", String::from_utf8_lossy(bytes)); + assert!(prefs.is_none() || bytes.starts_with(b"{\"zoom"), "{:?}", String::from_utf8_lossy(bytes)); + } + // A folder where a file should be, and an oversized file. + std::fs::remove_file(d.join(UI_FILE)).unwrap(); + std::fs::create_dir(d.join(UI_FILE)).unwrap(); + std::fs::write(d.join(PREFS_FILE), vec![b' '; MAX_BYTES as usize + 1]).unwrap(); + assert!(matches!(load(&d), (None, None))); + // Nothing there at all. + assert!(matches!(load(&d.join("missing")), (None, None))); + } + + #[test] + fn out_of_range_values_are_brought_into_range() { + let d = dir("ranges"); + let recent: Vec = (0..5000).map(|i| format!("/tmp/deck{i}.pptx")).collect(); + let ui = serde_json::json!({ + "zoom": 1.0e30, "sorterZoom": -4.0, "uiScale": 1.0e9, "notesHeight": 1.0e30, + "thumbsWidth": -1.0e30, "paneWidth": 3.4e38, "tab": "x".repeat(10_000), "pane": "y".repeat(500), + "recent": recent, + }); + let prefs = serde_json::json!({"gridSpacing": 0.0, "duplicateOffset": 1.0e300, "undoLimit": u64::MAX, "recoveryMinutes": -5.0, "author": "a".repeat(100_000)}); + std::fs::write(d.join(UI_FILE), ui.to_string()).unwrap(); + std::fs::write(d.join(PREFS_FILE), prefs.to_string()).unwrap(); + let (ui, prefs) = load(&d); + let (ui, prefs) = (ui.unwrap(), prefs.unwrap()); + assert_eq!(ui.zoom, Some(*crate::ZOOM_RANGE.end())); + assert_eq!((ui.sorter_zoom, ui.ui_scale), (0.5, 3.0)); + assert!(ui.notes_height <= 2000.0 && ui.thumbs_width >= 60.0 && ui.pane_width <= 520.0); + assert_eq!((ui.tab.as_str(), ui.pane.as_deref()), ("Home", None)); + assert_eq!(ui.recent.len(), crate::RECENT_MAX); + assert_eq!(prefs.grid_spacing, Prefs::default().grid_spacing); + assert_eq!(prefs.duplicate_offset, 1000.0); + assert_eq!(prefs.undo_limit, deckcraft_engine::MAX_UNDO_LIMIT); + assert_eq!(prefs.recovery_minutes, 0.1); + assert_eq!(prefs.author.chars().count(), 256); + // A zero or negative zoom means "fit". + std::fs::write(d.join(UI_FILE), r#"{"zoom": 0}"#).unwrap(); + assert_eq!(load(&d).0.unwrap().zoom, None); + } + + #[test] + fn saving_round_trips_and_leaves_no_temporary_file() { + let d = dir("save"); + let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() }; + let prefs = Prefs { author: "Ann".into(), ..Default::default() }; + save(&d.join("nested"), &ui, &prefs); + let (u, p) = load(&d.join("nested")); + assert_eq!(u.unwrap().zoom, Some(1.5)); + assert_eq!(p.unwrap().author, "Ann"); + let names: Vec = + std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}"); + // A settings folder that is a file: nothing saved, no panic. + std::fs::write(d.join("blocked"), "file").unwrap(); + save(&d.join("blocked"), &ui, &prefs); + } + + /// The window opens with the worst `ui.json` values once they are sanitized; a zoom of + /// 10,000 % used to panic in egui and 1e30 exhausted memory at start-up. + #[test] + fn the_window_opens_with_hostile_saved_state() { + let hostile = UiState { + zoom: Some(1.0e30), + notes_height: 1.0e30, + thumbs_width: -1.0e30, + pane: Some("format".into()), + pane_width: 1.0e30, + ..Default::default() + }; + for ui in [hostile.clone().sanitized(), UiState { zoom: Some(1.0e4), ..Default::default() }] { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default()); + // The second case is not sanitized: the canvas keeps a stray zoom in range itself. + app.ui = ui; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut crate::SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.state().canvas_scale <= *crate::ZOOM_RANGE.end() + 1e-3, "{}", h.state().canvas_scale); + } + } +} diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 8e915b71..11efe3f7 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -39,7 +39,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() { app.ui.zoom = None; } - let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round(); + let pct = (app.canvas_scale * 100.0).round(); let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click())); if lbl.clicked() { app.dialog = Some(crate::dialogs::Dialog::new("zoom")); diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..b28f227f 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -116,7 +116,9 @@ impl Textures { Some((i, d)) if i == index => estimate.union(d), _ => estimate, }; - let want = deckcraft_render::pasteboard_region(size, scale, reach, visible); + // The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui. + let max_side = ctx.input(|i| i.max_texture_side).max(1) as u32; + let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side); let key = Key { slide: Arc::as_ptr(&slide) as usize, master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), @@ -142,7 +144,8 @@ impl Textures { // Text spilling out of its box can reach further than the boxes said. Once drawn, the // region it calls for is known (and is next frame's key): render again only if it shows // more than this render covered. - let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key }; + let key = + Key { want: fit_texture(deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), visible, max_side), ..key }; if !region.contains(&key.want) { region = key.want; img = render(region).0; @@ -213,3 +216,33 @@ impl Textures { Some(tex) } } + +/// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits. +pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region { + fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) { + if *len > max { + let hi = *start as i64 + (*len - max) as i64; + *start = (vis as i64).clamp(*start as i64, hi) as i32; + *len = max; + } + } + axis(&mut r.x, &mut r.w, visible.x, max); + axis(&mut r.y, &mut r.h, visible.y, max); + r +} + +#[cfg(test)] +mod fit_tests { + use super::*; + + #[test] + fn canvas_textures_stay_within_the_texture_limit() { + let r = Region { x: -100, y: -50, w: 5000, h: 1000 }; + let f = fit_texture(r, Region { x: 300, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.y, f.w, f.h), (300, -50, 2048, 1000)); + // Visible past the far end: the cut is pinned there. + let f = fit_texture(r, Region { x: 4800, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048)); + assert_eq!(fit_texture(r, r, 8192), r); + } +} From ba71a40a39f4d1501251670fec431f8aee202a0d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:48:39 +0300 Subject: [PATCH 43/69] Canvas: a trackpad swipe turns the page A sideways flick changes slides when the slide fits the window, and any flick does so during the show. One gesture turns one page. A zoomed slide still pans. Pinch-zoom is unchanged. Co-authored-by: Cursor --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 25 ++++++-- crates/ui-egui/src/lib.rs | 110 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/show.rs | 21 +++++++ docs/gaps.md | 6 +- docs/hardware-parity.md | 6 +- docs/slideshow-parity.md | 3 +- 7 files changed, 162 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..26ad6c97 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged | | 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 | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..5aa2ee51 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,6 +53,17 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } +/// A trackpad flick or mouse-wheel notch that should turn the page. +/// +/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative +/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — +/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. +pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { + const MIN_FLICK: f32 = 18.0; + let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; + (along.abs() > MIN_FLICK).then_some(along < 0.0) +} + pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -80,15 +91,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let _ = pinch_pos; } else if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - // Wheel over a fitted slide pages through slides (like PowerPoint). - let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" }; + } else if app.session.tool.marquee.is_none() + && !app.session.tool.dragging() + && let Some(next) = page_flick(wheel) + { + // A fitted slide: the mouse wheel and a trackpad swipe turn the page. Pinch zoom is + // the branch above and is left alone. let gate = ui.id().with("wheel_gate"); - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0)); + // No previous flick yet: the first one must turn the page, not wait out the gap. + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); let now = ui.input(|i| i.time); if now - last > 0.35 { ui.data_mut(|d| d.insert_temp(gate, now)); - let _ = app.run(id, json!({})); + let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..94a05f4b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1408,4 +1408,114 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. + harness.state_mut().ui.zoom = Some(0.4); + harness + } + + fn slide_at(app: &SlideApp) -> usize { + app.session.active().unwrap().selection.slide + } + + /// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a + /// mouse notch. The harness runs one frame per queued event, so they have to share a frame or + /// none of them reaches the page threshold. + fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { + let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + let step = delta / 4.0; + for _ in 0..4 { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: step, + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); + } + + #[test] + fn page_flick_uses_the_stronger_axis() { + assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); + assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); + } + + /// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. + /// The frames of one gesture do not skip ahead. + #[test] + fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + assert_eq!(slide_at(harness.state()), 0); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); + + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); + } + + #[test] + fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-2.0, -24.0)); + assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); + + harness.state_mut().ui.zoom = Some(3.0); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); + } + + #[test] + fn a_swipe_during_the_show_turns_the_page() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } + + /// The pen is drawing. A flick must not turn the page out from under the stroke. + #[test] + fn the_pen_ignores_a_swipe_during_the_show() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..3d29387f 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -35,6 +35,8 @@ pub struct Show { show_start: f64, timings: Vec<(usize, f64)>, last_move: f64, + /// The last trackpad flick or mouse-wheel notch, so one gesture turns one page. + last_swipe: f64, pub ended: bool, /// The slide whose media was last started (auto-play runs once per visit). media_slide: Option, @@ -95,6 +97,7 @@ impl Show { show_start: 0.0, timings: vec![], last_move: 0.0, + last_swipe: f64::NEG_INFINITY, ended: false, media_slide: None, last_viewed: None, @@ -342,6 +345,24 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if secondary && !show.ended { actions.push(show.state.prev(&doc)); } + // A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick + // there stays on the slide. Pinch zoom is not a show gesture. + if !show.pen + && let Some(next) = crate::canvas::page_flick(ctx.input(|i| i.smooth_scroll_delta)) + && now - show.last_swipe > 0.35 + { + show.last_swipe = now; + if next { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(&doc)); + } + } else { + show.ended = false; + actions.push(show.state.prev(&doc)); + } + } // Apply input first: a step started by this click must restart the clock before it is read. for a in actions.drain(..) { if !show.apply(a, &doc, now) { diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..459490e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page in the editor and the show; pinch-zoom remains; previous: minor, 3-D bevel, camera and face lighting rendered) · **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 @@ -217,7 +217,8 @@ Detail in [hardware-parity.md](hardware-parity.md). or wgpu path). - **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy FilmCraft's hardware decode). -- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h. +- **No pen pressure or tilt for ink**; no pinch-zoom. A trackpad swipe turns the page when the + slide fits the window, and during the show. 2–4 h for pinch-zoom. ## Localization @@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A trackpad swipe (and the mouse wheel, in the show) turns the page. Pinch-zoom remains | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..21e8d401 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page; pinch-zoom remains; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -43,7 +43,8 @@ because it mostly follows from hardware use. |---|---|---| | Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes | | Mouse and trackpad scroll | yes | yes | -| Pinch to zoom, two-finger gestures | yes | no | +| Pinch to zoom | yes | no | +| Two-finger swipe between slides | yes | yes: when the slide fits the window, and during the show. A zoomed slide still pans | | Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | @@ -71,5 +72,6 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A sideways trackpad swipe turns the page in Normal view (when the slide fits) and in the show. Pinch-zoom is unchanged | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fd981595..47e2ff2a 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe or mouse wheel turns the page during the show; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -14,6 +14,7 @@ ready** (estimated), **10–16 h** to parity. |---|---|---|---| | Play from Start / from Current Slide (F5, Shift+F5, Cmd+Enter) | yes | yes | | | Next/previous by click, arrows, Space, Page Up/Down, Enter, Backspace | yes | yes | #17 reported arrows not working in v0.3.0 AppImage; recheck | +| Next/previous by trackpad swipe or mouse wheel | yes | yes | One flick turns one page. The pen ignores the flick | | Go to slide (number + Enter), first/last | yes | yes | | | Black / white screen (B / W) | yes | yes | | | End screen, Esc to leave | yes | yes | | From 05eb4c611b737a01318f3bb998d87de66ed5bac2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:57:32 +0300 Subject: [PATCH 44/69] UI: keep every texture within the GPU's max texture side Slide show, transition and morph frames, thumbnails, master-view canvas, master/layout thumbnails, Design Ideas previews, presenter-view preview and video frames all upload through textures::upload/replace, which shrinks a picture past max_texture_side (egui panicked on one). Render sizes are fitted to the limit first, so a big screen, extreme DPI or a 1:56 slide doesn't render a picture the GPU can't hold. The master canvas is cropped like the slide canvas. Co-authored-by: Cursor --- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/media.rs | 4 +- crates/ui-egui/src/panes.rs | 8 +- crates/ui-egui/src/ribbon.rs | 3 +- crates/ui-egui/src/show.rs | 32 ++++---- crates/ui-egui/src/texture_limit_tests.rs | 53 +++++++++++++ crates/ui-egui/src/textures.rs | 95 +++++++++++++++++++++-- crates/ui-egui/src/thumbs.rs | 12 +-- 9 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 crates/ui-egui/src/texture_limit_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c7290b35..14cbe491 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -314,14 +314,18 @@ fn master_tex( visible: deckcraft_render::Region, ) -> Option<(egui::TextureHandle, deckcraft_render::Region)> { let scale = px.0 as f64 / doc.slide_size.width.max(1.0); - let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible); + let region = crate::textures::fit_texture( + deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible), + visible, + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_layout( doc, master, layout, &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region)) + Some((crate::textures::upload(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) } fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b40daf7b..5e53cb40 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1165,6 +1165,8 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod texture_limit_tests; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index f887c2b2..fd018aea 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -209,8 +209,8 @@ impl MediaHost { { let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba); match v.tex.as_mut() { - Some(t) => t.set(img, egui::TextureOptions::LINEAR), - None => v.tex = Some(ctx.load_texture(format!("media-{id}"), img, egui::TextureOptions::LINEAR)), + Some(t) => crate::textures::replace(ctx, t, img), + None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)), } v.frame = Some(f); } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..f8aa0525 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -613,14 +613,16 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { m.theme = th.clone(); } let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp) as u32; - let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; + let (px, h) = crate::textures::fit_size( + (((w * ppp) as u32).max(1), (((w * ppp) as f64) * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); let img = deckcraft_render::render_slide( &d, idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ui.ctx(), format!("idea-{}", th.name), crate::textures::to_color_image(&img, false)); let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click()); ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke( diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..f2e06424 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -339,8 +339,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..a0dfcc30 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -233,7 +233,7 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; let tex = |i: usize, part: SlidePart, name: &str| { let img = deckcraft_render::render_slide_part(doc, i, part, &opts); - ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR) + crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false)) }; [ from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), @@ -383,7 +383,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; let srect = fit(avail, &doc); let ppp = ctx.pixels_per_point(); - let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32); + // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit + // (drawn stretched to the screen). + let size = crate::textures::fit_size( + ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), + crate::textures::max_side(&ctx), + ); if show.ended { painter.text( pos2(full.center().x, full.min.y + 40.0), @@ -439,10 +444,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ci = crate::textures::to_color_image(&img, false); let tex = match show.tex.take() { Some((_, mut h)) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "show", ci), }; show.tex = Some((key, tex.clone())); Some(tex) @@ -484,10 +489,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ci = crate::textures::to_color_image(&img, false); let frame = match tr.morph.take() { Some(mut h) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "morph", ci), }; painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); tr.morph = Some(frame); @@ -775,16 +780,17 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if let Some(n) = next && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) { + // A very tall slide shape would otherwise ask for a picture taller than the GPU's limit. + let next_size = crate::textures::fit_size( + (480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_slide( doc, n, - &RenderOpts { - scale: 480.0 / doc.slide_size.width.max(1.0), - size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), - ..Default::default() - }, + &RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() }, ); - show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))); + show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); } let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default(); @@ -876,7 +882,7 @@ mod tests { s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap(); let doc = s.active().unwrap().doc.clone(); let ctx = egui::Context::default(); - let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR); + let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED)); let mut show = Show::new(&doc, 0, false, false); show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone())); assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); diff --git a/crates/ui-egui/src/texture_limit_tests.rs b/crates/ui-egui/src/texture_limit_tests.rs new file mode 100644 index 00000000..bf5c648d --- /dev/null +++ b/crates/ui-egui/src/texture_limit_tests.rs @@ -0,0 +1,53 @@ +//! Textures stay within the renderer's `max_texture_side` (2048 in the test harness, as on many +//! GPUs and software fallbacks): bigger ones panicked in egui. Slide show on a big screen, +//! thumbnails, master views and presenter view of a very tall slide. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; + +fn harness(session: Session, size: egui::Vec2) -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ) +} + +/// The tallest slide allowed (1 × 56): its thumbnails and presenter-view preview are tens of +/// thousands of pixels high at their usual widths. +fn tall_deck() -> Session { + let mut s = Session::with_new(); + s.execute("design.slideSize", &json!({"w": 72, "h": 4032})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s +} + +#[test] +fn a_slide_show_on_a_screen_wider_than_the_texture_limit() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let mut h = harness(s, egui::vec2(2600.0, 1500.0)); + h.run_steps(2); + h.state_mut().run("show.start", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} + +#[test] +fn a_very_tall_slide_in_the_editor_views_and_presenter_view() { + let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); + h.run_steps(3); + for (cmd, p) in [("view.sorter", json!({})), ("view.normal", json!({})), ("view.slideMaster", json!({})), ("view.closeMaster", json!({}))] { + let _ = h.state_mut().run(cmd, p); + h.run_steps(3); + } + h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + h.state_mut().run("show.start", json!({"from": 0})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index b28f227f..df19e232 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -67,6 +67,66 @@ pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { ColorImage::new([w, h], px) } +/// The largest texture side the renderer (GPU or software fallback) takes; egui panics on +/// a bigger texture. +pub fn max_side(ctx: &egui::Context) -> u32 { + ctx.input(|i| i.max_texture_side).clamp(1, u32::MAX as usize) as u32 +} + +/// `(w, h)` scaled down, keeping its shape, so neither side exceeds `max` (and none is 0). +/// Render sizes go through this so a huge display, DPI or slide shape doesn't render a +/// picture the GPU can't hold. +pub fn fit_size((w, h): (u32, u32), max: u32) -> (u32, u32) { + let (w, h, max) = (w.max(1), h.max(1), max.max(1)); + let big = w.max(h); + if big <= max { + return (w, h); + } + let k = max as f64 / big as f64; + (((w as f64 * k).floor() as u32).clamp(1, max), ((h as f64 * k).floor() as u32).clamp(1, max)) +} + +/// `img` shrunk by whole-pixel box averaging until both sides fit `max`. +pub fn shrink_to(img: ColorImage, max: u32) -> ColorImage { + let [w, h] = img.size; + let max = max.max(1) as usize; + if w <= max && h <= max { + return img; + } + let f = w.max(h).div_ceil(max); + let (nw, nh) = (w.div_ceil(f).max(1), h.div_ceil(f).max(1)); + let mut px = Vec::with_capacity(nw * nh); + for y in 0..nh { + for x in 0..nw { + let mut acc = [0u32; 4]; + let mut n = 0u32; + for sy in y * f..((y + 1) * f).min(h) { + for sx in x * f..((x + 1) * f).min(w) { + if let Some(c) = img.pixels.get(sy * w + sx) { + for (a, v) in acc.iter_mut().zip(c.to_array()) { + *a += v as u32; + } + n += 1; + } + } + } + let n = n.max(1); + px.push(egui::Color32::from_rgba_premultiplied((acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8, (acc[3] / n) as u8)); + } + } + ColorImage::new([nw, nh], px) +} + +/// Upload a texture, shrunk to the renderer's limit if need be. +pub fn upload(ctx: &egui::Context, name: impl Into, img: ColorImage) -> TextureHandle { + ctx.load_texture(name, shrink_to(img, max_side(ctx)), TextureOptions::LINEAR) +} + +/// Replace a texture's picture, shrunk to the renderer's limit if need be. +pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) { + tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR); +} + fn master_of(p: &Presentation, slide: &Slide) -> Option> { p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned() } @@ -117,7 +177,7 @@ impl Textures { _ => estimate, }; // The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui. - let max_side = ctx.input(|i| i.max_texture_side).max(1) as u32; + let max_side = max_side(ctx); let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side); let key = Key { slide: Arc::as_ptr(&slide) as usize, @@ -156,10 +216,10 @@ impl Textures { let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { Some(mut e) => { - e.tex.set(ci, TextureOptions::LINEAR); + replace(ctx, &mut e.tex, ci); e.tex } - None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), + None => upload(ctx, "slide-canvas", ci), }; self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 }); Some((tex, region)) @@ -200,9 +260,10 @@ impl Textures { return self.thumbs.get(&key.slide).map(|e| e.tex.clone()); } *budget -= 1; - let scale = width_px as f64 / p.slide_size.width.max(1.0); - let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); - let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); + let (tw, th) = fit_size((width_px, h), max_side(ctx)); + let scale = tw as f64 / p.slide_size.width.max(1.0); + let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() }); + let tex = upload(ctx, format!("thumb-{index}"), to_color_image(&img, false)); // Drop the old entry for this slide address; entries for slides that no longer exist age out. let region = key.want; self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock }); @@ -245,4 +306,26 @@ mod fit_tests { assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048)); assert_eq!(fit_texture(r, r, 8192), r); } + + #[test] + fn render_sizes_and_pictures_shrink_to_the_limit_keeping_their_shape() { + assert_eq!(fit_size((195, 10920), 2048), (36, 2048)); + assert_eq!(fit_size((3000, 1500), 2048), (2048, 1024)); + assert_eq!(fit_size((800, 600), 2048), (800, 600)); + assert_eq!(fit_size((0, 0), 0), (1, 1)); + // A 5000 × 3 picture: half red, half blue, averaged down to 1667 × 1. + let img = ColorImage::new( + [5000, 3], + (0..3) + .flat_map(|y| (0..5000).map(move |x| (x, y))) + .map(|(x, _)| if x < 2500 { egui::Color32::RED } else { egui::Color32::BLUE }) + .collect(), + ); + let small = shrink_to(img, 2048); + assert_eq!(small.size, [1667, 1]); + assert_eq!(small.pixels[0], egui::Color32::RED); + assert_eq!(small.pixels[1666], egui::Color32::BLUE); + let ok = ColorImage::filled([10, 10], egui::Color32::WHITE); + assert_eq!(shrink_to(ok.clone(), 2048).size, ok.size); + } } diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index ebdc81d0..07ac2dae 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -270,20 +270,22 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click()); let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)); let ppp = ui.ctx().pixels_per_point(); - let px = ((tw * ppp) as u32).max(1); + let px = crate::textures::fit_size( + (((tw * ppp) as u32).max(1), ((tw * ppp) as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); let pic = deckcraft_render::render_layout( &doc, mi, li, &deckcraft_render::RenderOpts { - scale: px as f64 / doc.slide_size.width.max(1.0), + scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, - size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), + size: Some(px), ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false)); ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let selected = match (target, li) { (Target::Master { master }, None) => master == mi, From d31ed52bbe12f84cd3029bea3815ed6808d70ca0 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:04:09 +0300 Subject: [PATCH 45/69] Show: presenter view keeps a portrait Next slide inside its column The Next preview took the column's full width and the slide's aspect for its height, so a portrait slide (up to 1:56) drew a white slab many times the window's height over the notes, which were pushed below the window. It is now fitted into the top 60% of the column; 16:9 slides keep the full width as before. Co-authored-by: Cursor --- crates/ui-egui/src/show.rs | 30 ++++++++++++++++++++--- crates/ui-egui/src/texture_limit_tests.rs | 16 ++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index a0dfcc30..01188544 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -208,6 +208,14 @@ fn fit(avail: Rect, doc: &Presentation) -> Rect { /// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective /// cards subdivided so their pictures stay straight) with per-vertex opacity. +/// Where presenter view's "Next" preview goes: the column's width below its label, kept inside +/// the top 60% of the column. A portrait slide used to get the full width and a height many +/// times the window (a white slab over the notes and past the window edge). +fn next_rect(right: Rect, doc: &Presentation) -> Rect { + let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.min.y + 22.0 + (right.height() - 22.0).max(0.0) * 0.6)); + Rect::from_min_size(area.min, fit(area, doc).size()) +} + fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { let mut m = match tex { Some(id) => Mesh::with_texture(id), @@ -835,10 +843,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if let Some(t) = &cur_tex { ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } - let nr = Rect::from_min_size( - right.min + vec2(0.0, 22.0), - vec2(right.width(), right.width() * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32), - ); + let nr = next_rect(right, doc); ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170)); match &next_tex { Some(t) => { @@ -891,4 +896,21 @@ mod tests { assert_eq!(tr.from, Some(0)); assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one"); } + + #[test] + fn the_next_preview_fits_its_column_whatever_the_slide_shape() { + let right = Rect::from_min_size(pos2(700.0, 100.0), vec2(400.0, 600.0)); + let mut s = deckcraft_engine::Session::with_new(); + for (w, h) in [(72, 4032), (4032, 72), (960, 540)] { + s.execute("design.slideSize", &json!({"w": w, "h": h})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let r = next_rect(right, &doc); + assert!(right.contains_rect(r), "{w}x{h}: {r:?}"); + let aspect = r.height() / r.width(); + assert!((aspect - h as f32 / w as f32).abs() / (h as f32 / w as f32) < 0.01, "{w}x{h}: {r:?}"); + } + // 16:9 still takes the column's full width. + let doc = s.active().unwrap().doc.clone(); + assert_eq!(next_rect(right, &doc).width(), 400.0); + } } diff --git a/crates/ui-egui/src/texture_limit_tests.rs b/crates/ui-egui/src/texture_limit_tests.rs index bf5c648d..69dbf523 100644 --- a/crates/ui-egui/src/texture_limit_tests.rs +++ b/crates/ui-egui/src/texture_limit_tests.rs @@ -51,3 +51,19 @@ fn a_very_tall_slide_in_the_editor_views_and_presenter_view() { h.run_steps(4); assert!(h.state().show.is_some()); } + +/// Presenter view of a portrait slide: the "Next" preview keeps the slide's shape inside its +/// column. It took the column's full width and 56 times that in height, a white slab over the +/// notes and past the window. +#[test] +fn presenter_view_keeps_a_portrait_next_slide_in_its_column() { + let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); + h.run_steps(3); + h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + h.state_mut().run("show.start", json!({"from": 0})).unwrap(); + h.run_steps(4); + let img = h.render().unwrap(); + // Right of the Next column's narrow preview, below where a landscape one would end. + let px = img.get_pixel(1000, 600).0; + assert!(px[0] < 128 && px[1] < 128 && px[2] < 128, "the Next preview spills over the column: {px:?}"); +} From 8fde746211adad6ec3adc66789a93188fff08613 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:45:32 +0300 Subject: [PATCH 46/69] Format: never save a .deckcraft that can't be reopened over a NaN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serde_json writes NaN and infinity as null, which doesn't read back as a number, so one NaN anywhere in the model (a rotation, position, crop, stroke width…) made the saved file (and its AutoRecover copy) fail to open as "damaged". - Writer: presentation.json goes through finite::Finite, a serializer adapter that writes non-finite floats as 0. - Loader: files already written that way open; each null where a number belongs reads as 0 (inside tagged enums, where serde only reports the enclosing object, candidates are tried nearest first and kept only when the parse gets further). Other damage still fails. - repair() brings shape geometry into range: positions and sizes finite and within ±1e6 pt, rotation 0–360, picture crop -10..1, font sizes 1–4000 pt, line widths 0–1584 pt. Animation timings are u32 and can't carry NaN. Co-authored-by: Cursor --- crates/format/src/finite.rs | 185 +++++++++++++++++++++++++++ crates/format/src/lib.rs | 247 +++++++++++++++++++++++++++++++++++- 2 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 crates/format/src/finite.rs diff --git a/crates/format/src/finite.rs b/crates/format/src/finite.rs new file mode 100644 index 00000000..834108a3 --- /dev/null +++ b/crates/format/src/finite.rs @@ -0,0 +1,185 @@ +//! A serde serializer adapter that writes non-finite floats as 0. +//! +//! serde_json writes NaN and ±infinity as `null`, which doesn't read back as a number: a single +//! NaN anywhere in the model (a rotation, a crop, a stroke width) made a saved file fail to open +//! as "damaged". [`Finite`] wraps any `Serialize` value and replaces those floats on the way out; +//! everything else passes through unchanged. + +use serde::ser::{self, Serialize, Serializer}; + +/// `T`, serialized with non-finite floats written as 0. +pub struct Finite<'a, T: ?Sized>(pub &'a T); + +impl Serialize for Finite<'_, T> { + fn serialize(&self, s: S) -> Result { + self.0.serialize(Wrap(s)) + } +} + +struct Wrap(S); + +macro_rules! pass { + ($($m:ident($t:ty)),* $(,)?) => { + $(fn $m(self, v: $t) -> Result { self.0.$m(v) })* + }; +} + +impl Serializer for Wrap { + type Ok = S::Ok; + type Error = S::Error; + type SerializeSeq = Wrap; + type SerializeTuple = Wrap; + type SerializeTupleStruct = Wrap; + type SerializeTupleVariant = Wrap; + type SerializeMap = Wrap; + type SerializeStruct = Wrap; + type SerializeStructVariant = Wrap; + + pass!( + serialize_bool(bool), + serialize_i8(i8), + serialize_i16(i16), + serialize_i32(i32), + serialize_i64(i64), + serialize_i128(i128), + serialize_u8(u8), + serialize_u16(u16), + serialize_u32(u32), + serialize_u64(u64), + serialize_u128(u128), + serialize_char(char), + serialize_str(&str), + serialize_bytes(&[u8]), + ); + + fn serialize_f32(self, v: f32) -> Result { + self.0.serialize_f32(if v.is_finite() { v } else { 0.0 }) + } + fn serialize_f64(self, v: f64) -> Result { + self.0.serialize_f64(if v.is_finite() { v } else { 0.0 }) + } + fn serialize_none(self) -> Result { + self.0.serialize_none() + } + fn serialize_some(self, v: &T) -> Result { + self.0.serialize_some(&Finite(v)) + } + fn serialize_unit(self) -> Result { + self.0.serialize_unit() + } + fn serialize_unit_struct(self, name: &'static str) -> Result { + self.0.serialize_unit_struct(name) + } + fn serialize_unit_variant(self, name: &'static str, i: u32, variant: &'static str) -> Result { + self.0.serialize_unit_variant(name, i, variant) + } + fn serialize_newtype_struct(self, name: &'static str, v: &T) -> Result { + self.0.serialize_newtype_struct(name, &Finite(v)) + } + fn serialize_newtype_variant(self, name: &'static str, i: u32, variant: &'static str, v: &T) -> Result { + self.0.serialize_newtype_variant(name, i, variant, &Finite(v)) + } + fn serialize_seq(self, len: Option) -> Result { + self.0.serialize_seq(len).map(Wrap) + } + fn serialize_tuple(self, len: usize) -> Result { + self.0.serialize_tuple(len).map(Wrap) + } + fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_tuple_struct(name, len).map(Wrap) + } + fn serialize_tuple_variant(self, name: &'static str, i: u32, variant: &'static str, len: usize) -> Result { + self.0.serialize_tuple_variant(name, i, variant, len).map(Wrap) + } + fn serialize_map(self, len: Option) -> Result { + self.0.serialize_map(len).map(Wrap) + } + fn serialize_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_struct(name, len).map(Wrap) + } + fn serialize_struct_variant( + self, + name: &'static str, + i: u32, + variant: &'static str, + len: usize, + ) -> Result { + self.0.serialize_struct_variant(name, i, variant, len).map(Wrap) + } + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() + } +} + +macro_rules! compound { + ($tr:ident, $m:ident) => { + impl ser::$tr for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn $m(&mut self, v: &T) -> Result<(), S::Error> { + self.0.$m(&Finite(v)) + } + fn end(self) -> Result { + self.0.end() + } + } + }; +} +compound!(SerializeSeq, serialize_element); +compound!(SerializeTuple, serialize_element); +compound!(SerializeTupleStruct, serialize_field); +compound!(SerializeTupleVariant, serialize_field); + +impl ser::SerializeMap for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_key(&mut self, k: &T) -> Result<(), S::Error> { + self.0.serialize_key(&Finite(k)) + } + fn serialize_value(&mut self, v: &T) -> Result<(), S::Error> { + self.0.serialize_value(&Finite(v)) + } + fn end(self) -> Result { + self.0.end() + } +} + +macro_rules! fields { + ($tr:ident) => { + impl ser::$tr for Wrap { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_field(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> { + self.0.serialize_field(key, &Finite(v)) + } + fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> { + self.0.skip_field(key) + } + fn end(self) -> Result { + self.0.end() + } + } + }; +} +fields!(SerializeStruct); +fields!(SerializeStructVariant); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_floats_are_written_as_zero_and_nothing_else_changes() { + #[derive(serde::Serialize)] + struct T { + a: f64, + b: Option, + c: Vec<(f32, f64)>, + d: Option, + e: std::collections::BTreeMap, + } + let t = T { a: f64::NAN, b: Some(f64::INFINITY), c: vec![(f32::NEG_INFINITY, 1.5)], d: None, e: [("k".to_string(), f64::NAN)].into() }; + let s = serde_json::to_string(&Finite(&t)).unwrap_or_default(); + assert_eq!(s, r#"{"a":0.0,"b":0.0,"c":[[0.0,1.5]],"d":null,"e":{"k":0.0}}"#); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1b37a232..9af576f0 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -4,6 +4,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod finite; + use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -55,7 +57,7 @@ pub fn save(p: &Presentation) -> Result> { generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation: p.clone(), }; - let json = serde_json::to_vec(&m).map_err(|e| FormatError::Write(e.to_string()))?; + let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?; z.start_file("presentation.json", deflate).map_err(w)?; z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?; for item in &p.media { @@ -100,7 +102,7 @@ fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; - let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?; + let m = parse_manifest(&json)?; if m.format != "deckcraft" { return Err(FormatError::NotOurs(m.format)); } @@ -145,6 +147,162 @@ pub fn repair(p: &mut Presentation) { } p.slide_size.width = p.slide_size.width.min(4000.0); p.slide_size.height = p.slide_size.height.min(4000.0); + sane_numbers(p); +} + +/// Most `null`s standing in for numbers that [`parse_manifest`] patches in one file, and the +/// candidates it tries for each. +const MAX_NULL_FIXES: usize = 256; +const MAX_TRIES_PER_FIX: usize = 32; + +/// Where a parse failed, comparable: later is progress. +fn failure(src: &[u8]) -> std::result::Result { + serde_json::from_slice::(src).map_err(|e| (e.to_string(), (e.line(), e.column()))) +} + +/// Parse `presentation.json`. serde_json writes a NaN or infinite number as `null`, which then +/// fails to read back as a number ("invalid type: null, expected f64"), so DeckCraft could save +/// a file it couldn't open. Each such `null` is read as 0 instead ([`repair`] then brings the +/// value into range); anything else that doesn't parse is damage. +/// +/// serde reports the failing `null` exactly, except inside tagged enums (buffered), where it +/// reports the end of the enclosing object: there the `null`s before that point are tried +/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since +/// [`finite::Finite`] never contain such `null`s; this reads older ones.) +fn parse_manifest(json: &[u8]) -> Result { + let mut src = json.to_vec(); + let mut last = match failure(&src) { + Ok(m) => return Ok(m), + Err(f) => f, + }; + for _ in 0..MAX_NULL_FIXES { + let (msg, pos) = &last; + let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number"); + if !number { + break; + } + let end = offset(&src, pos.0, pos.1).unwrap_or(src.len()); + let mut progressed = false; + let candidates: Vec = value_nulls_before(&src, end + 4).take(MAX_TRIES_PER_FIX).collect(); + for at in candidates { + let mut next = Vec::with_capacity(src.len()); + next.extend_from_slice(&src[..at]); + next.push(b'0'); + next.extend_from_slice(&src[at + 4..]); + match failure(&next) { + Ok(m) => return Ok(m), + // The patch shortened the text by 3 bytes before the error: compare positions + // allowing for that. + Err(f) if f.1 > (pos.0, pos.1.saturating_sub(3)) => { + src = next; + last = f; + progressed = true; + break; + } + Err(_) => {} + } + } + if !progressed { + break; + } + } + Err(FormatError::Damaged(last.0)) +} + +/// Byte offset of a 1-based `line`:`column`. +fn offset(src: &[u8], line: usize, column: usize) -> Option { + let mut start = 0; + for _ in 1..line { + start += src.get(start..)?.iter().position(|b| *b == b'\n')? + 1; + } + Some((start + column).min(src.len())) +} + +/// Starts of `null` tokens in value position (after `:`, `[` or `,`) before `end`, nearest first. +fn value_nulls_before(src: &[u8], end: usize) -> impl Iterator + '_ { + let end = end.min(src.len()); + (0..end.saturating_sub(3)).rev().filter(move |&i| { + src.get(i..i + 4) == Some(b"null") + && src[..i].iter().rev().find(|b| !b.is_ascii_whitespace()).is_some_and(|b| matches!(b, b':' | b'[' | b',')) + && src.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric()) + }) +} + +/// Largest coordinate or size kept, in points (about 350 m; PowerPoint's own limit is far smaller). +const MAX_COORD: f64 = 1.0e6; + +/// Numbers no view can draw or no file should keep: non-finite positions, sizes and angles become +/// 0, huge ones are clamped, angles are brought into 0–360, crops into a sane range, font sizes +/// into 1–4000 pt and line widths into 0–1584 pt (PowerPoint's limits). +fn sane_xfrm(x: &mut deckcraft_model::Xfrm) { + let fin = |v: f64| if v.is_finite() { v } else { 0.0 }; + x.x = fin(x.x).clamp(-MAX_COORD, MAX_COORD); + x.y = fin(x.y).clamp(-MAX_COORD, MAX_COORD); + x.w = fin(x.w).clamp(0.0, MAX_COORD); + x.h = fin(x.h).clamp(0.0, MAX_COORD); + x.rot = fin(x.rot).rem_euclid(360.0); + if x.rot >= 360.0 { + x.rot = 0.0; + } +} + +fn sane_run(p: &mut deckcraft_model::text::RunProps) { + p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0)); +} + +fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + if let Some(x) = sh.xfrm.as_mut() { + sane_xfrm(x); + } + if let Some(l) = sh.line.as_mut() { + l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0)); + } + if let Some(t) = sh.text.as_mut() { + for para in &mut t.paragraphs { + sane_run(&mut para.end_props); + for r in &mut para.runs { + sane_run(&mut r.props); + } + } + } + match &mut sh.kind { + deckcraft_model::ShapeKind::Picture { fill } => { + for c in &mut fill.crop { + *c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 }; + } + } + deckcraft_model::ShapeKind::Group { children, child } => { + sane_xfrm(child); + sane_shapes(children, depth + 1); + } + _ => {} + } + } +} + +/// [`sane_shapes`] over every slide, layout and master; slides are only copied when they change. +pub fn sane_numbers(p: &mut Presentation) { + for slide in &mut p.slides { + let mut fixed = (**slide).clone(); + sane_shapes(&mut fixed.shapes, 0); + if fixed != **slide { + *slide = Arc::new(fixed); + } + } + for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) { + let mut fixed = (**m).clone(); + sane_shapes(&mut fixed.shapes, 0); + for l in &mut fixed.layouts { + sane_shapes(&mut l.shapes, 0); + } + if fixed != **m { + *m = Arc::new(fixed); + } + } } /// Outline text: one slide per unindented line; tab-indented lines become body bullets at their @@ -241,6 +399,91 @@ mod tests { assert!(load(&b).is_err()); } + /// Non-finite numbers are written as `null` by serde_json; the file still opens, with each + /// such number repaired (it failed as "invalid type: null, expected f64"). + #[test] + fn a_file_saved_with_non_finite_numbers_reopens() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = Presentation::default(); + let nan = f64::NAN; + let inf = f64::INFINITY; + let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; + pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; + pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); + let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; + let mut body = deckcraft_model::text::TextBody::default(); + let mut para = deckcraft_model::text::Paragraph::default(); + para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); + para.end_props.size = Some(1e9); + body.paragraphs.push(para); + text.text = Some(body); + let group = Shape { + xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), + kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(group); + let bytes = save(&p).unwrap(); + let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); + let g = back.slides[0].shapes.last().unwrap(); + let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; + assert_eq!((child.x, child.w), (0.0, 0.0)); + let x = children[0].xfrm.unwrap(); + assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); + let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; + assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); + assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); + assert_eq!(children[1].xfrm.unwrap().rot, 0.0); + let para = &children[1].text.as_ref().unwrap().paragraphs[0]; + assert_eq!(para.runs[0].props.size, None); + assert_eq!(para.end_props.size, Some(4000.0)); + // Saved again, it is clean. + assert_eq!(load(&save(&back).unwrap()).unwrap(), back); + } + + /// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, + /// including a `null` inside a tagged enum, where serde reports only the enclosing object. + #[test] + fn older_files_with_null_numbers_open() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let nan = f64::NAN; + for case in 0..3 { + let mut p = Presentation::default(); + let sh = match case { + 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, + 1 => Shape { + kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, + ..Default::default() + }, + _ => Shape { + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, + ..Default::default() + }, + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; + let json = serde_json::to_vec(&m).unwrap(); + assert!(json.windows(4).any(|w| w == b"null")); + let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); + assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); + } + } + + /// Only `null`s where numbers belong are patched: other damage still fails. + #[test] + fn other_damage_is_still_reported() { + let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); + assert!(parse_manifest(&ok).is_ok()); + let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; + assert!(parse_manifest(bad).is_err()); + let truncated = &ok[..ok.len() - 3]; + assert!(parse_manifest(truncated).is_err()); + // A null number on a later line (pretty-printed by another tool) is found too. + let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; + let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); + } + #[test] fn repair_fixes_missing_layout_and_size() { let mut p = Presentation::default(); From 3ea696dc1a27720df03a9c93b1249971dc47e50e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:48:33 +0300 Subject: [PATCH 47/69] Charts: read, write and draw error bars --- crates/model/src/chart.rs | 66 ++++++++++++++++++++++++++++++++++ crates/pptx/src/read/chart.rs | 25 ++++++++++++- crates/pptx/src/write/chart.rs | 36 +++++++++++++++++++ crates/render/src/chart.rs | 31 ++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..ac6156b1 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -138,6 +138,55 @@ pub struct Series { /// source data's. #[serde(skip_serializing_if = "Option::is_none")] pub number_format: Option, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + let vals: Vec = values.iter().flatten().copied().filter(|v| v.is_finite()).collect(); + let n = vals.len() as f64; + let mean = vals.iter().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -431,6 +480,23 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { mod tests { use super::*; + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + #[test] fn nice_scale_covers() { let (a, b, s) = nice_scale(0.0, 5.1); diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..6234df23 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -1,6 +1,6 @@ //! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`. -use deckcraft_model::chart::{Chart, ChartType, Series}; +use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series}; use deckcraft_model::style::Fill; use crate::xml::El; @@ -229,6 +229,21 @@ pub(crate) fn chart_from(root: &El) -> Option { if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { ser.values = numbers(v); } + // Error bars on the values: a scatter chart's y bars, any other chart's only ones. + if let Some(e) = s + .children_named("errBars") + .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble))) + { + let d = ErrorBars::default(); + ser.error_bars = Some(ErrorBars { + bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), + val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), + value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), + plus: e.child("plus").map(numbers).unwrap_or_default(), + minus: e.child("minus").map(numbers).unwrap_or_default(), + end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), + }); + } if let Some(b) = s.child("bubbleSize") { ser.sizes = numbers(b); } @@ -310,6 +325,14 @@ fn text_size(e: Option<&El>) -> Option { mod tests { use super::*; + #[test] + fn error_bars_are_read() { + let xml = br#"5"#; + let c = read_chart_xml(xml).unwrap(); + let e = c.series[0].error_bars.clone().unwrap(); + assert_eq!((e.bar_type.as_str(), e.val_type.as_str(), e.value, e.end_cap), ("plus", "percentage", 20.0, false)); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..b9ab714b 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -108,6 +108,20 @@ fn num_cache(w: &mut W, f: &str, vals: &[Option]) { w.close("c:numRef"); } +/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them. +fn num_lit(w: &mut W, vals: &[Option]) { + w.open0("c:numLit"); + w.val("c:ptCount", vals.len()); + for (i, v) in vals.iter().enumerate() { + if let Some(v) = v.filter(|v| v.is_finite()) { + w.open("c:pt", A::new().a("idx", i)); + w.elt("c:v", &fmt_num(v)); + w.close("c:pt"); + } + } + w.close("c:numLit"); +} + #[derive(Clone, Copy, PartialEq)] enum Group { Bar, @@ -176,6 +190,28 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) } let n = c.categories.len().max(s.values.len()); let last = n + 1; + if let Some(e) = &s.error_bars { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open0("c:errBars"); + if matches!(g, Group::Scatter | Group::Bubble) { + w.val("c:errDir", "y"); + } + w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.val("c:errValType", vt); + w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); + if vt == "cust" { + w.open0("c:plus"); + num_lit(w, &e.plus); + w.close("c:plus"); + w.open0("c:minus"); + num_lit(w, &e.minus); + w.close("c:minus"); + } else if e.value.is_finite() { + w.val("c:val", fmt_num(e.value)); + } + w.close("c:errBars"); + } if matches!(g, Group::Scatter | Group::Bubble) { let xs: Vec> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() }; w.open0("c:xVal"); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..f6805c65 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -379,6 +379,8 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } let zero = to_v(0.0f64.clamp(min, max)); + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); @@ -420,6 +422,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.set_transform(m); ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); ctx.fill_rect(&r.abs()); + if let Some(e) = &s.error_bars { + let (end, mid) = if horiz { (if v >= 0.0 { b } else { a }, r.center().y) } else { (if v >= 0.0 { b } else { a }, r.center().x) }; + if let Some((lo, hi)) = e.range(&s.values, i, end) { + let (p0, p1) = if horiz { (to_v(lo), to_v(hi)) } else { (to_axis(sec, lo), to_axis(sec, hi)) }; + err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap)); + } + } if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { @@ -475,6 +484,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } _ => plot.x0 + band * (i as f64 + 0.5), }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| e.range(&s.values, i, v)) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } pts.push(Point::new(x, to_axis(sec, v))); } if pts.is_empty() { @@ -511,6 +523,25 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } + ctx.set_transform(m); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } // Axis line. let mut ax = BezPath::new(); if horiz { From a9e7747ea5262dff2b10b675ac1c07f35b153058 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:55:33 +0300 Subject: [PATCH 48/69] Canvas: a two-finger twist rotates the selection macOS delivers a trackpad rotation as egui's Rotate event. While the pointer is over the canvas, that twist turns the selected shapes, and the whole gesture is one undo step. Pinch-zoom and scrolling are unchanged. Co-authored-by: Cursor --- ROADMAP.md | 1 + crates/engine/src/cmd/shape.rs | 67 +++++++++++++++++++++++++++++++++- crates/engine/src/tools.rs | 10 ++++- crates/ui-egui/src/canvas.rs | 27 ++++++++++++++ crates/ui-egui/src/lib.rs | 48 ++++++++++++++++++++++++ docs/gaps.md | 5 ++- docs/hardware-parity.md | 5 ++- 7 files changed, 157 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..e9a8fe0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged | | 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 | diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..159321e7 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -33,7 +33,15 @@ pub fn specs() -> Vec { cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds), - cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate), + cmd!( + "shape.rotate", + "Rotation", + ["Shape Format", "Arrange", "Rotate"], + None, + "{deg?: absolute, by?: relative, ids?, gesture?: move|end}", + has_selection, + rotate + ), cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip), cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust), cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change), @@ -174,6 +182,15 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { } fn rotate(s: &mut Session, p: &Value) -> Result { + // A trackpad twist sends many small steps. `gesture: move` keeps them as one undo; + // `gesture: end` commits that step and does not rotate again. + if p.get("gesture").and_then(Value::as_str) == Some("end") { + s.commit_named("Rotation"); + return Ok(json!({})); + } + if p.get("gesture").and_then(Value::as_str) == Some("move") { + s.begin("Rotation"); + } let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); edit_geometry(s, p, "shape.rotate", |sh| { if let Some(x) = sh.xfrm.as_mut() { @@ -860,3 +877,51 @@ fn freeform(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"id": id.0})) } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use serde_json::json; + + fn rotation(s: &Session) -> f64 { + let st = s.active().unwrap(); + let id = st.selection.shapes[0]; + st.shape(id).unwrap().xfrm.unwrap().rot + } + + #[test] + fn a_twist_is_one_undo_and_wraps_past_a_full_turn() { + let mut s = Session::with_new(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap(); + let id = id.get("id").and_then(Value::as_u64).unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01); + + s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap(); + s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s)); + s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap(); + s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); + assert!((rotation(&s) - 20.0).abs() < 0.01); + assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation"); + assert!(s.active().unwrap().interaction.is_none()); + + s.execute("edit.undo", &json!({})).unwrap(); + assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!((rotation(&s) - 20.0).abs() < 0.01); + } + + #[test] + fn ending_a_twist_does_not_commit_a_drag() { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap(); + let st = s.active_mut().unwrap(); + st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() }); + s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); + assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move"); + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..3b75c76e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -309,7 +309,7 @@ impl Session { } } - fn begin(&mut self, label: &str) { + pub(crate) fn begin(&mut self, label: &str) { if let Some(st) = self.active_mut() && st.interaction.is_none() { @@ -326,6 +326,14 @@ impl Session { } } + /// End the interaction only when it is the one `label` started. A twist must not commit a drag. + pub(crate) fn commit_named(&mut self, label: &str) { + let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label); + if ours { + self.commit(); + } + } + fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { // Next click of a freeform: close on the start point, else add a vertex. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..e798f6e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -35,6 +35,33 @@ fn mods(ui: &Ui) -> Mods { ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl }) } +/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; +/// other platforms leave the delta at zero. +/// +/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom +/// and two-finger scrolling are left as they are. +pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { + let delta = ctx.input(|i| i.rotation_delta()); + let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; + let turning = delta.abs() > 1e-4; + let blocked = app.show.is_some() + || app.backstage_open() + || app.dialog.as_ref().is_some_and(|d| d.modal()) + || app.palette.is_some() + || app.session.tool.dragging(); + let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p))); + let rotatable = app.session.active().is_some_and(|d| { + d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some())) + }); + if !blocked && turning && rotatable && (app.twisting || over) { + let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"})); + app.twisting = true; + } else if app.twisting { + let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []})); + app.twisting = false; + } +} + /// The slide's rect on screen for the current zoom. pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { let Some(d) = app.session.active() else { return (avail, 1.0) }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..d1f190e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -227,6 +227,8 @@ pub struct SlideApp { pub(crate) thumb_drag: Option<(usize, usize)>, /// Selection pane: folded groups, renaming, drag and drop. pub selpane: selpane::State, + /// A trackpad twist is in progress (one undo step when it ends). + twisting: bool, } impl SlideApp { @@ -267,6 +269,7 @@ impl SlideApp { ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, selpane: selpane::State::default(), + twisting: false, } } @@ -779,6 +782,7 @@ impl SlideApp { Err(e) => self.set_status(e), } } + canvas::trackpad_twist(self, ctx); } /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open @@ -1136,6 +1140,50 @@ mod tests { use super::*; + fn shape_rot(app: &SlideApp) -> f64 { + let d = app.session.active().unwrap(); + let id = d.selection.shapes[0]; + d.shape(id).unwrap().xfrm.unwrap().rot + } + + /// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection; + /// letting go is one undo step. Off the canvas it does nothing. + #[test] + fn a_trackpad_twist_rotates_the_shape_under_the_canvas() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap(); + let id = id.get("id").and_then(|v| v.as_u64()).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.run_steps(1); + let canvas = harness.state().canvas_rect.expect("canvas"); + let outside = egui::pos2(1.0, 1.0); + assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}"); + + harness.input_mut().events.push(egui::Event::PointerMoved(outside)); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state())); + + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state())); + harness.run_steps(1); + assert!(harness.state().session.active().unwrap().interaction.is_none()); + harness.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state())); + } + fn title_text(app: &SlideApp) -> String { let d = app.session.active().unwrap(); let sh = &d.doc.slides[0].shapes[0]; diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..68030390 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, 3-D bevel, camera and face lighting rendered) · **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 @@ -217,7 +217,7 @@ Detail in [hardware-parity.md](hardware-parity.md). or wgpu path). - **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy FilmCraft's hardware decode). -- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h. +- **No pen pressure or tilt for ink**; no pinch-zoom (it does not keep the point under the pointer). A two-finger twist does rotate the selected shapes. 3–6 h. ## Localization @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..aada56db 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -43,7 +43,7 @@ because it mostly follows from hardware use. |---|---|---| | Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes | | Mouse and trackpad scroll | yes | yes | -| Pinch to zoom, two-finger gestures | yes | no | +| Pinch to zoom, two-finger gestures | yes | pinch zoom does not keep the point under the pointer; a two-finger twist rotates the selected shapes | | Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | @@ -71,5 +71,6 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | From 1e6292306ae0d965084b50fa63029d4ef747f10a Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:08:40 +0300 Subject: [PATCH 49/69] UI: Format Background picture fill sets transparency and tiling A picture background can set transparency, tile, offset, scale, alignment and mirror. A solid fill can set transparency. Apply to All copies the current slide's background, including a picture, onto every slide. Unchanged options are not an undo step. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/engine/src/cmd/design.rs | 333 ++++++++++++++++++++++--- crates/ui-egui/src/background_tests.rs | 75 ++++++ crates/ui-egui/src/panes.rs | 104 +++++++- docs/gaps.md | 7 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 7 +- 7 files changed, 494 insertions(+), 43 deletions(-) create mode 100644 crates/ui-egui/src/background_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..d0eaaaec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Background picture options; percentages unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Format Background picture and solid options | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 3cad3f00..0e7b5cf2 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Format Background", ["Design", "Customize"], None, - "{color? | gradient? | picture?: base64 | style?: 1..12 | reset?, all?: bool (apply to all), index?}", + "{color? | gradient? | picture?: base64 | pattern? | style?: 1..12 | reset?, all?: bool, index?, transparency?: 0..1, pictureProps?: {tile?, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0.01..10, align?, mirror?, stretch?: [l,t,r,b], rotateWithShape?}, copy?: bool}", has_slide, background ), @@ -149,65 +149,249 @@ fn fonts(s: &mut Session, p: &Value) -> Result { } fn background(s: &mut Session, p: &Value) -> Result { - let bg: Option = if bool_or(p, "reset", false) { - None - } else if let Some(c) = color_param(p, "color") { - Some(Background::Fill { fill: Fill::solid(c) }) - } else if let Some(i) = usize_param(p, "style") { + if bool_or(p, "copy", false) { + return copy_slide_background(s); + } + let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); + let transparency = f64_param(p, "transparency"); + let reset = bool_or(p, "reset", false); + let color = color_param(p, "color"); + let style = usize_param(p, "style"); + let op = if reset { + BgOp::Set(None) + } else if let Some(mut c) = color { + if let Some(t) = transparency { + set_solid_transparency(&mut c, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) })) + } else if let Some(i) = style { // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). let slots = [deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1]; let i = i.clamp(1, 12) - 1; - Some(Background::Ref { + BgOp::Set(Some(Background::Ref { idx: 1001 + (i / 4) as u32, color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)), - }) + })) } else if let Some(g) = p.get("gradient") { let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; g.rotate_with_shape = false; - Some(Background::Fill { fill: Fill::Gradient(g) }) + BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) } else if let Some(pt) = p.get("pattern") { - Some(Background::Fill { + BgOp::Set(Some(Background::Fill { fill: Fill::Pattern(deckcraft_model::style::PatternFill { preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)), }), - }) + })) } else if p.get("picture").is_some() { let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; let ct = super::insert::content_type(&name, &bytes); let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; - Some(Background::Fill { fill: Fill::Picture(deckcraft_model::style::PictureFill { media, ..Default::default() }) }) + let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; + if let Some(pp) = &props { + apply_picture_props(&mut pf, pp); + } + if let Some(t) = transparency { + set_picture_transparency(&mut pf, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) })) + } else if props.is_some() || transparency.is_some() { + BgOp::Adjust } else { - return Err(bad("design.background", "give `color`, `gradient`, `pattern`, `picture`, `style` or `reset`")); + return Err(bad( + "design.background", + "give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`", + )); }; let all = bool_or(p, "all", false); let index = usize_param(p, "index"); + if matches!(op, BgOp::Adjust) { + let next = { + let st = s.doc()?; + let current = background_of(&st.doc, &st.selection, index); + let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) })); + let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } })); + if props.is_some() && !picture { + return Err(bad("design.background", "picture options need a picture background")); + } + if transparency.is_some() && !picture && !solid { + return Err(bad("design.background", "transparency needs a solid or picture background")); + } + let mut next = current.clone(); + if let Some(bg) = next.as_mut() { + adjust_background(bg, props.as_ref(), transparency); + } + if next == current { None } else { Some(next) } + }; + let Some(next) = next else { return ok() }; + s.edit(|doc, sel| { + write_background(doc, sel, index, false, next); + Ok(()) + })?; + return ok(); + } + let BgOp::Set(bg) = op else { return ok() }; + let unchanged = { + let st = s.doc()?; + backgrounds_already(&st.doc, &st.selection, index, all, &bg) + }; + if unchanged { + return ok(); + } s.edit(|doc, sel| { - match sel.target { - Target::Master { master } => { - if let Some(m) = doc.masters.get_mut(master) { - Arc::make_mut(m).background = bg.clone(); + write_background(doc, sel, index, all, bg); + Ok(()) + })?; + ok() +} + +enum BgOp { + Set(Option), + Adjust, +} + +fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { + match sel.target { + Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()), + Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()), + } +} + +fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> bool { + match sel.target { + Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg), + Target::Slides => { + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)) + } + } +} + +fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { + match sel.target { + Target::Master { master } => { + if let Some(m) = doc.masters.get_mut(master) { + Arc::make_mut(m).background = bg; + } + } + Target::Layout { master, layout } => { + if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { + l.background = bg; + } + } + Target::Slides => { + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).background = bg.clone(); } } - Target::Layout { master, layout } => { - if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { - l.background = bg.clone(); - } + } + } +} + +fn copy_slide_background(s: &mut Session) -> Result { + let bg = { + let st = s.doc()?; + if !matches!(st.selection.target, Target::Slides) { + return Err(bad("design.background", "copy applies to slides")); + } + let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone()); + if st.doc.slides.iter().all(|sl| sl.background == bg) { + return ok(); + } + bg + }; + s.edit(|doc, sel| { + write_background(doc, sel, None, true, bg); + Ok(()) + })?; + ok() +} + +fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option) { + match bg { + Background::Fill { fill: Fill::Picture(pf) } => { + if let Some(pp) = props { + apply_picture_props(pf, pp); } - Target::Slides => { - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; - for i in list { - if let Some(sl) = doc.slides.get_mut(i) { - Arc::make_mut(sl).background = bg.clone(); + if let Some(t) = transparency { + set_picture_transparency(pf, t); + } + } + Background::Fill { fill: Fill::Solid { color } } => { + if let Some(t) = transparency { + set_solid_transparency(color, t); + } + } + _ => {} + } +} + +fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); +} + +fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) { + color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_))); + let t = t.clamp(0.0, 1.0); + if t > 0.0 { + color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32)); + } +} + +/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch). +fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) { + use deckcraft_model::style::PictureMode; + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); } } } } - Ok(()) - })?; - ok() + } } fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { @@ -558,9 +742,100 @@ fn insert_placeholder(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + use deckcraft_model::style::PictureMode; + use deckcraft_model::{Background, Fill}; + use serde_json::json; + + use crate::Session; + + fn png() -> String { + let mut img = image::RgbaImage::new(2, 2); + img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255])); + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + crate::cmd::base64_encode(&bytes) + } + + fn picture(s: &Session) -> deckcraft_model::style::PictureFill { + match &s.doc().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(), + other => panic!("expected a picture background, got {other:?}"), + } + } + + fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + #[test] fn civil_dates() { assert_eq!(super::civil(0), (1970, 1, 1)); assert_eq!(super::civil(20_734), (2026, 10, 8)); } + + #[test] + fn background_picture_options_tile_transparency_and_copy() { + let mut s = Session::with_new(); + let n = undo_len(&s); + let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string(); + assert!(err.contains("picture background"), "{err}"); + assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err()); + assert_eq!(undo_len(&s), n); + + s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap(); + match &s.doc().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()), + other => panic!("expected a solid background, got {other:?}"), + } + let n = undo_len(&s); + s.execute("design.background", &json!({"transparency": 0.25})).unwrap(); + assert_eq!(undo_len(&s), n, "the same transparency is not an undo step"); + + s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap(); + let pf = picture(&s); + assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6); + assert!(matches!(pf.mode, PictureMode::Tile { .. })); + let n = undo_len(&s); + + s.execute( + "design.background", + &json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}), + ) + .unwrap(); + match picture(&s).mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0)); + assert_eq!(flip, "xy"); + assert_eq!(align, "ctr"); + } + other => panic!("expected a tile, got {other:?}"), + } + assert!(picture(&s).upright); + s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap(); + match picture(&s).mode { + PictureMode::Tile { tx, align, .. } => { + assert_eq!(tx, 12.0); + assert_eq!(align, "ctr"); + } + other => panic!("expected the tile to stay, got {other:?}"), + } + assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step"); + + s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap(); + match picture(&s).mode { + PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]), + other => panic!("expected a stretch, got {other:?}"), + } + + s.execute("slide.new", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.slides[1].background.is_none()); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let n = undo_len(&s); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background"); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step"); + } } diff --git a/crates/ui-egui/src/background_tests.rs b/crates/ui-egui/src/background_tests.rs new file mode 100644 index 00000000..b2a7a879 --- /dev/null +++ b/crates/ui-egui/src/background_tests.rs @@ -0,0 +1,75 @@ +//! Format Background pane: picture transparency, tiling, and Apply to All. + +use deckcraft_engine::Session; +use deckcraft_model::style::PictureMode; +use deckcraft_model::{Background, Fill}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// 1×1 PNG. +const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +fn harness(mut app: SlideApp) -> Harness<'static, SlideApp> { + app.ui.tab = "Design".into(); + app.ui.pane = Some("background".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 picture_mode(h: &Harness<'_, SlideApp>) -> PictureMode { + match &h.state().session.active().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.mode.clone(), + other => panic!("expected a picture background, got {other:?}"), + } +} + +#[test] +fn picture_fill_shows_transparency_and_tiling() { + let mut s = Session::with_new(); + s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default())); + let _ = h.get_by_label("Format Background"); + let _ = h.get_by_label("Picture or texture fill"); + assert!(h.query_all_by_label("Transparency").next().is_some()); + h.get_by_label("Tile picture as texture").click(); + h.run_steps(2); + let _ = h.get_by_label("Offset X"); + let _ = h.get_by_label("Scale X"); + let _ = h.get_by_label("Alignment"); + let _ = h.get_by_label("Mirror"); + assert!(matches!(picture_mode(&h), PictureMode::Tile { .. })); +} + +#[test] +fn solid_fill_shows_transparency_without_tile_options() { + let mut s = Session::with_new(); + s.execute("design.background", &json!({"color": "accent1"})).unwrap(); + let h = harness(SlideApp::new(s, Services::default())); + assert!(h.query_all_by_label("Transparency").next().is_some()); + let _ = h.get_by_label("Solid fill"); + assert!(h.query_by_label("Tile picture as texture").is_none()); +} + +#[test] +fn apply_to_all_copies_the_picture() { + let mut s = Session::with_new(); + s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default())); + h.get_by_label("Apply to All").click(); + h.run_steps(2); + let doc = &h.state().session.active().unwrap().doc; + assert!(matches!(doc.slides[1].background, Some(Background::Fill { fill: Fill::Picture(_) }))); + assert_eq!(doc.slides[1].background, doc.slides[0].background); +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..e4b88d5d 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -397,6 +397,10 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { } }); }); + let mut tr = ((1.0 - color.alpha()) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } } Some(Fill::Gradient(g)) => { current = Some(json!({"gradient": crate::fillui::gradient_json(g)})); @@ -426,7 +430,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } } - Some(Fill::Picture(_)) => { + Some(Fill::Picture(pf)) => { if ui.button("Insert picture from file…").clicked() && let Some(pick) = app.services.pick_open.as_mut() && let Some(path) = pick("picture") @@ -434,6 +438,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { { let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); } + picture_background_options(app, ui, pf); } _ => {} } @@ -444,12 +449,15 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.hideBackgroundGraphics", json!({"hide": h})); } ui.add_space(8.0); + let picture = matches!(fill, Some(Fill::Picture(_))); ui.horizontal(|ui| { - if ui.add_enabled(current.is_some(), egui::Button::new("Apply to All")).clicked() - && let Some(Value::Object(mut o)) = current.clone() - { - o.insert("all".into(), json!(true)); - run(app, "design.background", Value::Object(o)); + if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { + if let Some(Value::Object(mut o)) = current.clone() { + o.insert("all".into(), json!(true)); + run(app, "design.background", Value::Object(o)); + } else if picture { + run(app, "design.background", json!({"copy": true})); + } } if ui.button("Reset Background").clicked() { run(app, "design.background", json!({"reset": true})); @@ -457,6 +465,86 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } +fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + let alpha = pf.alpha.unwrap_or(1.0); + let mut tr = ((1.0 - alpha) * 100.0) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); + } + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "design.background", json!({"pictureProps": {"tile": tile}})); + } + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return }; + for (label, id, value, suffix, scale) in [ + ("Offset X", "bg-ox", *tx, " pt", 1.0), + ("Offset Y", "bg-oy", *ty, " pt", 1.0), + ("Scale X", "bg-sx", *sx * 100.0, "%", 0.01), + ("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01), + ] { + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label)); + if let Some(v) = widgets::spinner(ui, id, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { + let key = match id { + "bg-ox" => "offsetX", + "bg-oy" => "offsetY", + "bg-sx" => "scaleX", + _ => "scaleY", + }; + run(app, "design.background", json!({"pictureProps": {key: v * scale}})); + } + }); + } + let align_name = match align.as_str() { + "t" => "Top", + "tr" => "Top Right", + "l" => "Left", + "ctr" => "Center", + "r" => "Right", + "bl" => "Bottom Left", + "b" => "Bottom", + "br" => "Bottom Right", + _ => "Top Left", + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); + egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [ + ("tl", "Top Left"), + ("t", "Top"), + ("tr", "Top Right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom Left"), + ("b", "Bottom"), + ("br", "Bottom Right"), + ] { + if ui.selectable_label(align == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"align": k}})); + } + } + }); + }); + let mirror_name = match flip.as_str() { + "x" => "Horizontal", + "y" => "Vertical", + "xy" => "Both", + _ => "None", + }; + ui.horizontal(|ui| { + ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); + egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { + for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { + if ui.selectable_label(flip == k, l).clicked() { + run(app, "design.background", json!({"pictureProps": {"mirror": k}})); + } + } + }); + }); +} + fn animation(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -635,3 +723,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(8.0); } } + +#[cfg(test)] +#[path = "background_tests.rs"] +mod background_tests; diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..fb6675c8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture options are pane controls, not a new dialog; dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **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 @@ -204,7 +204,9 @@ Detail in [ui-parity.md](ui-parity.md). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. - **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. + Replace Fonts, Action Settings, Print, Insert Object…). Format Background picture options + (transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture) are + pane controls on `design.background`, not another dialog. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -256,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Format Background picture and solid options are in the existing pane. Dialogs stay 27 | | 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-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 | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..1940b7e0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -211,7 +211,9 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc inheritance (slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields, embedded media, unlimited undo on shared slide snapshots. - **Themes:** 8 original themes with colour and font schemes, custom colours and fonts, - backgrounds (solid, gradient, picture, pattern), slide size presets. + backgrounds (solid, gradient, picture, pattern; a solid fill's transparency; picture + transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture), + slide size presets. - **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop, corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites, @@ -276,6 +278,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Format Background picture and solid options noted under what works today. Percentages, the 226-command count and the d556ff9 dialog snapshot stay as they are | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..7d10b47b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,8 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** 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 (Format Background picture and solid options; dialogs stay 27) · **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 @@ -59,7 +59,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | -| Format Background pane | yes | yes | +| Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 | | 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 | From 2f3a5255682f1467480995f5ef242b6b73dd424b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:16:14 +0300 Subject: [PATCH 50/69] UI: Format Shape fill transparency follows the selection The Fill slider seeded itself at 0% and wrote that when it lost focus, so a solid fill that was already transparent opened as opaque and could be cleared. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/ui-egui/src/panes.rs | 127 ++++++++++++++++++++++++++++++++++-- docs/ui-parity.md | 5 +- 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..56909db5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Shape fill transparency follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..6c387f56 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -59,6 +59,29 @@ fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } +/// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s +/// `transparency` parameter writes as an alpha transform. Anything else — no fill, a +/// gradient, a picture, a pattern — is 0, because that command only changes a solid. +fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 { + let Some(Fill::Solid { color }) = fill else { + return 0.0; + }; + ((1.0 - color.alpha()) * 100.0) as f32 +} + +/// Fraction to pass to `shape.fill` when the slider moved. An unchanged value (a focus +/// loss, or switching to another shape) is `None`, so the pane does not rewrite the fill. +fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) -> Option { + if !shown_percent.is_finite() { + return None; + } + let shown = shown_percent.clamp(0.0, 100.0); + if (shown - seed_percent).abs() <= 0.01 { + return None; + } + Some(f64::from(shown) / 100.0) +} + fn scheme(app: &SlideApp) -> deckcraft_color::ColorScheme { app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| deckcraft_model::Theme::default().colors) } @@ -285,12 +308,19 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { ui.label("Transparency"); - let id = ui.id().with("ftr"); - let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(0.0)); + // Seed from this shape. A shared temp defaulted to 0, so a shape that was + // already 40% transparent showed as opaque, and leaving the slider wrote 0. + let seed = solid_fill_transparency_percent(sh.fill.as_ref()); + let id = ui.id().with(("ftr", sh.id.0)); + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed)); let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); - ui.data_mut(|d| d.insert_temp(id, v)); if r.drag_stopped() || r.lost_focus() { - let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0})); + if let Some(t) = fill_transparency_to_apply(v, seed) { + let _ = app.run("shape.fill", json!({"transparency": t})); + } + ui.data_mut(|d| d.remove::(id)); + } else if r.dragged() || r.changed() { + ui.data_mut(|d| d.insert_temp(id, v)); } }); if let Some(Fill::Gradient(g)) = &sh.fill { @@ -635,3 +665,92 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(8.0); } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use deckcraft_color::{ColorTransform, SchemeSlot}; + use deckcraft_engine::Session; + use deckcraft_model::{ColorRef, Fill, ShapeId}; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + use serde_json::json; + + use super::{fill_transparency_to_apply, solid_fill_transparency_percent}; + use crate::{Services, SlideApp}; + + #[test] + fn solid_fill_transparency_reads_the_alpha_shape_fill_writes() { + assert_eq!(solid_fill_transparency_percent(None), 0.0); + assert_eq!(solid_fill_transparency_percent(Some(&Fill::None)), 0.0); + assert_eq!(solid_fill_transparency_percent(Some(&Fill::Group)), 0.0); + let plain = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1)); + assert_eq!(solid_fill_transparency_percent(Some(&plain)), 0.0); + // `shape.fill` transparency 0.4 stores Alpha((1 - 0.4) * 100_000). + let forty = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(60_000))); + let p = solid_fill_transparency_percent(Some(&forty)); + assert!((p - 40.0).abs() < 0.01, "{p}"); + let clear = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0))); + assert!((solid_fill_transparency_percent(Some(&clear)) - 100.0).abs() < 0.01); + // A later alpha wins, matching ColorRef::alpha. + let layered = Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(0)).with(ColorTransform::Alpha(60_000))); + assert!((solid_fill_transparency_percent(Some(&layered)) - 40.0).abs() < 0.01); + + assert_eq!(fill_transparency_to_apply(40.0, 40.0), None); + assert_eq!(fill_transparency_to_apply(40.005, 40.0), None); + assert_eq!(fill_transparency_to_apply(f32::NAN, 40.0), None); + assert_eq!(fill_transparency_to_apply(55.0, 40.0), Some(0.55)); + assert_eq!(fill_transparency_to_apply(0.0, 40.0), Some(0.0)); + } + + fn solid_alpha(app: &SlideApp, id: u32) -> Option { + let sh = app.session.active()?.shape(ShapeId(id))?; + let Fill::Solid { color } = sh.fill.as_ref()? else { return None }; + color.mods.iter().find_map(|m| match m { + ColorTransform::Alpha(v) => Some(*v), + _ => None, + }) + } + + fn slider_values(h: &Harness<'_, SlideApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::Slider).filter_map(|n| n.accesskit_node().numeric_value()).collect() + } + + /// The Fill slider shows the selected solid's transparency, and opening the pane + /// (or looking at another shape first) does not write 0% over it. + #[test] + fn fill_transparency_slider_follows_the_selection() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.fill", &json!({"id": a, "color": "accent1", "transparency": 0.0})).unwrap(); + s.execute("shape.fill", &json!({"id": b, "color": "accent1", "transparency": 0.4})).unwrap(); + s.execute("edit.select", &json!({"ids": [a]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("format".into()); + app.ui.format_tab = "fill".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let opaque: Vec = slider_values(&h); + assert!(opaque.iter().any(|v| (*v - 0.0).abs() < 0.5), "opaque shape should show 0%, got {opaque:?}"); + assert!(opaque.iter().all(|v| (*v - 40.0).abs() > 0.5), "opaque shape should not show 40%, got {opaque:?}"); + + h.state_mut().session.execute("edit.select", &json!({"ids": [b]})).unwrap(); + h.run_steps(3); + let clear: Vec = slider_values(&h); + assert!(clear.iter().any(|v| (*v - 40.0).abs() < 0.5), "40% fill should show 40%, got {clear:?}"); + assert_eq!(solid_alpha(h.state(), b), Some(60_000)); + + h.get_by_label("Transparency").click(); + h.run_steps(2); + assert_eq!(solid_alpha(h.state(), b), Some(60_000)); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..b95223a4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Format Shape fill transparency follows the selection; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -58,7 +58,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss | | 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 | From 5d9f47c81a1ca099dec563c23e37dd685bb1f9b7 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:37:49 +0300 Subject: [PATCH 51/69] Clipboard: rich text and pictures for other apps Copy puts HTML on the pasteboard with the same plain words, so Pages keeps bold, italic and underline, and a paste of that HTML keeps them here. A shape with no text copies out as a picture. Co-authored-by: Cursor --- ROADMAP.md | 4 +- apps/deckcraft/src/main.rs | 13 +- apps/deckcraft/src/pasteboard.rs | 77 +++++++ crates/engine/src/cmd/edit.rs | 79 ++++++- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/richclip.rs | 346 ++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 56 +++++ crates/render/src/lib.rs | 43 ++++ crates/render/src/tests.rs | 16 ++ crates/ui-egui/src/lib.rs | 154 +++++++++++-- docs/ui-parity.md | 7 +- 11 files changed, 763 insertions(+), 33 deletions(-) create mode 100644 apps/deckcraft/src/pasteboard.rs create mode 100644 crates/engine/src/cmd/richclip.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..0fe66975 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: copy and paste rich text and pictures with other apps; previous: hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them), and a shape with no text copies out as a picture | | 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 | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bb..5173ff87 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -17,6 +17,8 @@ mod control_server; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging; +#[cfg(not(target_arch = "wasm32"))] +mod pasteboard; use deckcraft_engine::Session; use deckcraft_ui_egui::{Services, SlideApp}; @@ -111,14 +113,9 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - clipboard_image: Some(Box::new(|| { - let mut cb = arboard::Clipboard::new().ok()?; - let img = cb.get_image().ok()?; - let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?; - let mut out = Vec::new(); - rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) - })), + clipboard_image: Some(Box::new(pasteboard::image_png)), + clipboard_html: Some(Box::new(pasteboard::html)), + clipboard_set_html: Some(Box::new(pasteboard::set_html)), // Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently. audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO") .is_none() diff --git a/apps/deckcraft/src/pasteboard.rs b/apps/deckcraft/src/pasteboard.rs new file mode 100644 index 00000000..98626295 --- /dev/null +++ b/apps/deckcraft/src/pasteboard.rs @@ -0,0 +1,77 @@ +//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard). +//! +//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain +//! words. A shape with no text is copied as an image by the UI; this module only reads a picture +//! back (Keynote's TIFF) as PNG bytes. + +/// HTML currently on the pasteboard, when there is some. +pub fn html() -> Option { + let mut cb = arboard::Clipboard::new().ok()?; + let html = cb.get().html().ok()?; + let html = html.trim(); + if html.is_empty() { None } else { Some(html.to_string()) } +} + +/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it. +pub fn set_html(html: &str, text: &str) -> bool { + let Ok(mut cb) = arboard::Clipboard::new() else { return false }; + cb.set_html(html, Some(text)).is_ok() +} + +/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard). +pub fn image_png() -> Option> { + let mut cb = arboard::Clipboard::new().ok()?; + let img = cb.get_image().ok()?; + let width = u32::try_from(img.width).ok()?; + let height = u32::try_from(img.height).ok()?; + let rgba = image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?; + let mut out = Vec::new(); + rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back. + /// Restores whatever plain text was there. Other platforms return immediately. + #[test] + fn html_and_a_picture_round_trip_on_macos() { + if !cfg!(target_os = "macos") { + return; + } + let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()); + let _restore = Restore(previous); + assert!(super::set_html("DeckCraft", "DeckCraft"), "set html"); + let html = super::html().unwrap_or_default(); + assert!(html.contains("") || html.contains(""), "{html}"); + let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default(); + assert_eq!(text, "DeckCraft"); + let mut cb = arboard::Clipboard::new().expect("pasteboard"); + let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) }; + cb.set_image(img).expect("set image"); + drop(cb); + let png = super::image_png().expect("png"); + let decoded = image::load_from_memory(&png).expect("decode").to_rgba8(); + let p = decoded.get_pixel(0, 0); + assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}"); + } + + /// Puts the previous plain text back, including when the test fails halfway. + struct Restore(Option); + + impl Drop for Restore { + fn drop(&mut self) { + let Ok(mut cb) = arboard::Clipboard::new() else { return }; + match self.0.take() { + Some(t) => { + let _ = cb.set_text(t); + } + None => { + let _ = cb.clear(); + } + } + } + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..d4108705 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use deckcraft_model::text::TextBody; use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId}; use serde_json::{Value, json}; @@ -12,9 +13,17 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo), - cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut), - cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy), - cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste), + cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut), + cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy), + cmd!( + "edit.paste", + "Paste", + ["Home", "Clipboard"], + Some("Cmd+V"), + "{text?: plain text to paste, html?: rich text from another app, scope?: slides}", + has_doc, + paste + ), cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate), cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete), @@ -114,15 +123,41 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { Ok(plain) } +fn copying_caret(s: &Session) -> bool { + s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range())) +} + fn copy(s: &mut Session, p: &Value) -> Result { + // A caret copies nothing; the previous clipboard must not leak into this answer. + let caret = copying_caret(s); let t = copy_inner(s, p)?; - Ok(json!({"text": t})) + let html = if caret { String::new() } else { copied_html(s) }; + Ok(json!({"text": t, "html": html})) +} + +/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes). +fn copied_html(s: &Session) -> String { + if let Some(body) = &s.clipboard.text { + return richclip::body_to_html(body); + } + let mut paragraphs = Vec::new(); + for sh in &s.clipboard.shapes { + if let Some(t) = &sh.text { + paragraphs.extend(t.paragraphs.clone()); + } + } + if paragraphs.is_empty() { + return String::new(); + } + richclip::body_to_html(&TextBody { paragraphs, ..Default::default() }) } fn cut(s: &mut Session, p: &Value) -> Result { + let caret = copying_caret(s); let t = copy_inner(s, p)?; + let html = if caret { String::new() } else { copied_html(s) }; delete(s, p)?; - Ok(json!({"text": t})) + Ok(json!({"text": t, "html": html})) } /// Give pasted shapes fresh ids (groups included) and map connector ends. @@ -157,9 +192,16 @@ fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model fn paste(s: &mut Session, p: &Value) -> Result { // External plain text (from the system clipboard) wins when given and differs from ours. + // HTML from Pages or Keynote keeps bold, italic and underline when it says the same words. if let Some(t) = str_param(p, "text") && t != s.clipboard.plain { + if let Some(html) = str_param(p, "html") + && let Some(body) = richclip::html_to_body(html) + && richclip::same_words(&body.text(), t) + { + return paste_external_body(s, &body); + } return paste_plain(s, t); } let editing = s.doc()?.selection.text.is_some(); @@ -228,6 +270,33 @@ fn paste(s: &mut Session, p: &Value) -> Result { ok() } +fn paste_external_body(s: &mut Session, body: &TextBody) -> Result { + if s.doc()?.selection.text.is_some() { + return super::text::paste_body(s, body); + } + let plain = body.text(); + let st = s.doc()?; + let size = st.doc.slide_size; + let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0]; + let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?; + let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?; + let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?; + let body = body.clone(); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?; + let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?; + if let Some(t) = sh.text.as_mut() { + let (wrap, autofit) = (t.body.wrap, t.body.autofit); + *t = body.clone(); + t.body.wrap = wrap; + t.body.autofit = autofit; + } + sel.shapes = vec![ShapeId(id)]; + sel.text = None; + Ok(json!({"id": id})) + }) +} + fn paste_plain(s: &mut Session, text: &str) -> Result { if s.doc()?.selection.text.is_some() { return super::text::insert_text(s, text, None); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..98d26b47 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ pub mod inspect; pub mod media; pub mod merge; pub mod review; +mod richclip; pub mod selection; pub mod shape; pub mod slide; diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs new file mode 100644 index 00000000..69f70686 --- /dev/null +++ b/crates/engine/src/cmd/richclip.rs @@ -0,0 +1,346 @@ +//! Rich text on the system clipboard, as HTML. +//! +//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes +//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive. +//! Anything else in the markup is skipped. A hostile fragment is capped, never executed. + +use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody}; + +const MAX_HTML: usize = 1 << 20; +const MAX_PARAS: usize = 2_000; +const MAX_RUNS: usize = 10_000; + +/// HTML for `body`: one `

` per paragraph, ``, `` and `` for the formatting. +pub fn body_to_html(body: &TextBody) -> String { + let mut out = String::new(); + for (i, p) in body.paragraphs.iter().enumerate() { + if i > 0 { + out.push_str("
"); + } + out.push_str("

"); + for r in &p.runs { + let (open, close) = tags(&r.props); + out.push_str(&open); + match &r.kind { + deckcraft_model::text::RunKind::Break => out.push_str("
"), + _ => push_escaped(&mut out, &r.text), + } + out.push_str(&close); + if out.len() > MAX_HTML { + break; + } + } + out.push_str("

"); + } + out +} + +fn tags(props: &RunProps) -> (String, String) { + let mut open = String::new(); + let mut close = String::new(); + if props.bold == Some(true) { + open.push_str(""); + close.insert_str(0, ""); + } + if props.italic == Some(true) { + open.push_str(""); + close.insert_str(0, ""); + } + if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) { + open.push_str(""); + close.insert_str(0, ""); + } + (open, close) +} + +fn push_escaped(out: &mut String, text: &str) { + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } +} + +/// The formatted text of an HTML fragment, or `None` when it has no text. +/// +/// Understands ``, ``, ``, ``, ``, `
`, paragraphs, and a `style` that +/// says bold, italic or underline. Other tags are ignored. The result is capped. +pub fn html_to_body(html: &str) -> Option { + if html.len() > MAX_HTML { + return None; + } + let bytes = html.as_bytes(); + let mut i = 0; + let mut bold = 0i32; + let mut italic = 0i32; + let mut underline = 0i32; + let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new(); + let mut paragraphs: Vec = vec![Paragraph::default()]; + let mut runs = 0usize; + let mut skip = 0i32; + while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS { + if bytes[i] == b'<' { + let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len()); + let raw = &html[i + 1..end]; + i = (end + 1).min(bytes.len()); + let token = raw.trim(); + if token.starts_with('!') || token.starts_with('?') { + continue; + } + let closing = token.starts_with('/'); + let name_src = token.trim_start_matches('/').trim(); + let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len()); + let name = name_src[..name_end].to_ascii_lowercase(); + if name == "script" || name == "style" || name == "head" { + if closing { + skip = (skip - 1).max(0); + } else if !token.ends_with('/') { + skip += 1; + } + continue; + } + if skip > 0 { + continue; + } + if closing { + let kind = tag_kind(&name); + if !kind.is_empty() + && let Some(pos) = stack.iter().rposition(|e| e.0 == kind) + { + let (_, db, di, du) = stack[pos]; + bold -= db; + italic -= di; + underline -= du; + stack.truncate(pos); + } + if is_block(&name) { + new_para(&mut paragraphs); + } + continue; + } + if is_break(&name) { + new_para(&mut paragraphs); + continue; + } + let (db, di, du) = tag_style(&name, name_src); + if db + di + du > 0 { + bold += db; + italic += di; + underline += du; + if !token.ends_with('/') { + stack.push((tag_kind(&name), db, di, du)); + } else { + bold -= db; + italic -= di; + underline -= du; + } + } + continue; + } + if skip > 0 { + i += 1; + continue; + } + let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len()); + let text = decode_text(&html[i..end]); + i = end; + if text.is_empty() { + continue; + } + push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0); + } + while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 { + paragraphs.pop(); + } + if paragraphs.iter().all(Paragraph::is_empty) { + return None; + } + for p in &mut paragraphs { + p.normalize(); + } + Some(TextBody { paragraphs, ..Default::default() }) +} + +fn tag_kind(name: &str) -> &'static str { + match name { + "b" | "strong" => "b", + "i" | "em" => "i", + "u" => "u", + "span" | "font" => "span", + "p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block", + _ => "", + } +} + +fn is_break(name: &str) -> bool { + name == "br" || name == "hr" +} + +fn is_block(name: &str) -> bool { + matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote") +} + +fn tag_style(name: &str, src: &str) -> (i32, i32, i32) { + let mut bold = i32::from(name == "b" || name == "strong"); + let mut italic = i32::from(name == "i" || name == "em"); + let mut underline = i32::from(name == "u"); + if let Some(style) = attr(src, "style") { + let s = style.to_ascii_lowercase(); + if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) { + bold = 1; + } + if s.contains("font-style:italic") || s.contains("font-style: italic") { + italic = 1; + } + if s.contains("text-decoration:underline") + || s.contains("text-decoration: underline") + || s.contains("text-decoration-line:underline") + || s.contains("text-decoration-line: underline") + { + underline = 1; + } + } + (bold, italic, underline) +} + +fn weight_bold(style: &str) -> bool { + let Some(at) = style.find("font-weight") else { return false }; + let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace()); + rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9') +} + +fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> { + let lower = tag.to_ascii_lowercase(); + let at = lower.find(key)?; + let after = tag.get(at + key.len()..)?.trim_start(); + let after = after.strip_prefix('=')?.trim_start(); + let quote = after.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let rest = after.get(quote.len_utf8()..)?; + let end = rest.find(quote)?; + rest.get(..end) +} + +fn new_para(paragraphs: &mut Vec) { + if paragraphs.len() >= MAX_PARAS { + return; + } + if paragraphs.last().is_some_and(|p| !p.is_empty()) { + paragraphs.push(Paragraph::default()); + } +} + +fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) { + if *runs >= MAX_RUNS { + return; + } + let Some(p) = paragraphs.last_mut() else { return }; + let mut props = RunProps::default(); + if bold { + props.bold = Some(true); + } + if italic { + props.italic = Some(true); + } + if underline { + props.underline = Some("sng".into()); + } + p.runs.push(Run::with(text, props)); + *runs += 1; +} + +fn decode_text(raw: &str) -> String { + let mut out = String::new(); + let mut rest = raw; + while let Some(at) = rest.find('&') { + out.push_str(&rest[..at].replace(['\n', '\r'], "")); + rest = &rest[at..]; + let Some(end) = rest.find(';') else { + out.push_str(&rest.replace(['\n', '\r'], "")); + break; + }; + let entity = &rest[1..end]; + rest = &rest[end + 1..]; + match entity { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + "nbsp" => out.push(' '), + _ => { + let ch = entity.strip_prefix('#').and_then(numeric_char); + if let Some(c) = ch { + out.push(c); + } + } + } + } + if !rest.is_empty() && !rest.starts_with('&') { + out.push_str(&rest.replace(['\n', '\r'], "")); + } + out +} + +fn numeric_char(digits: &str) -> Option { + let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X')); + let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? }; + char::from_u32(n).filter(|c| !c.is_control() || *c == '\n') +} + +/// Do the HTML's words and the paste's plain text say the same thing? +pub fn same_words(html_plain: &str, pasted: &str) -> bool { + let a = fold(html_plain); + let b = fold(pasted); + a == b +} + +fn fold(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bold_italic_and_underline_round_trip() { + let mut body = TextBody::from_text("Hi there"); + body.paragraphs[0].runs = vec![ + Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }), + Run::new(" "), + Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }), + ]; + let html = body_to_html(&body); + assert!(html.contains("Hi"), "{html}"); + assert!(html.contains("there") || html.contains("there"), "{html}"); + let back = html_to_body(&html).unwrap(); + assert_eq!(back.text(), "Hi there"); + assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true)); + let last = back.paragraphs[0].runs.last().unwrap(); + assert_eq!(last.props.italic, Some(true)); + assert!(last.props.underline.is_some()); + } + + #[test] + fn pages_style_and_escapes() { + let html = "

A & B

C

"; + let body = html_to_body(html).unwrap(); + assert_eq!(body.text(), "A & B\nC"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true)); + assert!(same_words(&body.text(), "A & B\nC")); + assert!(!same_words(&body.text(), "other")); + } + + #[test] + fn a_huge_fragment_is_refused() { + let html = "x".repeat(MAX_HTML + 1); + assert!(html_to_body(&html).is_none()); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..57b66c09 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -93,6 +93,62 @@ fn clipboard_copy_paste_shapes_and_slides() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +#[test] +fn bold_copy_is_html_and_pages_html_pastes_bold() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + let v = s.execute("edit.copy", &json!({})).unwrap(); + let html = v["html"].as_str().unwrap(); + assert!(html.contains(""), "{html}"); + assert_eq!(v["text"], "Hello"); + // The same plain words are our own clipboard, so a plainer HTML fragment is not reparsed. + s.execute("text.select", &json!({"anchor": [0, 5], "caret": [0, 5]})).unwrap(); + s.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.clone().unwrap(); + assert_eq!(body.text(), "HelloHello"); + assert!(body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| r.props.bold == Some(true)), "{body:?}"); + s.execute("text.select", &json!({"anchor": [0, 10], "caret": [0, 10]})).unwrap(); + let caret = s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(caret["text"], ""); + assert_eq!(caret["html"], ""); + + let mut ext = session(); + ext.execute("edit.paste", &json!({"text": "Hello", "html": "

Hello

"})).unwrap(); + let pasted = ext + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello")) + .unwrap() + .text + .clone() + .unwrap(); + assert_eq!(pasted.paragraphs[0].runs[0].props.bold, Some(true), "{pasted:?}"); + + let mut mismatched = session(); + mismatched.execute("edit.paste", &json!({"text": "Hi", "html": "Nope"})).unwrap(); + let plain = mismatched + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .unwrap() + .text + .clone() + .unwrap(); + assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true)); +} + #[test] fn group_ungroup_roundtrip_positions() { let mut s = session(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..e9ac442e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2107,6 +2107,49 @@ fn draw_glyph_with( } } +/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). +pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { + if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { + return None; + } + let rctx = Ctx::for_slide(pres, slide)?; + let mut items = Vec::new(); + let mut bounds: Option = None; + for id in ids.iter().take(256) { + let Some(s) = slide.shape(*id) else { continue }; + let b = resolve::xfrm(&rctx, s).bounds(); + if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { + continue; + } + bounds = Some(match bounds { + Some(u) => u.union(b), + None => b, + }); + items.push(s); + } + let b = bounds?.inflate(8.0, 8.0); + if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { + return None; + } + let fit = (2048.0 / b.width().max(b.height())).min(scale); + if !(fit.is_finite() && fit > 0.0) { + return None; + } + let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; + let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale: fit, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; + let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + for s in items { + r.shape(&mut ctx, &f, &rctx, s, view, 0); + } + Some(r.finish(ctx, w, h)) + }) +} + /// Render one shape alone onto a transparent canvas (drag previews, copy as picture). pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { let rctx = Ctx::for_slide(pres, slide)?; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..2c469218 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -35,6 +35,22 @@ fn shape_fill_uses_theme_accent() { assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); } +#[test] +fn render_shapes_draws_the_selection_on_a_transparent_canvas() { + let sh = Shape { + id: ShapeId(880), + xfrm: Some(Xfrm::new(100.0, 80.0, 60.0, 40.0)), + fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))), + line: Some(deckcraft_model::Line::none()), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_shapes(&p, &p.slides[0], &[ShapeId(880)], 1.0).expect("picture"); + let mid = img.pixel(img.width / 2, img.height / 2); + assert!(mid[0] > 200 && mid[1] < 40 && mid[3] > 200, "{mid:?}"); + assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0)); +} + #[test] fn text_draws_ink() { let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..7cf2fde3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,6 +60,10 @@ pub struct Services { pub inbox: Option, /// Image on the system clipboard (PNG bytes), when the host can read one. pub clipboard_image: Option Option>>>, + /// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`). + pub clipboard_html: Option Option>>, + /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed. + pub clipboard_set_html: Option bool>>, /// Audio output for media playback (cpal on desktop); without one media plays silently. pub audio_out: Option>, } @@ -881,28 +885,24 @@ impl SlideApp { } } egui::Event::Ime(ime) => self.ime_event(&ime), - egui::Event::Copy => { - if let Ok(v) = self.session.execute("edit.copy", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } - } - egui::Event::Cut => { - if let Ok(v) = self.session.execute("edit.cut", &json!({})) - && let Some(t) = v.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } - } + egui::Event::Copy => self.publish_clipboard(ctx, false), + egui::Event::Cut => self.publish_clipboard(ctx, true), egui::Event::Paste(text) => { - // An image on the clipboard (screenshots, copied pictures) becomes a picture. + // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. + // Text keeps its HTML (bold, italic, underline) when the host can read it. + let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let r = match img { Some(png) => self .session .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), - None => self.session.execute("edit.paste", &json!({"text": text})), + None => { + let mut params = json!({"text": text}); + if let Some(h) = html { + params["html"] = Value::String(h); + } + self.session.execute("edit.paste", ¶ms) + } }; if let Err(e) = r { self.set_status(e.to_string()); @@ -925,6 +925,61 @@ impl SlideApp { self.drain_requests(); } + /// Offer the copy or cut on the system clipboard. + /// + /// Slides go out as plain text. A textless shape goes out as a picture (Keynote and Pages + /// paste that). Text goes out as HTML plus the same plain words, so a paste back into + /// DeckCraft still uses the internal clipboard. + fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { + // Cut deletes the shapes, so the picture is taken first. + let picture = self.textless_selection_image(); + let cmd = if cut { "edit.cut" } else { "edit.copy" }; + let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; + let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); + if !self.session.clipboard.slides.is_empty() { + ctx.copy_text(text); + return; + } + if let Some(img) = picture { + ctx.copy_image(img); + return; + } + if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) { + return; + } + ctx.copy_text(text); + } + + /// A picture of the selected shapes when none of them has text, on the slide (not a master). + fn textless_selection_image(&self) -> Option { + let st = self.session.active()?; + if st.selection.target != deckcraft_engine::Target::Slides || st.selection.text.is_some() || st.selection.shapes.is_empty() { + return None; + } + let mut plain = String::new(); + let mut any = false; + for id in &st.selection.shapes { + let Some(sh) = st.shape(*id) else { continue }; + any = true; + if let Some(t) = &sh.text { + if !plain.is_empty() { + plain.push('\n'); + } + plain.push_str(&t.text()); + } + } + if !any || !plain.is_empty() { + return None; + } + let slide = st.current_slide()?; + let img = deckcraft_render::render_shapes(&st.doc, slide, &st.selection.shapes, 2.0)?; + if img.width == 0 || img.height == 0 { + return None; + } + Some(textures::to_color_image(&img, false)) + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1408,4 +1463,71 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), services), + ); + h.run_steps(2); + h + } + + /// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. + #[test] + fn copy_offers_html_and_paste_keeps_bold() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(None)); + let record = seen.clone(); + let services = Services { + clipboard_set_html: Some(Box::new(move |html, plain| { + *record.borrow_mut() = Some((html.to_string(), plain.to_string())); + true + })), + ..Default::default() + }; + let mut h = step_app(services); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + h.event(egui::Event::Text("Hi".into())); + h.run_steps(1); + h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let (html, plain) = seen.borrow().clone().expect("html offered"); + assert!(html.contains(""), "{html}"); + assert_eq!(plain, "Hi"); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); + + let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; + let mut h = step_app(services); + h.event(egui::Event::Paste("Hi".into())); + h.run_steps(1); + let st = h.state().session.active().unwrap(); + let body = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .and_then(|sh| sh.text.clone()) + .expect("pasted text"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); + } + + /// A shape with no text copies out as a picture. + #[test] + fn a_textless_shape_copies_as_a_picture() { + let mut h = step_app(Services::default()); + h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let cmds = &h.output().platform_output.commands; + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyImage(img) if img.size[0] > 0 && img.size[1] > 0)), "{cmds:?}"); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..dec16e32 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,8 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **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 @@ -85,7 +85,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | -| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | +| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), a textless shape copies out as a picture, and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | ## Keyboard shortcuts @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) and a picture of a textless shape with other apps | | 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 | From 0609ca01cbf7a0d1828c08869f907d758f8fe8ee Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:27:15 -0400 Subject: [PATCH 52/69] Fix up merged PRs: right-click on the edited text box's frame selects that box Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 76f45079..25f71b9f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -998,7 +998,8 @@ impl Session { | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } - | Hit::Vertex { id, .. }, + | Hit::Vertex { id, .. } + | Hit::Frame { id }, ) => Some(*id), None => None, }; From 2dc22843e4296af733ef792c3da9bb4821951904 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:31:11 -0400 Subject: [PATCH 53/69] Fix up merged PRs: the missing-recent test reads main's toast, which now carries open errors Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6ebaeedc..fec400d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1939,7 +1939,7 @@ mod tests { // drawn message is a second node that mentions the name (the OS text after it varies). harness.get_by_label("missing.deckcraft").click(); harness.run_steps(2); - assert!(harness.state().status.is_some(), "the open error was raised"); + assert!(harness.state().toast.is_some(), "the open error was raised"); assert!( harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry" From 2a410e54fa1635733d2af8be98e0a00cd3f7fa7c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:37:30 -0400 Subject: [PATCH 54/69] Fix up merged PRs: main's newer textures (document window, SmartArt previews, show grid) go through textures::upload so they stay within the GPU max texture side (#652) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/doc_window.rs | 2 +- crates/ui-egui/src/show.rs | 2 +- crates/ui-egui/src/textures.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs index 4445550d..dfff3d4b 100644 --- a/crates/ui-egui/src/doc_window.rs +++ b/crates/ui-egui/src/doc_window.rs @@ -164,7 +164,7 @@ fn ensure_texture( slide, &RenderOpts { scale: w as f64 / doc.slide_size.width.max(1.0), size: Some((w, h)), edit: true, threads: 0, ..Default::default() }, ); - let tex = ctx.load_texture("doc-window-slide", crate::textures::to_color_image(&img, grayscale), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ctx, "doc-window-slide", crate::textures::to_color_image(&img, grayscale)); if let Some(win) = app.doc_window.as_mut() { win.key = Some(key); win.tex = Some(tex.clone()); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 6406aacb..6e6268ad 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1689,7 +1689,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi i, &RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() }, ); - let tex = ctx.load_texture(format!("show-grid-{i}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false)); show.thumbs.push((i, tex.clone())); Some(tex) } diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 629f935a..9f52918a 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -89,7 +89,7 @@ impl Textures { let id = g.id; s.shapes = vec![g]; let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?; - let tex = ctx.load_texture(format!("smartart-{key}"), to_color_image(&img, false), TextureOptions::LINEAR); + let tex = upload(ctx, format!("smartart-{key}"), to_color_image(&img, false)); if self.smartart.len() > 300 { self.smartart.clear(); } From 7acb9faad1c90516cc5e883b29a8da6c179ce67b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:38:03 -0400 Subject: [PATCH 55/69] Fix up merged PRs: one format.tabs and one Tabs dialog for #567/#569 and #637 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #569 and #637 each added a `format.tabs` command and a Tabs dialog. Keep one of each: - format.tabs takes #567/#569's add/move/remove/clear/set plus #637's strict `set` (unknown alignment or non-object entries are errors, stops within 0.05 pt collapse, last wins) and opens the Tabs dialog when called with no fields, as #637 did. A defaultTab-only call no longer rewrites the stops. - The Tabs dialog is #569's; it now seeds from the dialog's params when Paragraph (or format.tabs) opened it, and on OK hands the applied stops back to Paragraph. - Tests adapted: an empty format.tabs opens the dialog; the Paragraph → Tabs test sets stops through #569's dialog fields. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/ruler_tests.rs | 4 +++- crates/ui-egui/src/dialogs.rs | 8 +++++++- crates/ui-egui/src/paragraph_dialog_tests.rs | 3 +-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs index 95c87c6c..e920ce93 100644 --- a/crates/engine/src/cmd/ruler_tests.rs +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -50,7 +50,9 @@ fn tabs_add_move_remove_clear_and_undo() { assert_eq!(tabs(&mut s).len(), 2); s.execute("format.tabs", &json!({"clear": true})).unwrap(); assert!(tabs(&mut s).is_empty()); - assert!(s.execute("format.tabs", &json!({})).is_err()); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog"); assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); for i in 0..100 { s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb890e1f..e570233b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2773,7 +2773,9 @@ const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "R /// list (inches, as PowerPoint's dialog) and applied together with OK. fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !d.fields.contains_key("stops") { - let r = app.session.execute("format.ruler", &json!({})).unwrap_or_default(); + // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the + // dialog; otherwise they are read from the caret's paragraph. + let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; let stops: Vec = r["tabs"] .as_array() .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) @@ -2846,6 +2848,10 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { app.set_status(e); + } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { + // Back in Paragraph, its Tabs… shows what was just applied. + ret.insert("tabs".into(), json!(set)); + ret.insert("defaultTab".into(), json!(dt)); } } ok || cancel diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs index cba8fe53..fb8e796b 100644 --- a/crates/ui-egui/src/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -71,8 +71,7 @@ fn tabs_from_paragraph_applies_a_stop_and_returns() { assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); { let d = h.state_mut().dialog.as_mut().unwrap(); - d.params["editing"] = json!(true); - d.params["edit"] = json!([{"pos": 144.0, "align": "ctr"}]); + d.fields.insert("stops".into(), "2:ctr".into()); } h.get_by_label("OK").click_accesskit(); // The Tabs OK frame restores Paragraph; the next frame fills its fields. From 3b28496c36dda4f724d26c3ecc1d137ef2dda61d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:40:09 -0400 Subject: [PATCH 56/69] Fix up merged PRs: main's newer params docs fit describe's grammar; help pages for bench and render-transition file.export's slide range and transition.sound's keys parse as parameters, and the subcommands main added since #303 have their own help page. Co-Authored-By: Claude Opus 5.5 --- apps/deckcraft-cli/src/help.rs | 24 ++++++++++++++++++++++++ apps/deckcraft-cli/tests/cli.rs | 2 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/transition.rs | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 4245172a..5803dc81 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -30,6 +30,20 @@ slide. Prints each image's path and size. --edit draw what the editor shows: placeholder prompts and their dashed outlines deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", + }, + Subcommand { + name: "render-transition", + usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png", + summary: "draw frames of the transition into a slide as PNG images", + details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N), +as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as +OUT-t0.250.png and so on. Prints each image's path, size, transition and time. + + --slide N the slide the transition leads into; indices start at 0 + --t T,T... the times to draw, comma-separated + --width PX the image width in pixels, twice the slide width in points by default + + deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png", }, Subcommand { name: "convert", @@ -114,6 +128,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT. --sample opens the sample presentation.", + }, + Subcommand { + name: "bench", + usage: "bench (FILE | --sample) [--runs N] [--json]", + summary: "time opening, drawing, saving and exporting a presentation", + details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to +save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the +median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json. + + deckcraft-cli bench deck.pptx --runs 10", }, Subcommand { name: "links", diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs index 3f1ed98b..8932c909 100644 --- a/apps/deckcraft-cli/tests/cli.rs +++ b/apps/deckcraft-cli/tests/cli.rs @@ -14,7 +14,7 @@ fn stderr(o: &Output) -> String { String::from_utf8_lossy(&o.stderr).into_owned() } -const SUBCOMMANDS: [&str; 9] = ["info", "render", "convert", "run", "commands", "describe", "app", "mcp", "links"]; +const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"]; fn words(s: &str) -> String { s.split_whitespace().collect::>().join(" ") diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7f47d742..9b1030d7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|\"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?}", has_doc, export), cmd!( query "file.info", "Info", diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs index 4c798125..4047faf2 100644 --- a/crates/engine/src/cmd/transition.rs +++ b/crates/engine/src/cmd/transition.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Sound", ["Transitions", "Timing"], None, - "{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?; none?: true (No Sound); stopPrevious?: true; loop?: bool (Loop Until Next Sound); index?} — the sound plays as the slide comes in", + "{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?, none?: true (No Sound), stopPrevious?: true, loop?: bool (Loop Until Next Sound), index?} — the sound plays as the slide comes in", has_slide, sound ), From 7dcfe0f989ffe4fb573162a39a349e07f1887069 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:40:18 -0400 Subject: [PATCH 57/69] Fix up merged PRs: a textless shape still copies as the objects marker so Ctrl+V pastes it (#182), not as a picture (#702) With only an image on the system clipboard egui-winit drops Ctrl+V, so #702's picture copy of a textless shape undid main's #182 fix. Text keeps #702's HTML, and empty plain text falls back to the marker. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/lib.rs | 58 ++++++++------------------------------- docs/ui-parity.md | 4 +-- 3 files changed, 15 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3883b475..ce2d2b7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -210,7 +210,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` | | 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% | | 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero | -| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them), and a shape with no text copies out as a picture | +| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) | | 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 | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0ad85c8..d5f3e36f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1120,59 +1120,24 @@ impl SlideApp { /// Offer the copy or cut on the system clipboard. /// - /// Slides go out as plain text. A textless shape goes out as a picture (Keynote and Pages - /// paste that). Text goes out as HTML plus the same plain words, so a paste back into - /// DeckCraft still uses the internal clipboard. + /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste + /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the + /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard + /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { - // Cut deletes the shapes, so the picture is taken first. - let picture = self.textless_selection_image(); let cmd = if cut { "edit.cut" } else { "edit.copy" }; let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); - if !self.session.clipboard.slides.is_empty() { - ctx.copy_text(system_clipboard_text(&text)); - return; - } - if let Some(img) = picture { - ctx.copy_image(img); - return; - } - if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) { + if self.session.clipboard.slides.is_empty() + && !html.is_empty() + && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + { return; } ctx.copy_text(system_clipboard_text(&text)); } - /// A picture of the selected shapes when none of them has text, on the slide (not a master). - fn textless_selection_image(&self) -> Option { - let st = self.session.active()?; - if st.selection.target != deckcraft_engine::Target::Slides || st.selection.text.is_some() || st.selection.shapes.is_empty() { - return None; - } - let mut plain = String::new(); - let mut any = false; - for id in &st.selection.shapes { - let Some(sh) = st.shape(*id) else { continue }; - any = true; - if let Some(t) = &sh.text { - if !plain.is_empty() { - plain.push('\n'); - } - plain.push_str(&t.text()); - } - } - if !any || !plain.is_empty() { - return None; - } - let slide = st.current_slide()?; - let img = deckcraft_render::render_shapes(&st.doc, slide, &st.selection.shapes, 2.0)?; - if img.width == 0 || img.height == 0 { - return None; - } - Some(textures::to_color_image(&img, false)) - } - /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1943,14 +1908,15 @@ mod tests { assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); } - /// A shape with no text copies out as a picture. + /// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can + /// still paste it here (#182). #[test] - fn a_textless_shape_copies_as_a_picture() { + fn a_textless_shape_copies_as_the_objects_marker() { let mut h = step_app(Services::default()); h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); h.event(egui::Event::Copy); h.run_steps(1); let cmds = &h.output().platform_output.commands; - assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyImage(img) if img.size[0] > 0 && img.size[1] > 0)), "{cmds:?}"); + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e72bc696..768512f2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -133,7 +133,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | | Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | -| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), a textless shape copies out as a picture, and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | +| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | ## Keyboard shortcuts @@ -223,7 +223,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 | | 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout | | 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip | -| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) and a picture of a textless shape with other apps | +| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps | | 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 | From 740a57ddbabad8da84a1ad4d709274a80fb4ea65 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:43:36 -0400 Subject: [PATCH 58/69] Fix up merged PRs: table borders under a right-click select the table; the cell caret is drawn once Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 4 +++- crates/ui-egui/src/canvas.rs | 11 ----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 97a4cfb4..9c0dcabc 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1025,7 +1025,9 @@ impl Session { | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } | Hit::Vertex { id, .. } - | Hit::Frame { id }, + | Hit::Frame { id } + | Hit::TableColumn { id, .. } + | Hit::TableRow { id, .. }, ) => Some(*id), None => None, }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3c504f3d..33553a83 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -612,17 +612,6 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] .map(|q| xf.to_screen(f.affine * q)); poly(painter, pts, Stroke::new(2.0, t.accent)); - // The composition is in the cell text; underline it where that text is drawn (#101). - for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { - painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret)); - } - if !ts.is_range() { - let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); - if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { - painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret)); - } - } } // Smart guides, marquee, previews. for g in &app.session.tool.guides { From 526e31d1f7d6c582805b7237cd52c928f7afa051 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:44:40 -0400 Subject: [PATCH 59/69] Fix up merged PRs: a picked file remembers main's Handout and Notes Master views (#479) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a19f7c7..dfc6a443 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -96,6 +96,8 @@ enum FilePlace { master: MasterId, layout: LayoutId, }, + /// The handout or notes master (one per presentation, so no id is needed). + NotesPage(Target), /// The document was open but had no current slide, master or layout. Unspecified, } @@ -1169,6 +1171,7 @@ impl SlideApp { }, None => FilePlace::Unspecified, }, + t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), }; FileTarget::Document { doc: d.uid, place } } @@ -1189,6 +1192,7 @@ impl SlideApp { matches!(d.selection.target, Target::Layout { master: mi, layout: li } if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) } + FilePlace::NotesPage(t) => d.selection.target == *t, FilePlace::Unspecified => false, } } @@ -1293,6 +1297,7 @@ impl SlideApp { .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) } + FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), FilePlace::Unspecified => Err("there was no slide to put it on".into()), } } From e90e26545388df50393872fec36db652a2c76ee0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:45:12 -0400 Subject: [PATCH 60/69] Fix up merged PRs: Grid Options guides follow #559, and its dialog test finds its own Spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turning guides on in Grid Options (#536) now adds the two centre guides to a presentation without any, as View › Guides does since #559 stopped drawing fixed centre lines. - The Grid Options dialog test looked for any "Spacing" label; main's ribbon has Line Spacing too, so it looks for "Spacing (pt)". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f8f188b1..d27f163c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -588,6 +588,10 @@ impl SlideApp { } if let Some(b) = guides { self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } } } else { self.dialog = Some(dialogs::Dialog::new("gridOptions")); @@ -1690,7 +1694,7 @@ mod tests { harness.run_steps(2); assert!(harness.query_by_label_contains("Display grid on screen").is_some()); assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); - assert!(harness.query_by_label_contains("Spacing").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); let show_grid = harness .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") .next() From 560928af94e9149cbc44d4e46355cf42421a8672 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:46:41 -0400 Subject: [PATCH 61/69] Fix up merged PRs: cargo fmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dfc6a443..e5b5b884 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,8 +19,8 @@ pub mod icons; pub mod media; pub mod menus; pub mod panes; -pub mod qat; mod preview; +pub mod qat; pub mod ribbon; pub mod selpane; pub mod settings; @@ -1524,9 +1524,7 @@ impl SlideApp { let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); - if self.session.clipboard.slides.is_empty() - && !html.is_empty() - && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) { return; } From e6862f18ae91969a98a0305e272e0971c1d707e8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:49:10 -0400 Subject: [PATCH 62/69] Fix up merged PRs: WordArt warp handles on main's warp catalog and hover hit list - #519's handle test iterated the old `ALL` preset list; main has `CATALOG` now. - The hover hit-test match lists every Hit variant; add #519's Hit::Warp. - Drop the duplicate "tabs" dialog title that #569 and #637 both added. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 3 ++- crates/geom/src/warp/tests.rs | 2 +- crates/ui-egui/src/dialogs.rs | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 418c1de4..7dbf9038 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1104,7 +1104,8 @@ impl Session { | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } - | Hit::Vertex { id, .. }, + | Hit::Vertex { id, .. } + | Hit::Warp { id, .. }, ) => Some(*id), None => None, }; diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index ac231cf0..c04ddd10 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -281,7 +281,7 @@ fn hostile_adjusts_and_boxes_stay_finite() { #[test] fn every_preset_has_one_handle_per_adjust() { let r = rect(); - for name in ALL { + for name in CATALOG.iter().map(|p| p.id) { let hs = handles(name, &[], r); assert_eq!(hs.len(), adjust_count(name), "{name}"); let defaults = adjust_defaults(name); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e3b23245..c3010053 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -47,7 +47,6 @@ fn title(id: &str) -> &'static str { "paragraph" => "Paragraph", "tabs" => "Tabs", "font" => "Font", - "tabs" => "Tabs", "about" => "About DeckCraft", "preferences" => "Preferences", "gridOptions" => "Grid Options", From 69e212e3afef295f83972533e53739d21e72562b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:50:49 -0400 Subject: [PATCH 63/69] Fix up merged PRs: the IME notes test reads the formatted draft's text; drop an unused import Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ime_tests.rs | 4 ++-- crates/ui-egui/src/sorter.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index 911a2d1a..c9bc7dab 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -132,14 +132,14 @@ fn korean_composes_in_the_notes_pane() { for pre in ["ㄴ", "노", "놑", "노트"] { h.event(preedit(pre)); h.run_steps(1); - assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one"); + assert_eq!(h.state().notes_buf.2.text, pre, "the preedit replaces the last one"); } let ime = h.output().platform_output.ime.expect("the notes field turns the input method on"); let slide = h.state().slide_rect.unwrap(); assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide"); h.event(Event::Ime(ImeEvent::Commit("노트".into()))); h.run_steps(1); - assert_eq!(h.state().notes_buf.2, "노트"); + assert_eq!(h.state().notes_buf.2.text, "노트"); // Focus leaves the field (Escape): the notes are committed as one step. h.key_press(egui::Key::Escape); h.run_steps(2); diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 03afb638..e33842a0 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; -use crate::{SlideApp, widgets}; +use crate::SlideApp; /// Sorter-only state. Nothing here is part of the document. #[derive(Debug, Default)] From ed13dd6158fb403f943fe65770ff96ed0ab3f322 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:54:35 -0400 Subject: [PATCH 64/69] Fix up merged PRs: GIF export goes through main's export path The Export dialog's GIF choice runs through SlideApp::export_as (desktop and web download), .gif is a writable output extension for convert/run, and the CLI help pages describe GIF output and --export-params. A file.export `slides` range string ("1-3,5") works for GIF as for PDF. Co-Authored-By: Claude Opus 5.5 --- apps/deckcraft-cli/src/help.rs | 11 +++++++---- apps/deckcraft-cli/tests/convert.rs | 2 +- crates/engine/src/tests.rs | 3 ++- crates/ui-egui/src/lib.rs | 2 ++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 5803dc81..d6158f46 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -50,19 +50,22 @@ OUT-t0.250.png and so on. Prints each image's path, size, transition and time. usage: "convert IN OUT", summary: "save a presentation in the format OUT's extension names", details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names: -.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide. Any other -extension is written as .deckcraft. Prints OUT. +.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an +animated GIF with one frame per slide. Other extensions are refused. Prints OUT. deckcraft-cli convert deck.deckcraft deck.pptx", }, Subcommand { name: "run", - usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT] [--print]", + usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]", summary: "run commands on a presentation, then save or export it", details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample, else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the -current slide as .png or .jpg, or the presentation as .pdf or .pptx. +current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per +slide). --export-params passes file.export options as one JSON object, such as +'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF +(`deckcraft-cli describe file.export` lists them). Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one JSON object. Quote it for the shell. A command that needs no parameters is just its id. diff --git a/apps/deckcraft-cli/tests/convert.rs b/apps/deckcraft-cli/tests/convert.rs index 5de25e51..4f162994 100644 --- a/apps/deckcraft-cli/tests/convert.rs +++ b/apps/deckcraft-cli/tests/convert.rs @@ -21,7 +21,7 @@ fn convert_rejects_output_formats_it_cannot_write() { let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - for name in ["out.odp", "out.gif", "out.svg", "out.outline", "out"] { + for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] { let target = dir.join(name); let out = cli(&[Path::new("convert"), &input, &target]); assert!(!out.status.success(), "{name}: convert should fail"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e0d71985..a81450a0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -237,7 +237,8 @@ fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() { assert_eq!(f("a.pdf"), Some("pdf")); assert_eq!(f("a.jpg"), Some("jpeg")); assert_eq!(f("a.txt"), Some("outline")); - for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] { + assert_eq!(f("a.gif"), Some("gif")); + for path in ["a.odp", "a.bmp", "a.svg", "a.outline", "a", "dir.v2/a"] { assert_eq!(f(path), None, "{path}"); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a9876e0a..34545650 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -506,6 +506,7 @@ impl SlideApp { "pptx" => ("pptx", "pptx", false), "outline" | "txt" => ("txt", "outline", false), "deckcraft" => ("deckcraft", "deckcraft", false), + "gif" => ("gif", "gif", false), other => { self.set_status(format!("DeckCraft can't export “{other}”")); return false; @@ -535,6 +536,7 @@ impl SlideApp { Ok(if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }) } "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), + "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), other => deckcraft_engine::cmd::file::save_bytes(&doc, other), }; self.download(&suggested, bytes) From 330fc16c8cb36874dba497632302a4d37d6e43e2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:02:43 -0400 Subject: [PATCH 65/69] Fix up merged PRs: zoom anchors on the unsnapped slide rect; zoom tests allow the pixel-grid snap Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 36317c1d..84a5a84a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -109,12 +109,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); - // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). + // On the physical pixel grid, so the slide texture is one texel per device pixel (#184). The + // zoom anchor keeps the unsnapped rect, so snapping never accumulates into drift. let ppp = ui.ctx().pixels_per_point(); - let slide_rect = crate::textures::device_pixel_rect(content_rect.translate(-offset), ppp); + let exact_rect = content_rect.translate(-offset); + let slide_rect = crate::textures::device_pixel_rect(exact_rect, ppp); ui.data_mut(|d| { d.insert_temp(scroll_id, offset); - d.insert_temp(last_id, (slide_rect, scale)); + d.insert_temp(last_id, (exact_rect, scale)); }); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; @@ -907,8 +909,10 @@ mod tests { h.run_steps(30); } + /// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel + /// grid (#184). fn assert_near(a: Pos2, b: Pos2, what: &str) { - assert!((a - b).length() < 0.5, "{what}: {a:?} moved to {b:?}"); + assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}"); } /// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. From 320e3a971a9432de2634a0a3cbb360a5c665c36c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:04:16 -0400 Subject: [PATCH 66/69] Fix up merged PRs: one Editing group on Home, fmt, docs after the format/canvas/dialog merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home: #556's Find and Replace join main's Replace Fonts… in one Editing group with the Selection Pane, as PowerPoint lays it out; the separate group pushed Replace Fonts… out of reach at 1400 px. The Replace Fonts test picks the dialog's Replace button, not the ribbon's. - cargo fmt. - docs: parity summary regenerated with `cargo xtask parity`; ui-parity's ruler row says tab stops are set on the ruler (#567/#569) as well as in the Tabs dialog. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/review.rs | 1 - crates/ui-egui/src/canvas.rs | 1 - crates/ui-egui/src/lib.rs | 4 +-- crates/ui-egui/src/replacefonts_tests.rs | 3 ++- crates/ui-egui/src/ribbon.rs | 9 +++---- docs/parity-checklist.md | 34 +++++------------------- docs/ui-parity.md | 2 +- 7 files changed, 15 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 13f4bde0..0becb304 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -398,7 +398,6 @@ fn replace_word(s: &mut Session, p: &Value) -> Result { s.execute("text.insert", &json!({"text": with})) } - fn comparing(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a732b413..e65b3c51 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1154,7 +1154,6 @@ fn grid_fits(span: f32, step: f32) -> bool { span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 } - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e1892a08..61b31f1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1401,12 +1401,12 @@ mod animpane_tests; #[cfg(test)] mod browsetheme_tests; #[cfg(test)] +mod compare_tests; +#[cfg(test)] mod eyedropper_tests; #[cfg(test)] mod huge_deck_tests; #[cfg(test)] -mod compare_tests; -#[cfg(test)] mod ime_tests; #[cfg(test)] mod replacefonts_tests; diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index cec694bf..205c65aa 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -50,7 +50,8 @@ fn ribbon_button_opens_the_dialog_and_replaces() { d.fields.insert("to".into(), "Lato".into()); } h.run_steps(2); - h.get_by_label("Replace").click(); + // The ribbon has a Replace button too (Find and Replace); the dialog's is drawn last. + h.query_all_by_label("Replace").last().expect("the dialog's Replace button").click(); h.run_steps(3); assert_eq!(run_font(&h).as_deref(), Some("Lato")); // The dialog stays open with a confirmation, like PowerPoint's. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index eb71d4d9..0298ea06 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1160,18 +1160,17 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. group(ui, |ui| { ui.vertical(|ui| { small(app, ui, Icon::Search, "Find", "app.find", json!({})); small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } }); - }); - group(ui, |ui| { // Also under Arrange, but that is disabled until something is selected (#132). big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - } }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index fe541d2e..8992c00c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,55 +3,33 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 80%** over 191 features. +**Weighted breadth parity: 82%** over 193 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 14 | 15 | 25 | 40% | -| P1 | 65 | 39 | 21 | 5 | 76% | -| P2 | 54 | 14 | 17 | 23 | 42% | -| P2 | 54 | 14 | 14 | 26 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | +| P1 | 66 | 43 | 18 | 5 | 79% | +| P2 | 55 | 16 | 20 | 19 | 47% | | Area | Parity | |---|---| | Application shell | 81% | | Files | 71% | | Slides | 90% | -| Views | 93% | -| Views | 95% | -| Masters & themes | 85% | -| Views | 90% | +| Views | 98% | | Masters & themes | 88% | -| Shapes | 88% | | Shapes | 91% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 82% | | Tables | 95% | -| Text | 81% | -| Text | 81% | -| Tables | 89% | | Charts | 65% | -| SmartArt | 33% | -| Pictures | 52% | | SmartArt | 50% | -| Pictures | 48% | +| Pictures | 52% | | Media | 67% | | Transitions | 100% | | Animations | 79% | | Slide show | 68% | -| Review | 60% | -| Slide show | 66% | | Review | 70% | | Draw | 33% | | Undo & clipboard | 100% | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1e07a75..f5b43424 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -139,7 +139,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | | Exact size/position, lock aspect | yes | yes | -| Rulers with indent markers and tab stops | yes | rulers yes; tab stops are set in the Tabs dialog, not on the ruler | +| Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | From c448a3dba9b965f9421494e09ee83920a67ea76a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:05:32 -0400 Subject: [PATCH 67/69] Fix up merged PRs: progress docs without the rows the union merges duplicated Stale status lines and table rows from the older PRs are folded into main's current ones. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 ----- docs/control-protocol.md | 2 -- docs/file-format-parity.md | 7 +------ docs/gaps.md | 9 +++------ docs/hardware-parity.md | 2 -- docs/mcp.md | 4 ++-- docs/parity-checklist.md | 3 --- docs/target-app-parity.md | 3 --- docs/ui-parity.md | 18 ++++-------------- 9 files changed, 10 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a901549a..458143c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,10 +33,6 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log for 2026-10-12; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; before that: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log: table cell editing and border resizing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export in the progress log, #171; P2 breadth 36% → 37%; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Background picture options; percentages unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON @@ -50,7 +46,6 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av | | Value | Kind | |---|---|---| | **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 39%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | -| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 37%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) | | Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands | | **Ready for real work** | **~55%** | estimated: weighted dimensions below | | Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c6f45d92..a83a712d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -27,8 +27,6 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | | `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | -| `show.start` / `show.end` | `{from?: slide index, presenter?, reading?}` | Slide show | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 09de8768..597058c9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -12,8 +12,6 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text kept; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (formatted speaker notes) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171; previous: major, created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -82,12 +80,9 @@ the round trip even though DeckCraft doesn't model it. | Presentation, slide size, sections (p14), custom shows | yes | yes | | | Slide masters, layouts, placeholders and inheritance | yes | yes | | | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | -| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master | +| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master. Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown | | Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master | | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | -| Notes slides and notes master | yes | yes | Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown; no notes-master editing view | -| Handout master | no | no | | -| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | Effects: shadow, glow, soft edges, reflection | yes | yes | | diff --git a/docs/gaps.md b/docs/gaps.md index 7d23dc99..5ad63b51 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -25,7 +25,6 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 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 (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 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 (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export landed, #171; the video gap stays; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture options are pane controls, not a new dialog; dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **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 @@ -357,11 +356,9 @@ Detail in [ui-parity.md](ui-parity.md). - **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, Action Settings, Print, Insert Object…). 8–14 h. - **Dialogs**: 30 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, Replace Fonts, Print, - Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). Format Background picture options - (transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture) are - pane controls on `design.background`, not another dialog. 8–14 h. + Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. + Format Background picture options (transparency, tile, offset, scale, alignment, mirror, and Apply + to All copying a picture) are pane controls on `design.background`, not another dialog. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index acd70735..e2964b38 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,7 +1,6 @@ # Hardware parity with PowerPoint > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor rasters on the Retina pixel grid, #184; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -14,7 +13,6 @@ because it mostly follows from hardware use. | Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback | | Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) | | Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same | -| High-DPI / Retina | yes | yes | yes | yes | yes | | Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same | | High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes | | Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no | diff --git a/docs/mcp.md b/docs/mcp.md index 4cdcae69..5f6c5756 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -35,6 +35,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, `shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, -`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe COMMAND` prints any -`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` (GIF). `deckcraft-cli describe ID` prints any +`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}` +(GIF). `deckcraft-cli describe COMMAND` prints any command's parameters. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 58a04ece..dd53bafd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -16,14 +16,11 @@ The summary between ` | P2 | 54 | 14 | 15 | 25 | 40% | | P1 | 65 | 39 | 21 | 5 | 76% | | P2 | 54 | 14 | 17 | 23 | 42% | -| P2 | 54 | 13 | 14 | 27 | 37% | | Area | Parity | |---|---| | Application shell | 81% | | Files | 71% | -| Application shell | 71% | -| Files | 72% | | Slides | 90% | | Views | 93% | | Masters & themes | 85% | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0319ca9c..d6474e8a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -17,7 +17,6 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (essentials discount no longer cites the palette Export failure, #79; percentages unchanged; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF export, #171: P2 breadth 36% → 37%, overall unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". @@ -30,7 +29,6 @@ number. | Number | Value | Kind | |---|---|---| | **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 39%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md) | -| **Feature breadth** (weighted checklist) | **79%** over 191 features (P0 92%, P1 75%, P2 37%) | **measured**: `cargo xtask parity` formula over [parity-checklist.md](parity-checklist.md), rows re-scored 2026-10-10 | | Menu-bar coverage | **59%** (128 of 217 app-specific PowerPoint menu items have a DeckCraft command or UI label of the same name) | **measured** (heuristic lower bound): script matching the 2026-10-07 PowerPoint menu dump against the 226 engine command labels and the UI's strings; excludes the app, Window and Help menus and the subtitle language lists | | **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) | | **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) | @@ -243,7 +241,6 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc latin faces (one scheme on that master, not a library of saved font schemes, and not East Asian or complex-script faces). effect schemes (Design ▸ Effects: DeckCraft, Soft, Crisp, Lift, Glow, Plain), - backgrounds (solid, gradient, picture, pattern), slide size presets. backgrounds (solid, gradient, picture, pattern; a solid fill's transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture), slide size presets. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2853063c..64e68053 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -20,10 +20,6 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; 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 (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the frame of a text box being edited moves it; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (table cells: caret, clicks between cells, border resizing; previous: minor, 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 (Format Background picture and solid options; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (editor slide rasters sit on the physical pixel grid, #184; 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) @@ -93,9 +89,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options | -| Format Background pane | yes | yes | | Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | @@ -133,7 +127,6 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time | | Drag the frame of a text box being edited to move it | yes | yes | | Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells | -| Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | @@ -184,13 +177,10 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93). Colours, spacing, ribbon card and tab geometry follow the observations in `plan/powerpoint/01-observed-ui.md`; the ribbon and menu icons are original (~240, drawn in code) and must not imitate Microsoft's. Insert › Icons places Lucide stroke icons (ISC, release 1.56.0) -on the slide as shapes. No pixel comparison against PowerPoint screenshots has been run since -2026-10-07. Cross-app icon consistency is open (#73, #56). -`plan/powerpoint/01-observed-ui.md`; icons are original (~240, drawn in code or Lucide) and must not -imitate Microsoft's. The editing canvas, thumbnails and layout previews are drawn on the physical -pixel grid, so a Retina display does not blur them between pixels (#184). No pixel comparison -against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon consistency is open -(#73, #56). +on the slide as shapes. The editing canvas, thumbnails and layout previews are drawn on the +physical pixel grid, so a Retina display does not blur them between pixels (#184). No pixel +comparison against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon +consistency is open (#73, #56). ## Estimates From a0facd60af4be004ce86303d9bdb220830d1e7bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:10:48 -0400 Subject: [PATCH 68/69] Fix up merged PRs: cargo fmt, and the write_file tests may unwrap Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 ++ crates/engine/src/tools.rs | 7 +------ crates/ui-egui/src/backstage.rs | 6 +----- crates/ui-egui/src/lib.rs | 5 +---- crates/ui-egui/src/panes.rs | 7 +++++-- crates/ui-egui/src/sorter.rs | 2 +- 6 files changed, 11 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b0b76f29..127ae31f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -643,6 +643,8 @@ mod color_at_tests; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::write_file; fn scratch(name: &str) -> std::path::PathBuf { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 9c0dcabc..73f41ffb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1064,12 +1064,7 @@ impl Session { || matches!( self.hit_test(p, tol), Some( - Hit::Resize { .. } - | Hit::Rotate { .. } - | Hit::Adjust { .. } - | Hit::Frame { .. } - | Hit::TableColumn { .. } - | Hit::TableRow { .. } + Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Frame { .. } | Hit::TableColumn { .. } | Hit::TableRow { .. } ) ) { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index fa413c74..9eb50898 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -613,11 +613,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); } } - let resp = if missing { - resp.on_hover_text(format!("{path} — this file can't be found")) - } else { - resp.on_hover_text(&path) - }; + let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon // (a text glyph would need a font that has it). let x = ui.allocate_rect( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34545650..3b67ea71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1943,10 +1943,7 @@ mod tests { harness.get_by_label("missing.deckcraft").click(); harness.run_steps(2); assert!(harness.state().toast.is_some(), "the open error was raised"); - assert!( - harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, - "the open error is drawn on the page, next to the entry" - ); + assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); } /// #172: a Recent entry can be removed from the list. diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 76cde256..89ea94a4 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1096,8 +1096,11 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("idea-{name}"), crate::textures::to_color_image(&img, false), crate::textures::editor_texture_options()); + let tex = ui.ctx().load_texture( + format!("idea-{name}"), + crate::textures::to_color_image(&img, false), + crate::textures::editor_texture_options(), + ); cache.tex.push((name, tex, px, h)); }; for (id, _) in &ideas { diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 92d6d2e9..28b4b1a5 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -6,9 +6,9 @@ use deckcraft_model::{Presentation, SlideId}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; use serde_json::json; +use crate::SlideApp; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; -use crate::SlideApp; /// Sorter-only state. Nothing here is part of the document. #[derive(Debug, Default)] From d66833b7c23ee74c3084862524fa6b0d8ddbaeed Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:23:16 -0400 Subject: [PATCH 69/69] Fix up merged PRs: land-r6's changes fitted to the landing branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - review.thesaurus's params doc parses under describe's grammar; describe's edit.copy test expects the landing branch's {text, html?} answer. - The Language dialog test picks the landing branch's "Français (France)" entry; the Format Background test allows the pane's title to appear twice. - Progress docs rebuilt from the landing branch's versions with land-r6's rows applied on top, so the union merge no longer drops the pen-pressure, ui.pointer and ui.key rows. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +++ apps/deckcraft-cli/tests/cli.rs | 2 +- crates/engine/src/cmd/review.rs | 2 +- crates/engine/src/tools.rs | 8 +++++++- crates/ui-egui/src/background_tests.rs | 2 +- crates/ui-egui/src/lib.rs | 6 ++---- docs/control-protocol.md | 2 ++ docs/gaps.md | 4 +++- docs/hardware-parity.md | 8 ++++++-- docs/parity-checklist.md | 3 +-- docs/roadmap.md | 6 ++++++ docs/target-app-parity.md | 6 ++++-- docs/ui-parity.md | 1 - 13 files changed, 37 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 425f48bd..db0d810f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,6 +42,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Paragraph dialog; previous: trivial, Font dialog; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: pen pressure scales ink width; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -233,6 +234,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% | | 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | | 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) | +| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read | | 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 | @@ -313,6 +315,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | trivial | Progress log: Paragraph dialog | | 2026-10-14 | trivial | Progress log: Font dialog | | 2026-10-13 | trivial | Progress log: Grid Options dialog | +| 2026-10-14 | trivial | Progress log: pen pressure scales ink width | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-13 | trivial | Progress log: slide show for interactive decks | | 2026-10-12 | minor | Progress log: table cell editing and border resizing | diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs index 8932c909..d04e55c7 100644 --- a/apps/deckcraft-cli/tests/cli.rs +++ b/apps/deckcraft-cli/tests/cli.rs @@ -104,7 +104,7 @@ Acts on the selected text or shapes. Run it with: deckcraft-cli run --cmd 'format.size={…}' " ); - assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")); + assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")); assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n")); assert_eq!( stdout(&cli(&["describe", "shape.setDefault"])), diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 52045e99..4358a004 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { ), cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary), cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info), - cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus), + cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus), cmd!( "review.replaceWord", "Insert Synonym", diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 768e73b2..5c004de2 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1224,7 +1224,13 @@ impl Session { || matches!( self.hit_test(p, tol), Some( - Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. } | Hit::Frame { .. } | Hit::TableColumn { .. } | Hit::TableRow { .. } + Hit::Resize { .. } + | Hit::Rotate { .. } + | Hit::Adjust { .. } + | Hit::Warp { .. } + | Hit::Frame { .. } + | Hit::TableColumn { .. } + | Hit::TableRow { .. } ) ) { diff --git a/crates/ui-egui/src/background_tests.rs b/crates/ui-egui/src/background_tests.rs index b2a7a879..0bb3d229 100644 --- a/crates/ui-egui/src/background_tests.rs +++ b/crates/ui-egui/src/background_tests.rs @@ -38,7 +38,7 @@ fn picture_fill_shows_transparency_and_tiling() { let mut s = Session::with_new(); s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap(); let mut h = harness(SlideApp::new(s, Services::default())); - let _ = h.get_by_label("Format Background"); + assert!(h.query_all_by_label("Format Background").next().is_some(), "the pane is open"); let _ = h.get_by_label("Picture or texture fill"); assert!(h.query_all_by_label("Transparency").next().is_some()); h.get_by_label("Tile picture as texture").click(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 54a64b78..337bf502 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -186,9 +186,7 @@ fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'stati FilePurpose::Picture => Some("insert.picture"), FilePurpose::Audio => Some("insert.audio"), FilePurpose::Video => Some("insert.video"), - FilePurpose::ChangePicture => { - deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change") - } + FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), FilePurpose::Open => open_command(name), FilePurpose::Infer => infer_command(name, bytes), }; @@ -2686,7 +2684,7 @@ mod tests { harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); // The anchored window settles over its first frames; click once it stays put. harness.run_steps(4); - harness.get_by_label("Français").click(); + harness.get_by_label("Français (France)").click(); harness.run_steps(2); harness.get_by_label("OK").click(); harness.run_steps(2); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e700b454..b7b6f013 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -27,6 +27,8 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show: digits then Enter go to that slide, G is See All Slides, Z arms zoom, T pauses the presenter timer, R resets it, `[` and `]` change notes size, Esc clears a slide number, the grid and zoom before it leaves the show) | | `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show; Escape stops the canvas animation preview) | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?, pressure?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse. `pressure` is `0..=1` for a pen; omit it for a mouse | +| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | | `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) | diff --git a/docs/gaps.md b/docs/gaps.md index d495f9a6..5b329773 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -33,6 +33,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; 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-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; 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 (pen pressure scales ink width; tilt, pinch and trackpad gestures remain; 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) 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 @@ -396,7 +397,6 @@ Detail in [ui-parity.md](ui-parity.md). Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. Format Background picture options (transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture) are pane controls on `design.background`, not another dialog. 8–14 h. - Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h. - **Dialogs**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline, colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h. @@ -419,6 +419,7 @@ Detail in [hardware-parity.md](hardware-parity.md). - **No pen pressure or tilt for ink**; no pinch-zoom (it does not keep the point under the pointer). A two-finger twist does rotate the selected shapes. 3–6 h. - **No pen pressure or tilt for ink**; no pinch-zoom. A trackpad swipe turns the page when the slide fits the window, and during the show. 2–4 h for pinch-zoom. +- **Pen pressure scales ink width** when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat `0.5` still draws at the pen width). Tilt is not read. No pinch-zoom or other trackpad gestures. 2–4 h. ## Localization @@ -512,6 +513,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | | 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing | | 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) | +| 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain | | 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 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 27f180f7..c803da55 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -3,6 +3,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page; pinch-zoom remains; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt remains; previous: minor, IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -50,6 +51,8 @@ because it mostly follows from hardware use. | Pinch to zoom | yes | no | | Two-finger swipe between slides | yes | yes: when the slide fits the window, and during the show. A zoomed slide still pans | | Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | +| Pinch to zoom, two-finger gestures | yes | no | +| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | pressure scales the stroke when the pointer reports it (a mouse or a missing reading draws at the pen width); tilt is not read | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | | Dictation | yes (macOS) | no | @@ -68,9 +71,9 @@ outlines every frame (#67, fix open), and the decoders run in software. | GPU slide rasterization (vello's GPU/hybrid path) | 12–20 | | Hardware video decode (copy FilmCraft's VideoToolbox / Media Foundation / VA-API paths) | 8–14 | | Windows GPU startup robustness: backend probing, software fallback (#40, #59) | 4–8 (counted under Stability) | -| Pen pressure, pinch zoom | 3–6 | +| Pen tilt, pinch zoom, trackpad gestures | 2–5 | | Benchmark suite (`cargo xtask bench`) and a first performance pass | 6–12 (Performance dimension) | -| **Hardware total** | **~26–45** | +| **Hardware total** | **~25–44** | ## Revision history @@ -80,5 +83,6 @@ outlines every frame (#67, fix open), and the decoders run in software. | 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-14 | minor | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged | | 2026-10-14 | minor | A sideways trackpad swipe turns the page in Normal view (when the slide fits) and in the show. Pinch-zoom is unchanged | +| 2026-10-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 74d26971..d3497c6f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,7 +1,6 @@ # PowerPoint feature checklist -> **Generated:** 2026-10-11 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` **Weighted breadth parity: 82%** over 193 features. diff --git a/docs/roadmap.md b/docs/roadmap.md index 71e7996f..7485dd08 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,6 +19,7 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (canvas animation preview; beta item 9 keeps the timeline; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options; previous: core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps: adjust handles drag; gallery and command remain) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (after beta: pen pressure scales ink width, tilt remains; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -102,6 +103,10 @@ Handout masters, edit points, text warp and 3-D, proofing dictionaries, Zoom, re GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization and hardware video decode, the other 21 languages with RTL mirroring, a larger template gallery (six original starters ship), and local AI features (owner decision on models). Itemized with hours in [gaps.md](gaps.md). +GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization, hardware video +decode, pen tilt (pressure already scales ink width) and pinch zoom, the other 21 languages with +RTL mirroring, a larger original template gallery, and local AI features (owner decision on +models). Itemized with hours in [gaps.md](gaps.md). ## Milestones @@ -175,5 +180,6 @@ plan above. | 2026-10-13 | trivial | Canvas animation preview landed; beta item 9 keeps the timeline and preview from the selected effect | | 2026-10-13 | minor | M10: Grid Options dialog; New Window stays open | | 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open | +| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cdedcc7..1bcff00a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -23,6 +23,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Views: Grid Options landed; New Window remains; previous: hours to ~95% per audience added; ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (text-warp shortfall is the gallery and command; adjust handles drag) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (pen pressure scales ink; percentages unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -92,7 +93,7 @@ The weights are what those users actually depend on, written down so the total c | UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 | | Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) | | Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) | -| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom | +| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen tilt, no pinch zoom | | Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps | | Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist | | Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes and 6 original starter decks; PowerPoint's large template gallery remains; no add-ins or VBA (out of scope by policy) | @@ -227,7 +228,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Animations | 6 | 85% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview from the selected effect, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | | Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | | Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | -| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | +| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen tilt, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | | Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) | | Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) | | Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) | @@ -362,6 +363,7 @@ scored done (16 open issues). | 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged | | 2026-10-13 | minor | Views: Grid Options dialog landed (spacing, snap, display). Remaining in that row: New Window. Percentages unchanged | | 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain | +| 2026-10-14 | trivial | Draw and hardware blurbs: pen pressure scales ink width when reported; percentages and hour ranges 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 098a12c2..0bd84709 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -99,7 +99,6 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D | -| Format Background pane | yes | yes | | Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | | Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |