Browse / github.com/storytold/deckcraft / patch
FIX-IMAGES: improve image import and restore cut-picture paste
FIX-IMAGES: improve image import and restore cut-picture paste Image import previously selected only one file and reopened without a useful starting folder. The desktop picture picker now allows multiple images, persists the last selected folder, and falls back to the active presentation's directory when there is no valid remembered folder. Change Picture remains single-select. Cut pictures could fail to paste when system clipboard formats took precedence over DeckCraft's object marker. Marker pastes now restore the internal objects and original media directly. Copy/cut commands also publish the same nonempty marker as keyboard events so Ctrl+V is delive
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- uhhJoyz (imported from pull request #1065, open)
- Base
fe510bdac68118f2c9a714e69d0ff572e3f9a0c7- Size
- +168 −8 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
7286e785d1e095bef5276302
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md apps/deckcraft/src/main.rs crates/ui-egui/src/lib.rs docs/parity-checklist.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (picture cut/paste clipboard routing fixed; desktop picture import supports multiple files and remembers its directory; native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, 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@@ -131,6 +131,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-20 | Cut/copy of pictures publishes a nonempty clipboard marker through commands as well as keyboard events; pasting that marker restores internal objects and media without querying unrelated system clipboard formats |+| 2026-10-20 | Desktop picture import selects multiple files, remembers the last picture folder in preferences, and defaults to the active presentation folder. Change Picture remains single-select | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |@@ -239,6 +241,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---|+| 2026-10-20 | minor | Picture cut/paste clipboard regressions fixed |+| 2026-10-20 | minor | Desktop picture picker: multiple selection and directory memory | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |@@ -111,6 +111,12 @@ fn services() -> Services { }; d.pick_file().map(|p| p.to_string_lossy().to_string()) })),+ pick_pictures: Some(Box::new(|directory, multiple| {+ let dialog = rfd::FileDialog::new().add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]);+ let dialog = if let Some(directory) = directory { dialog.set_directory(directory) } else { dialog };+ let paths = if multiple { dialog.pick_files() } else { dialog.pick_file().map(|p| vec![p]) };+ paths.map(|paths| paths.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())+ })), 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()))),@@ -49,6 +49,9 @@ pub use control::{ControlRequest, ControlResponse}; pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>; pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;+/// Picture picker: starting directory, allow multiple → selected paths.+pub type PickPicturesFn = Box<dyn FnMut(Option<&std::path::Path>, bool) -> Option<Vec<String>>>;+ pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>; /// `(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@@ -202,6 +205,8 @@ const STATUS_LINGER_MS: f64 = 6000.0; pub struct Services { /// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path. pub pick_open: Option<PickFn>,+ /// Desktop picture picker with directory and multiple-selection support.+ pub pick_pictures: Option<PickPicturesFn>, /// Save dialog with a suggested name → path. pub pick_save: Option<PickFn>, pub read: Option<ReadFn>,@@ -258,6 +263,8 @@ pub struct UiState { pub zoom: Option<f32>, pub sorter_zoom: f32, pub recent: Vec<String>,+ /// Last selected picture directory, persisted with UI preferences.+ pub picture_directory: Option<std::path::PathBuf>, pub presenter_view: bool, pub ui_scale: f32, /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`).@@ -296,6 +303,7 @@ impl Default for UiState { zoom: None, sorter_zoom: 1.0, recent: vec![],+ picture_directory: None, presenter_view: true, ui_scale: 1.0, format_tab: "fill".into(),@@ -664,7 +672,7 @@ impl SlideApp { // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { if let Some(t) = v.get("text").and_then(Value::as_str) {- self.pending_copy = Some(t.to_string());+ self.pending_copy = Some(system_clipboard_text(t)); } } Ok(_) => {}@@ -1245,8 +1253,23 @@ impl SlideApp { /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) {- if let Some(pick) = self.services.pick_open.as_mut() {- if let Some(path) = pick(purpose) {+ if self.services.pick_open.is_some() || (purpose == "picture" && self.services.pick_pictures.is_some()) {+ let paths = if purpose == "picture" && self.services.pick_pictures.is_some() {+ let directory = self.ui.picture_directory.clone().filter(|p| p.is_dir()).or_else(|| {+ self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| {+ let path = std::path::Path::new(p);+ let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().ok()?.join(path) };+ absolute.parent().filter(|p| p.is_dir()).map(std::path::Path::to_path_buf)+ })+ });+ self.services.pick_pictures.as_mut().and_then(|pick| pick(directory.as_deref(), cmd == "insert.picture"))+ } else {+ self.services.pick_open.as_mut().and_then(|pick| pick(purpose)).map(|p| vec![p])+ };+ for path in paths.into_iter().flatten() {+ if purpose == "picture" {+ self.ui.picture_directory = std::path::Path::new(&path).parent().map(std::path::Path::to_path_buf);+ } let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; let r = match self.services.read.as_ref().map(|r| r(&path)) { Some(Ok(bytes)) => {@@ -1727,8 +1750,15 @@ impl SlideApp { egui::Event::Copy => self.publish_clipboard(ctx, false), egui::Event::Cut => self.publish_clipboard(ctx, true), egui::Event::Paste(text) => {- // Our own marker for objects without text: paste what the session copied.- let text = if text == OBJECTS_MARKER { String::new() } else { text };+ // Our marker identifies the session's objects, including their original media.+ // Do not query other system formats: they can be stale or belong to a different+ // clipboard provider (for example X11 versus Wayland).+ if text == OBJECTS_MARKER {+ if let Err(e) = self.session.execute("edit.paste", &json!({})) {+ self.set_status(e.to_string());+ }+ continue;+ } // 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());@@ -2234,6 +2264,61 @@ mod tests { assert!(harness.state().toast.is_none()); } + #[test]+ fn picture_picker_imports_multiple_and_remembers_directory() {+ let root = std::env::temp_dir();+ let chosen = root.join("pictures").join("one.svg");+ let selected = chosen.to_string_lossy().into_owned();+ let expected = root.clone();+ let services = Services {+ pick_pictures: Some(Box::new(move |directory, multiple| {+ assert_eq!(directory, Some(expected.as_path()));+ assert!(multiple);+ Some(vec![selected.clone(), selected.clone()])+ })),+ read: Some(Box::new(|_| Ok(b"<svg xmlns='http://www.w3.org/2000/svg' width='4' height='4'><rect width='4' height='4'/></svg>".to_vec()))),+ ..Default::default()+ };+ let mut app = SlideApp::new(Session::with_new(), services);+ app.session.active_mut().unwrap().path = Some(root.join("talk.pptx").to_string_lossy().into_owned());+ let before = app.session.active().unwrap().doc.slides[0].shapes.len();+ app.pick_and_insert("picture", "insert.picture");+ assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), before + 2);+ assert_eq!(app.ui.picture_directory.as_deref(), chosen.parent());+ let restored: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();+ assert_eq!(restored.picture_directory, app.ui.picture_directory);++ // A remembered existing folder takes precedence; cancelling preserves it and the slide.+ app.ui.picture_directory = Some(root.clone());+ app.session.active_mut().unwrap().path = None;+ app.services.pick_pictures = Some(Box::new(move |directory, multiple| {+ assert_eq!(directory, Some(root.as_path()));+ assert!(multiple);+ None+ }));+ app.pick_and_insert("picture", "insert.picture");+ assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), before + 2);+ assert!(app.ui.picture_directory.is_some());+ }++ #[test]+ fn picture_picker_missing_directory_falls_back_and_replacement_is_single() {+ let root = std::env::temp_dir();+ let expected = root.clone();+ let services = Services {+ pick_pictures: Some(Box::new(move |directory, multiple| {+ assert_eq!(directory, Some(expected.as_path()));+ assert!(!multiple);+ None+ })),+ ..Default::default()+ };+ let mut app = SlideApp::new(Session::with_new(), services);+ app.session.active_mut().unwrap().path = Some(root.join("talk.pptx").to_string_lossy().into_owned());+ app.ui.picture_directory = Some(root.join("deckcraft-nonexistent-picture-directory"));+ app.pick_and_insert("picture", "picture.change");+ }+ /// #115: Change Picture replaced nothing — it inserted a second picture. #[test] fn change_picture_replaces_the_selected_picture() {@@ -2870,6 +2955,62 @@ mod tests { assert!(pasted.text.as_ref().is_none_or(|t| t.text().is_empty()), "a copy of the rectangle, not a text box holding the marker"); } + #[test]+ fn cut_picture_pastes_internal_media_even_with_external_clipboard_formats() {+ let services = Services {+ // A clipboard provider can still advertise an image format alongside our marker.+ clipboard_image: Some(Box::new(|| Some(b"stale image data".to_vec()))),+ clipboard_html: Some(Box::new(|| Some("<p>Old clipboard text</p>".into()))),+ ..Default::default()+ };+ let mut h = step_app(services);+ let bytes = b"<svg xmlns='http://www.w3.org/2000/svg' width='4' height='4'><rect width='4' height='4'/></svg>";+ h.state_mut()+ .session+ .execute(+ "insert.picture",+ &json!({+ "name": "original.svg", "data": deckcraft_engine::cmd::base64_encode(bytes), "rect": [20, 30, 40, 50]+ }),+ )+ .unwrap();+ let count = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.doc().unwrap().current_slide().unwrap().shapes.len();+ let before = count(&h);+ h.event(egui::Event::Cut);+ h.run_steps(1);+ assert_eq!(count(&h), before - 1);+ let copied = h+ .output()+ .platform_output+ .commands+ .iter()+ .find_map(|cmd| match cmd {+ egui::output::OutputCommand::CopyText(text) => Some(text.clone()),+ _ => None,+ })+ .expect("cut publishes a clipboard marker");+ assert_eq!(copied, OBJECTS_MARKER);+ h.event(egui::Event::Paste(copied));+ h.run_steps(1);+ assert_eq!(count(&h), before, "paste restores the cut picture");+ let doc = h.state().session.doc().unwrap();+ let picture = doc.current_slide().unwrap().shapes.last().unwrap();+ let deckcraft_model::ShapeKind::Picture { fill } = &picture.kind else { panic!("not a picture") };+ assert_eq!(doc.doc.media(fill.media).unwrap().data.as_slice(), bytes);+ }++ #[test]+ fn cut_picture_command_publishes_nonempty_clipboard_marker() {+ let mut h = step_app(Services::default());+ h.state_mut().session.execute("insert.picture", &json!({+ "name": "original.svg", "data": deckcraft_engine::cmd::base64_encode(b"<svg xmlns='http://www.w3.org/2000/svg' width='4' height='4'/>")+ })).unwrap();+ h.state_mut().run("edit.cut", json!({})).unwrap();+ h.run_steps(1);+ assert!(h.output().platform_output.commands.iter().any(|cmd| matches!(cmd,+ egui::output::OutputCommand::CopyText(text) if text == OBJECTS_MARKER)));+ }+ /// #188: Ctrl/Cmd+A with the slide thumbnail pane focused selects every slide; with the /// canvas focused it still selects shapes. #[test]@@ -1,5 +1,7 @@ # PowerPoint feature checklist +> **Last updated:** 2026-10-20 (desktop picture picker and picture cut/paste; both rows remain D).+ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between `<!-- SUMMARY -->@@ -178,7 +180,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Charts | Chart styles and colours | P2 | P | | SmartArt | Insert SmartArt (lists, processes, cycles, hierarchy) | P1 | P | | SmartArt | Text pane and layout switching | P2 | P |-| Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF) | P0 | D |+| Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF; desktop multiple selection, remembered folder, presentation-folder default) | P0 | D | | Pictures | SVG pictures | P1 | M | | Pictures | Crop (and crop to shape, aspect ratio) | P0 | P | | Pictures | Corrections, colour, transparency (recolor and Set Transparent Color done; sharpen/soften and colour temperature pending) | P1 | P |@@ -226,10 +228,17 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Draw | Ink pens, highlighter, eraser | P1 | P | | 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 | Cut, copy, paste shapes and slides (picture cuts retain internal media on paste) | P0 | D | | Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D | | Automation (✱) | Every command scriptable (CLI, control channel, MCP; remote reply IDs, envelopes and framing validated, #718) | P0 | D | | Automation (✱) | Headless rendering and conversion | P0 | D | | Platforms (✱) | macOS, Windows, Linux builds | P0 | P | | Platforms (✱) | Web (WASM) build | P1 | D | | Platforms (✱) | BSD build | P2 | P |++## Revision history++| Date | Change |+|---|---|+| 2026-10-20 | Desktop picture picker improvements; Insert picture stays D |+| 2026-10-20 | Picture cut/paste clipboard routing fixed; Cut, copy, paste stays D |