From 503ee5d67e3014ae64496c5a3094e0538acfdcc0 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 13 Oct 2026 12:38:57 +0300 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 8bab5c20535b2871b8e568a9f2dde2a0e99fedc2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 20:53:10 +0300 Subject: [PATCH 04/17] 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 4658042de837fa5952fdc0dbbc232ba1f6db0b61 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:16:49 +0300 Subject: [PATCH 05/17] 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 b89c9cafd7c4fc2c1082910a8c593653a38b8cb0 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:21:42 +0300 Subject: [PATCH 06/17] 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 c583480010e2e565b0b174c28252bdeae1b53226 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:27:57 +0300 Subject: [PATCH 07/17] 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 08/17] 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 09/17] 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 10/17] 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 a9e7747ea5262dff2b10b675ac1c07f35b153058 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:55:33 +0300 Subject: [PATCH 11/17] 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 2f3a5255682f1467480995f5ef242b6b73dd424b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 06:16:14 +0300 Subject: [PATCH 12/17] 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 13/17] 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 2a410e54fa1635733d2af8be98e0a00cd3f7fa7c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:37:30 -0400 Subject: [PATCH 14/17] 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 7dcfe0f989ffe4fb573162a39a349e07f1887069 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:40:18 -0400 Subject: [PATCH 15/17] 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 526e31d1f7d6c582805b7237cd52c928f7afa051 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:44:40 -0400 Subject: [PATCH 16/17] 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 560928af94e9149cbc44d4e46355cf42421a8672 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:46:41 -0400 Subject: [PATCH 17/17] 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; }