From cbb0c484459af252620ef0ad9c928cde586290a0 Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Sun, 1 Nov 2026 22:19:06 +0100 Subject: [PATCH 1/7] Fix OS clipboard: enable eframe's clipboard feature dropped in the egui 0.37 migration Since #1350 (v0.11.1) the desktop build listed eframe features without `clipboard`, so egui-winit simulated a clipboard inside the app: Ctrl+V never read text or pictures from other apps, and Ctrl+C of text (sent out as HTML through arboard) never reached egui's clipboard, so Ctrl+V pasted nothing or stale text. The context menu kept working because it only uses the session clipboard. Turn the feature back on. On native Wayland, egui-winit owns the clipboard through winit's data device (text and PNG); leave the X11-only arboard pasteboard (HTML, pictures) off there so copies are claimed on the Wayland selection. Regression tests guard the feature list and the Wayland switch. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 102 +++++++++++++++++++++++++++++++++++-- ROADMAP.md | 4 +- apps/deckcraft/Cargo.toml | 4 +- apps/deckcraft/src/main.rs | 41 +++++++++++++-- docs/gaps.md | 5 +- docs/parity-checklist.md | 4 +- 6 files changed, 144 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 408e99948..2e7f72a5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,18 +565,42 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cast" version = "0.3.0" @@ -1507,13 +1531,16 @@ dependencies = [ "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", + "smithay-clipboard", "web-time", "webbrowser", "windows-sys 0.61.2", @@ -4403,8 +4430,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop", - "calloop-wayland-source", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -4421,6 +4448,44 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -4928,6 +4993,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5370,6 +5436,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -5980,7 +6072,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -6001,7 +6093,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", diff --git a/ROADMAP.md b/ROADMAP.md index f92554c14..6255c9421 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** major (fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.14.1; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: 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) @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | | 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | @@ -318,6 +319,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | major | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml index b336a8433..5b4afbb83 100644 --- a/apps/deckcraft/Cargo.toml +++ b/apps/deckcraft/Cargo.toml @@ -14,7 +14,9 @@ workspace = true deckcraft-engine = { workspace = true } deckcraft-ui-egui = { workspace = true } deckcraft-media = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } +# `clipboard` is the OS clipboard (Ctrl+C/X/V with other apps). Without it egui simulates one inside +# the app; the egui 0.37 migration (#1350) dropped it from this list. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index ad3f820fa..2798c2d71 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -103,7 +103,13 @@ fn save_prefs(app: &SlideApp) { } } -fn services() -> Services { +/// The desktop services. `native_wayland`: the window is a Wayland client (not Xwayland). +fn services(native_wayland: bool) -> Services { + // On native Wayland, egui-winit owns the clipboard through winit's data device, which carries + // text and PNG but no HTML. `pasteboard` (arboard, built for X11 only) would put a copy on the + // Xwayland selection instead and skip egui's Wayland claim, so Ctrl+V would read something + // else. There, text goes through egui and pictures arrive as `PasteImage` events. + let pasteboard = !native_wayland; Services { pick_open: Some(Box::new(|purpose: &str| { let d = rfd::FileDialog::new(); @@ -126,9 +132,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(pasteboard::image_png)), - clipboard_html: Some(Box::new(pasteboard::html)), - clipboard_set_html: Some(Box::new(pasteboard::set_html)), + clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box Option>>), + clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box Option>), + clipboard_set_html: pasteboard.then(|| Box::new(pasteboard::set_html) as Box bool>), // 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() @@ -247,6 +253,11 @@ fn main() -> eframe::Result { false } }; + // `Default` lets winit choose, and winit takes Wayland whenever `WAYLAND_DISPLAY` is set. + #[cfg(all(unix, not(target_os = "macos")))] + let native_wayland = !x11 && std::env::var_os("WAYLAND_DISPLAY").is_some_and(|w| !w.is_empty()); + #[cfg(not(all(unix, not(target_os = "macos"))))] + let native_wayland = false; #[cfg(all(unix, not(target_os = "macos")))] if x11 { options.event_loop_builder = Some(Box::new(|b| { @@ -268,7 +279,7 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx); - let mut app = SlideApp::new(Session::new(), services()); + let mut app = SlideApp::new(Session::new(), services(native_wayland)); load_prefs(&mut app); app.enable_update_checks(); // AutoRecover: reopen what a previous run left unsaved, then keep it current. @@ -335,6 +346,26 @@ mod tests { assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); } + /// The egui 0.37 migration (#1350) left `clipboard` out of eframe's features. egui then + /// simulated a clipboard inside the app: nothing pasted from other apps, and Ctrl+C/Ctrl+V + /// lost text that went out as HTML. Keep the OS clipboard on. + #[test] + fn eframe_uses_the_os_clipboard() { + let manifest = include_str!("../Cargo.toml"); + let eframe = manifest.lines().find(|l| l.starts_with("eframe = ")).unwrap_or_default(); + assert!(eframe.contains("\"clipboard\""), "eframe must enable the OS clipboard: {eframe}"); + } + + /// On native Wayland, the X11-only pasteboard would bypass egui's Wayland clipboard; elsewhere + /// it carries HTML and pictures beside egui's text. + #[test] + fn the_pasteboard_stays_off_native_wayland() { + let s = services(true); + assert!(s.clipboard_html.is_none() && s.clipboard_set_html.is_none() && s.clipboard_image.is_none()); + let s = services(false); + assert!(s.clipboard_html.is_some() && s.clipboard_set_html.is_some() && s.clipboard_image.is_some()); + } + #[test] fn help_exits_instead_of_opening_a_file() { assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help)); diff --git a/docs/gaps.md b/docs/gaps.md index 29edfd573..75a92355b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (slide-show hit testing follows each shape’s own animation state; scale and rotation covered; previous: trivial, AutoFormat lists run independently of Replace text as you type; previous: minor, slide-show click targets follow animated shapes and text links; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-01 · **Change:** major (workflow-breaking regression fixed: Ctrl+C/X/V use the system clipboard again; #1414 to re-test on Windows; previous: minor, slide-show hit testing follows each shape’s own animation state; scale and rotation covered; previous: trivial, AutoFormat lists run independently of Replace text as you type; previous: minor, slide-show click targets follow animated shapes and text links; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; 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 @@ -161,7 +161,7 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 3–5 h. **Beta blocker.** ### Image paste fails on Windows (#1414) -- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. Linux was not re-checked after #53 closed. +- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.14.1 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01. #1414 may have the same cause: re-test on Windows. - **Impact:** copy-paste of screenshots is a core workflow. - **Estimate:** 2–4 h. **Beta blocker.** @@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | major | Workflow-breaking regression: image paste entry: the OS clipboard was off from #1350 to 0.14.1; Linux re-checked, Windows #1414 to re-test | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | | 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0c0d3ec60..9a9094780 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -36,7 +36,7 @@ The summary between ` | Automation (✱) | 100% | | Platforms (✱) | 67% | -Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414; Linux not re-checked), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds. +Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds. ## Rows @@ -187,7 +187,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Pictures | Picture styles and borders | P1 | D | | Pictures | Change / reset picture | P1 | D | | Pictures | Compress pictures | P2 | D | -| Pictures | Paste image from clipboard (fails on Windows, #1414; Linux not re-checked) | P0 | P | +| Pictures | Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix) | P0 | P | | Pictures | Insert screenshot | P2 | M | | Media | Insert audio (any common format) | P0 | D | | Media | Insert video | P1 | D | From e1cfe8516e444af9357d5ea135f9b067de0bc70e Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Sun, 1 Nov 2026 22:29:43 +0100 Subject: [PATCH 2/7] Paste and duplicate keep a placeholder's inherited look A placeholder copied or duplicated on a slide became a regular shape by clearing `ph`, which also dropped everything it inherited from its layout and master: the title's 44 pt heading font, the body's bullets and indent levels, autofit and the box. Without `ph` the text fell back to the presentation's default style (18 pt, no bullets), on Ctrl+V and the context menu alike. `resolve::detach_placeholder` makes the inherited box, fill, line, effects, body properties and every level's text style the shape's own before `ph` is cleared. Theme references stay references, so the default paste still follows the destination theme. Copy (so Cut and pasting into another deck work after the source is gone) and Duplicate use it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/engine/src/cmd/edit.rs | 15 ++++++- crates/engine/src/tests.rs | 85 +++++++++++++++++++++++++++++++++++ crates/model/src/resolve.rs | 39 ++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6255c9421..fd17b3a1c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** major (fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.14.1; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** major (fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.14.1; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: 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) @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-01 | Copy, Cut, Paste and Duplicate of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | | 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f084c6225..9674bb8ad 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -120,6 +120,13 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { }) }) .collect(); + // A placeholder pastes on a slide as a regular shape; it keeps the look it inherited here. + let mut shapes = shapes; + if st.selection.target == crate::Target::Slides + && let Some(ctx) = st.current_slide().and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl)) + { + shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh)); + } let mids = crate::media_refs::shapes_media(&shapes); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); @@ -340,7 +347,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { x.x += off; x.y += off; } - // A pasted placeholder becomes a regular shape keeping its look. + // A pasted placeholder becomes a regular shape; copying gave it its look. if sh.ph.is_some() && sel.target == crate::Target::Slides { sh.ph = None; } @@ -533,6 +540,12 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let sources: Vec = copies.iter().map(|c| c.id).collect(); let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + // A duplicated placeholder becomes a regular shape that keeps the look it inherited. + if sel.target == crate::Target::Slides + && let Some(ctx) = doc.slides.get(sel.slide).and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(doc, sl)) + { + copies.iter_mut().for_each(|c| deckcraft_model::resolve::detach_placeholder(&ctx, c)); + } // Duplicating the copies of the last Duplicate again repeats how far they were moved // since (#966); anything else gets the usual offset. let (dx, dy) = last diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a687de95c..eec43cc4a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -214,6 +214,91 @@ fn clipboard_copy_paste_shapes_and_slides() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font, +/// colour, bold, bullet, indent, alignment, and the box's insets and anchor. +fn resolved_look(s: &Session, id: ShapeId) -> String { + use deckcraft_model::resolve; + let st = s.doc().unwrap(); + let slide = st.current_slide().unwrap(); + let ctx = resolve::Ctx::for_slide(&st.doc, slide).unwrap(); + let sh = st.shape(id).unwrap(); + let t = sh.text.as_ref().unwrap(); + let b = resolve::body(&ctx, sh); + let mut out = format!("body {:?} {:?} {:?}\n", b.anchor, b.inset_l, b.autofit); + for p in &t.paragraphs { + let pp = resolve::para(&ctx, sh, p); + out += &format!("para {:?} {:?} {:?} {:?}\n", pp.align, pp.margin_left, pp.indent, pp.bullet); + for r in &p.runs { + let rp = resolve::run(&ctx, sh, p, &r.props); + out += &format!(" run {:?} {:?} {:?} {:?}\n", rp.size, rp.font, rp.bold, rp.fill); + } + } + out +} + +/// A placeholder pasted or duplicated on a slide becomes a regular shape, and keeps the look its +/// layout and master gave it: the title's size and font, the body's bullets and indents. Before, +/// the copy fell back to the master's other-text style (18 pt, no bullets). +#[test] +fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let phs: Vec = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect(); + assert!(phs.len() >= 2, "a title and a body placeholder"); + for (k, id) in phs.iter().enumerate() { + s.execute("text.set", &json!({"id": id.0, "text": format!("Placeholder {k}\nSecond line")})).unwrap(); + } + for id in phs { + let want = resolved_look(&s, id); + for cmd in ["paste", "duplicate"] { + s.execute("selection.select", &json!({"id": id.0})).unwrap(); + if cmd == "paste" { + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + } else { + s.execute("edit.duplicate", &json!({})).unwrap(); + } + let copy = s.doc().unwrap().selection.shapes[0]; + assert_ne!(copy, id); + assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "{cmd}: the copy is a regular shape"); + assert_eq!(resolved_look(&s, copy), want, "{cmd} of placeholder {}", id.0); + } + } + assert!(s.doc().unwrap().doc.validate().is_empty()); +} + +/// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles). +#[test] +fn sample_placeholders_paste_with_their_look() { + let mut s = Session::new(); + crate::sample::open_sample(&mut s).unwrap(); + let slides = s.doc().unwrap().doc.slides.len(); + let mut checked = 0; + for i in 0..slides { + s.execute("slide.go", &json!({"index": i})).unwrap(); + let phs: Vec = s + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .filter(|x| x.ph.is_some() && x.text.as_ref().is_some_and(|t| !t.is_empty())) + .map(|x| x.id) + .collect(); + for id in phs { + let want = resolved_look(&s, id); + s.execute("selection.select", &json!({"id": id.0})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_eq!(resolved_look(&s, copy), want, "slide {i}, placeholder {}", id.0); + checked += 1; + } + } + assert!(checked >= 3, "the sample has text placeholders ({checked})"); +} + #[test] fn pasted_shapes_get_the_next_automatic_name() { let mut s = session(); diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8d87229f3..8cf87582d 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -218,6 +218,45 @@ pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option, Option) { (None, None) } +/// Turn a placeholder into a regular shape that looks the same: what it inherited from its layout +/// and master placeholders (position, fill, line, effects, style, body properties and the text +/// style of every outline level, the master's title or body style included) becomes its own, and +/// `ph` is cleared. A shape that is not a placeholder is left as it is. +/// +/// Theme references (`+mj-lt`, scheme colours) stay references, so the copy still follows the +/// destination theme, as PowerPoint's default paste does. +pub fn detach_placeholder(ctx: &Ctx, shape: &mut Shape) { + if shape.ph.is_none() { + return; + } + let (lp, mp) = parents(ctx, shape); + let parents = [lp, mp].into_iter().flatten(); + shape.xfrm = Some(xfrm(ctx, shape)); + if shape.fill.is_none() && shape.style.is_none() { + shape.fill = parents.clone().find_map(|s| s.fill.clone()); + } + if shape.style.is_none() { + shape.style = parents.clone().find_map(|s| s.style.clone()); + } + if shape.effects.is_none() { + shape.effects = parents.clone().find_map(|s| s.effects.clone()); + } + let mut line = shape.line.clone(); + for p in parents.filter_map(|s| s.line.as_ref()) { + line.get_or_insert_with(Line::default).inherit(p); + } + shape.line = line; + if shape.text.is_some() { + let body = body(ctx, shape); + let levels: Vec> = (0..9).map(|l| Some(level_style(ctx, shape, l))).collect(); + if let Some(t) = shape.text.as_mut() { + t.body = body; + t.list_style = ListStyle { levels }; + } + } + shape.ph = None; +} + /// Body properties with defaults filled in. pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { let (lp, mp) = parents(ctx, shape); From f006b5d8cef8ed499f14a16f58510b2dd0aaa7ef Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Sun, 1 Nov 2026 22:38:05 +0100 Subject: [PATCH 3/7] Ctrl+V pastes a picture copied in a browser, not its link A browser's Copy Image puts the picture on the clipboard together with text that only names it (the address and alt text, or Firefox's UTF-16 text/x-moz-url read as UTF-8) and an HTML fragment. Ctrl+V only looked for a picture when the text was empty, so it pasted a text box with the link. When the text or HTML only names a picture, paste the picture; with no picture on the clipboard the link still pastes as text. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/app/clipboard.rs | 46 +++++++++++++++++++++++++++++ crates/ui-egui/src/app/keys.rs | 6 ++-- crates/ui-egui/src/tests.rs | 44 +++++++++++++++++++++++++++ docs/gaps.md | 2 +- 5 files changed, 96 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fd17b3a1c..42ccd19dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it | | 2026-11-01 | Copy, Cut, Paste and Duplicate of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | | 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 19fd48bf1..f6003dd04 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -11,6 +11,52 @@ pub(crate) fn system_clipboard_text(t: &str) -> String { if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } } +/// Whether pasted `text` (and the clipboard's `html`) only name a picture that is on the clipboard +/// too, as a browser's Copy Image leaves them: the picture's address, perhaps followed by its alt +/// text, or HTML that is a lone ``. Then Ctrl+V pastes the picture, as PowerPoint does. +/// Text with NULs is UTF-16 read as UTF-8 (Firefox's `text/x-moz-url`), never words to keep. +pub(crate) fn names_a_picture(text: &str, html: Option<&str>) -> bool { + if text.contains('\0') { + return true; + } + if let Some(h) = html + && h.to_ascii_lowercase().contains("`/`"); + } else if rest.starts_with('<') { + i = skip_to(">"); + } else { + let end = rest.find('<').map_or(html.len(), |e| i + e); + out.push_str(&html[i..end]); + i = end; + } + } + out.replace(" ", " ") +} + impl SlideApp { /// Offer the copy or cut on the system clipboard. /// diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index d6903e82d..7523308c0 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -152,8 +152,10 @@ impl SlideApp { // 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()); // egui 0.37 sends `PasteImage` directly; the services fallback stays for - // integrations that still send an empty `Paste`. Never insert twice. - let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + // integrations that still send an empty `Paste`, and for a browser's Copy + // Image, whose text is only the picture's link. Never insert twice. + let wants_picture = text.is_empty() || super::clipboard::names_a_picture(&text, html.as_deref()); + let img = if wants_picture && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let revision = self.session.active().map(|d| d.revision); let r = match img { Some(png) => self diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 98f3b85c7..47192ad36 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -378,6 +378,50 @@ fn title_bar_export_opens_the_export_dialog() { assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); } +/// A browser's Copy Image puts the picture on the clipboard with text that only names it: the +/// address (and the alt text), and an `` fragment as HTML. Ctrl+V pastes the picture, as +/// PowerPoint does, instead of a text box with the link. Without a picture the link pastes as text. +#[test] +fn ctrl_v_pastes_the_picture_a_browser_copied_not_its_link() { + let url = "https://github.com/storytold/deckcraft/raw/main/assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png"; + let caption = format!("{url}\nDeckCraft icon: a presentation screen with a bar chart on an orange tile"); + for (text, html, with_picture, pictures_after) in [ + (caption.clone(), Some(format!("\"DeckCraft")), true, 1), + (url.to_string(), None, true, 1), + // Firefox's text/x-moz-url is UTF-16; read as UTF-8 it carries NULs. + (url.chars().flat_map(|c| [c, '\0']).collect(), None, true, 1), + (url.to_string(), None, false, 0), + ("See https://example.com for the photos".to_string(), None, true, 0), + ] { + let session = Session::with_new(); + let png = { + let d = session.active().unwrap(); + deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 + }; + let services = Services { + clipboard_image: Some(Box::new(move || with_picture.then(|| png.clone()))), + clipboard_html: Some(Box::new(move || html.clone())), + ..Default::default() + }; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + let shapes_before = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + harness.event(egui::Event::Paste(text.clone())); + harness.run_steps(2); + let d = harness.state().session.active().unwrap(); + let shapes = &d.doc.slides[0].shapes; + let pictures = shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count(); + assert_eq!(pictures, pictures_after, "{text:?}"); + assert_eq!(shapes.len(), shapes_before + 1, "one shape, picture or text, for {text:?}"); + } +} + /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and /// swallows the key press, so only the V release reaches the app. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 75a92355b..27c7ef7c7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -161,7 +161,7 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 3–5 h. **Beta blocker.** ### Image paste fails on Windows (#1414) -- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.14.1 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01. #1414 may have the same cause: re-test on Windows. +- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.14.1 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01, as is a browser's Copy Image on Linux, which pasted its link because Ctrl+V preferred any text over the picture. #1414 may have either cause: re-test on Windows. - **Impact:** copy-paste of screenshots is a core workflow. - **Estimate:** 2–4 h. **Beta blocker.** From 1192e5ef3076f7df1b239e2f4a14645ec53186b8 Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Sun, 1 Nov 2026 23:06:09 +0100 Subject: [PATCH 4/7] Detached placeholders follow each property's style precedence Review follow-up (CodeRabbit on #1658): - Text levels are resolved before the copy's style changes. A master placeholder's font reference reached the copy's text (body font in an accent colour for a title) although the original ignores it: text only takes the shape's or the layout placeholder's font reference. - Fill, line and effects come from where `resolve::fill`, `resolve::line` and `resolve::effects` take them: the first shape in the chain with an explicit value or a style. A layout's (or the slide's own) style no longer loses to an explicit fill, line or effect on the master. The copy gets one style whose references each come from the shape that supplied that property; unused references are neutral. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 100 ++++++++++++++++++++++++++++++++++++ crates/model/src/resolve.rs | 71 ++++++++++++++++--------- 2 files changed, 147 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eec43cc4a..26fb7b9cf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -267,6 +267,106 @@ fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +/// The resolved fill, line and effects of shape `id` on the current slide, with their style colours. +fn resolved_box(s: &Session, id: ShapeId) -> String { + use deckcraft_model::resolve; + let st = s.doc().unwrap(); + let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap(); + let sh = st.shape(id).unwrap(); + format!("{:?}\n{:?}\n{:?}", resolve::fill(&ctx, sh), resolve::line(&ctx, sh), resolve::effects(&ctx, sh)) +} + +/// Edit the title placeholders the current slide's title inherits from: its layout's and its +/// master's. Returns the slide title's id. +fn edit_title_parents( + s: &mut Session, + layout: impl FnOnce(&mut deckcraft_model::Shape), + master: impl FnOnce(&mut deckcraft_model::Shape), +) -> ShapeId { + let st = s.doc_mut().unwrap(); + let slide = st.current_slide().unwrap().clone(); + let title = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap().id; + let doc = std::sync::Arc::make_mut(&mut st.doc); + let m = std::sync::Arc::make_mut(&mut doc.masters[0]); + let lay = m.layouts.iter_mut().find(|l| l.id == slide.layout).unwrap(); + layout(lay.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap()); + master(m.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap()); + title +} + +/// A copied placeholder takes each of fill, line and effects from where the original does: a +/// style on the layout's placeholder (or on the slide's own) wins over an explicit value on the +/// master's, as `resolve::fill`, `resolve::line` and `resolve::effects` decide. +#[test] +fn a_pasted_placeholder_keeps_the_style_precedence_of_its_box() { + use deckcraft_color::SchemeSlot; + use deckcraft_model::{ColorRef, Effects, Fill, Line, ShapeStyle}; + for style_on_slide in [false, true] { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let style = ShapeStyle { + line_ref: (1, ColorRef::scheme(SchemeSlot::Accent2)), + fill_ref: (1, ColorRef::scheme(SchemeSlot::Accent1)), + effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)), + font_ref: ("minor".into(), None), + }; + let layout_style = (!style_on_slide).then(|| style.clone()); + let title = edit_title_parents( + &mut s, + |l| l.style = layout_style, + |m| { + m.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent4))); + m.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent5), 20.0)); + m.effects = Some(Effects { soft_edge: Some(5.0), ..Default::default() }); + }, + ); + if style_on_slide { + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).unwrap(); + list.iter_mut().find(|x| x.id == title).unwrap().style = Some(style.clone()); + Ok(()) + }) + .unwrap(); + } + s.execute("text.set", &json!({"id": title.0, "text": "Styled title"})).unwrap(); + let want = (resolved_box(&s, title), resolved_look(&s, title)); + s.execute("selection.select", &json!({"id": title.0})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want, "style on the slide: {style_on_slide}"); + } +} + +/// The master's placeholder style never reaches the copy's text: the original's text ignores it +/// (only the shape's or the layout's font reference counts), so the copy must too. +#[test] +fn a_pasted_placeholder_ignores_the_masters_font_reference() { + use deckcraft_color::SchemeSlot; + use deckcraft_model::{ColorRef, ShapeStyle}; + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let title = edit_title_parents( + &mut s, + |l| l.style = None, + |m| { + m.style = Some(ShapeStyle { + line_ref: (0, ColorRef::scheme(SchemeSlot::Accent2)), + fill_ref: (0, ColorRef::scheme(SchemeSlot::Accent1)), + effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)), + font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Accent6))), + }) + }, + ); + s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap(); + let want = (resolved_box(&s, title), resolved_look(&s, title)); + assert!(want.1.contains("+mj-lt"), "the title draws in the heading font: {}", want.1); + s.execute("selection.select", &json!({"id": title.0})).unwrap(); + s.execute("edit.duplicate", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want); +} + /// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles). #[test] fn sample_placeholders_paste_with_their_look() { diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8cf87582d..5f1651de9 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -4,7 +4,7 @@ use deckcraft_color::{ColorScheme, Rgba, SchemeSlot}; use deckcraft_geom::Xfrm; -use crate::style::{ColorRef, Effects, Fill, Line}; +use crate::style::{ColorRef, Effects, Fill, Line, ShapeStyle}; use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps}; use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide}; @@ -229,30 +229,53 @@ pub fn detach_placeholder(ctx: &Ctx, shape: &mut Shape) { if shape.ph.is_none() { return; } - let (lp, mp) = parents(ctx, shape); - let parents = [lp, mp].into_iter().flatten(); - shape.xfrm = Some(xfrm(ctx, shape)); - if shape.fill.is_none() && shape.style.is_none() { - shape.fill = parents.clone().find_map(|s| s.fill.clone()); - } - if shape.style.is_none() { - shape.style = parents.clone().find_map(|s| s.style.clone()); - } - if shape.effects.is_none() { - shape.effects = parents.clone().find_map(|s| s.effects.clone()); - } - let mut line = shape.line.clone(); - for p in parents.filter_map(|s| s.line.as_ref()) { - line.get_or_insert_with(Line::default).inherit(p); - } - shape.line = line; - if shape.text.is_some() { - let body = body(ctx, shape); - let levels: Vec> = (0..9).map(|l| Some(level_style(ctx, shape, l))).collect(); - if let Some(t) = shape.text.as_mut() { - t.body = body; - t.list_style = ListStyle { levels }; + // The text first: `level_style` reads the shape's own style, which changes below. + let text = shape.text.as_ref().map(|_| (body(ctx, shape), (0..9).map(|l| Some(level_style(ctx, shape, l))).collect::>())); + let own = shape.clone(); + let (lp, mp) = parents(ctx, &own); + let chain = [Some(&own), lp, mp].into_iter().flatten(); + + // Each property comes from where `fill`, `line` and `effects` take it: the first shape in the + // chain with an explicit value or a style. Lines merge explicit values up to the first styled + // shape, whose style line fills the rest. + let fill_src = chain.clone().find(|s| s.fill.is_some() || s.style.is_some()); + let effects_src = chain.clone().find(|s| s.effects.is_some() || s.style.is_some()); + let mut line = None; + let mut line_src = None; + for s in chain.clone() { + if let Some(l) = &s.line { + line.get_or_insert_with(Line::default).inherit(l); } + if s.style.is_some() { + line_src = Some(s); + break; + } + } + // Text takes its font reference from the shape's style or its layout placeholder's (a table's + // from its own only), never the master's. + let table = matches!(own.kind, crate::ShapeKind::Table(_)); + let font_src = own.style.as_ref().or(lp.filter(|_| !table).and_then(|s| s.style.as_ref())); + + // One style for the copy, each reference taken from the shape that supplied that property. A + // property with an explicit value ignores its reference, except a gradient without stops, + // which takes them from its own shape's style; a neutral reference (index 0) adds nothing. + let style_of = |s: Option<&Shape>| s.and_then(|s| s.style.clone()); + let neutral = (0, ColorRef::scheme(SchemeSlot::Tx1)); + let style = chain.clone().any(|s| s.style.is_some()).then(|| ShapeStyle { + fill_ref: style_of(fill_src).map_or_else(|| neutral.clone(), |st| st.fill_ref), + line_ref: style_of(line_src).map_or_else(|| neutral.clone(), |st| st.line_ref), + effect_ref: style_of(effects_src).map_or_else(|| neutral.clone(), |st| st.effect_ref), + font_ref: font_src.map_or_else(|| ("none".into(), None), |st| st.font_ref.clone()), + }); + + shape.xfrm = Some(xfrm(ctx, &own)); + shape.fill = fill_src.and_then(|s| s.fill.clone()); + shape.effects = effects_src.and_then(|s| s.effects.clone()); + shape.line = line; + shape.style = style; + if let (Some(t), Some((body, levels))) = (shape.text.as_mut(), text) { + t.body = body; + t.list_style = ListStyle { levels }; } shape.ph = None; } From d6cc51e07a5cea0043778624f9f1445e1abe9a91 Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Wed, 4 Nov 2026 21:43:33 +0100 Subject: [PATCH 5/7] Alt+drag detaches placeholders; Slide Master view pastes them as placeholders Review follow-up on #1658: - The drag duplicate cleared `ph` without detaching, so a title or body dragged off with Alt lost its size and bullets. Copy, Duplicate and the drag now share `detach_on_slide`. - Detaching at copy time turned a slide placeholder pasted in Slide Master view into a regular shape. The clipboard keeps the originals, and a paste there takes them, so it stays a placeholder as before this PR. Keep Source Formatting bakes them too. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/edit.rs | 43 ++++++++++++++-------- crates/engine/src/lib.rs | 3 ++ crates/engine/src/tests.rs | 57 +++++++++++++++++++++++++++++ crates/engine/src/tools/geometry.rs | 1 + 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bb42939ac..db6929860 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,7 +164,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it | -| 2026-11-01 | Copy, Cut, Paste and Duplicate of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | +| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | | 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9674bb8ad..9ff5cbb71 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() }; return Ok(plain); } - let shapes: Vec = st + let mut shapes: Vec = st .selection .shapes .iter() @@ -120,21 +120,28 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { }) }) .collect(); - // A placeholder pastes on a slide as a regular shape; it keeps the look it inherited here. - let mut shapes = shapes; - if st.selection.target == crate::Target::Slides - && let Some(ctx) = st.current_slide().and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl)) - { - shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh)); - } + // Copying is the last chance to read what a placeholder inherits: a cut removes it, and the + // paste can be in another deck. Slide Master view pastes the originals instead. + let placeholders: Vec = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect(); + detach_on_slide(&st.doc, &st.selection, &mut shapes); let mids = crate::media_refs::shapes_media(&shapes); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone())); - s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() }; + s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() }; Ok(plain) } +/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit +/// there. In Slide Master view a copy stays a placeholder, so this changes nothing. +pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) { + if sel.target == crate::Target::Slides + && let Some(ctx) = super::text::ctx_for(doc, sel) + { + shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh)); + } +} + fn copying_caret(s: &Session) -> bool { s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range())) } @@ -326,6 +333,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { return s.edit(|doc, sel| { let media = bring_media("edit.paste", doc, &clip.media)?; let mut shapes = clip.shapes.clone(); + if sel.target != crate::Target::Slides { + for sh in &mut shapes { + if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) { + sh.clone_from(original); + } + } + } reid("edit.paste", doc, &mut shapes)?; crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media)); let (mut taken, mut count) = super::current_names(doc, sel); @@ -347,7 +361,8 @@ fn paste(s: &mut Session, p: &Value) -> Result { x.x += off; x.y += off; } - // A pasted placeholder becomes a regular shape; copying gave it its look. + // A placeholder pasted on a slide becomes a regular shape. One copied from a slide + // already has its look; see `copy_inner`. if sh.ph.is_some() && sel.target == crate::Target::Slides { sh.ph = None; } @@ -413,6 +428,7 @@ fn paste_special(s: &mut Session, p: &Value) -> Result { let saved = s.clipboard.clone(); if let Some((scheme, fonts)) = &saved.source_theme { bake_theme(&mut s.clipboard.shapes, scheme, fonts); + bake_theme(&mut s.clipboard.placeholders, scheme, fonts); } let r = paste(s, &json!({})); let pastes = s.clipboard.pastes; @@ -540,12 +556,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let sources: Vec = copies.iter().map(|c| c.id).collect(); let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); - // A duplicated placeholder becomes a regular shape that keeps the look it inherited. - if sel.target == crate::Target::Slides - && let Some(ctx) = doc.slides.get(sel.slide).and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(doc, sl)) - { - copies.iter_mut().for_each(|c| deckcraft_model::resolve::detach_placeholder(&ctx, c)); - } + detach_on_slide(doc, sel, &mut copies); // Duplicating the copies of the last Duplicate again repeats how far they were moved // since (#966); anything else gets the usual offset. let (dx, dy) = last diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c6d9f4503..dbd2b68b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -457,6 +457,9 @@ pub struct MediaStatus { #[derive(Clone, Debug, Default)] pub struct Clipboard { pub shapes: Vec, + /// The copied placeholders as they were, by id. `shapes` holds a slide's placeholders as + /// regular shapes; a paste in Slide Master view takes these, which stay placeholders. + pub placeholders: Vec, pub slides: Vec, /// Media the copied shapes/slides reference. pub media: Vec, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fb7f2b039..05baa3ca3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -267,6 +267,63 @@ fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +/// Dragging a copy of a placeholder off it keeps the look too: the title's size and font, the +/// body's bullets. `edit.duplicate` is not the only way to duplicate. +#[test] +fn a_placeholder_duplicated_by_dragging_keeps_its_look() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let phs: Vec = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect(); + assert!(phs.len() >= 2, "a title and a body placeholder"); + for id in phs { + s.execute("text.set", &json!({"id": id.0, "text": "First\nSecond"})).unwrap(); + let want = resolved_look(&s, id); + s.execute("selection.select", &json!({"id": id.0})).unwrap(); + let st = s.doc().unwrap(); + let x = crate::cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()); + let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); + let alt = |kind, x, y| PointerEvent { mods: Mods { alt: true, ..Default::default() }, ..tev(kind, x, y) }; + s.pointer(alt(PointerKind::Down, cx, cy)).unwrap(); + s.pointer(alt(PointerKind::Drag, cx + 30.0, cy + 30.0)).unwrap(); + s.pointer(alt(PointerKind::Up, cx + 30.0, cy + 30.0)).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_ne!(copy, id, "the drag made a copy"); + assert!(s.doc().unwrap().shape(id).unwrap().ph.is_some(), "the original stays a placeholder"); + assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "the copy is a regular shape"); + assert_eq!(resolved_look(&s, copy), want, "placeholder {}", id.0); + } +} + +/// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where +/// it lands; only a paste on a slide turns it into a regular shape with the look it had. +#[test] +fn a_slide_placeholder_pasted_in_slide_master_view_stays_a_placeholder() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id; + s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap(); + let want = resolved_look(&s, title); + let source = s.doc().unwrap().shape(title).unwrap().clone(); + s.execute("selection.select", &json!({"id": title.0})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + + s.execute("view.slideMaster", &json!({})).unwrap(); + for paste in ["edit.paste", "edit.pasteSpecial"] { + s.execute(paste, &json!({"as": "keepSource"})).unwrap(); + let st = s.doc().unwrap(); + let copy = st.shape(st.selection.shapes[0]).unwrap(); + assert_eq!(copy.ph.as_ref().map(|p| p.kind), source.ph.as_ref().map(|p| p.kind), "{paste} keeps the placeholder"); + let list = copy.text.as_ref().map(|t| t.list_style.clone()); + assert_eq!(list, source.text.as_ref().map(|t| t.list_style.clone()), "{paste} adds no baked styles"); + } + s.execute("view.closeMaster", &json!({})).unwrap(); + + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "on a slide the copy is a regular shape"); + assert_eq!(resolved_look(&s, copy), want); +} + /// The resolved fill, line and effects of shape `id` on the current slide, with their style colours. fn resolved_box(s: &Session, id: ShapeId) -> String { use deckcraft_model::resolve; diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs index cbbd01275..0da6aa60c 100644 --- a/crates/engine/src/tools/geometry.rs +++ b/crates/engine/src/tools/geometry.rs @@ -101,6 +101,7 @@ impl Session { let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let boxes: Vec = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + cmd::edit::detach_on_slide(doc, sel, &mut copies); cmd::edit::reid("edit.duplicate", doc, &mut copies)?; for (c, b) in copies.iter_mut().zip(boxes) { c.xfrm = Some(b); From 30ccc38e9032fd240ac56f604693ba9c11df67b6 Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Wed, 4 Nov 2026 22:02:22 +0100 Subject: [PATCH 6/7] Keep Source Formatting uses the slide's colour map Review follow-up on #1658 (CodeRabbit): `source_theme` stored the master's scheme, so a slide or layout that overrides the colour map had its colours fixed through the wrong map. Detaching made this reach a placeholder's inherited colours: a title drawn white under an override pasted black. Store the scheme the slide draws with. Paste as Picture reads the same value. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 6 ++++- crates/engine/src/tests.rs | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ff5cbb71..20874d5dc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -127,7 +127,11 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { let mids = crate::media_refs::shapes_media(&shapes); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); - let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone())); + // The scheme the slide draws with: its or its layout's colour map, else the master's. + let source_theme = st + .current_slide() + .and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl)) + .map(|ctx| (ctx.scheme.clone(), ctx.master.theme.fonts.clone())); s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() }; Ok(plain) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 05baa3ca3..2c3ecedee 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -294,6 +294,48 @@ fn a_placeholder_duplicated_by_dragging_keeps_its_look() { } } +/// Keep Source Formatting fixes a copy's theme colours as the source drew them: through the colour +/// map of its slide or layout, not the master's. A title's inherited `tx1` stays the light colour +/// an override gave it. +#[test] +fn keep_source_formatting_uses_the_slides_colour_map() { + use deckcraft_model::resolve; + let first_run_colour = |s: &Session, id: ShapeId| { + let st = s.doc().unwrap(); + let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap(); + let sh = st.shape(id).unwrap(); + let p = &sh.text.as_ref().unwrap().paragraphs[0]; + resolve::text_color(&ctx, &resolve::run(&ctx, sh, p, &p.runs[0].props)) + }; + let swapped = || Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec()); + for on_layout in [false, true] { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id; + s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap(); + let plain = first_run_colour(&s, title); + let st = s.doc_mut().unwrap(); + let at = st.selection.slide; + let doc = std::sync::Arc::make_mut(&mut st.doc); + if on_layout { + let layout = doc.slides[at].layout; + let m = std::sync::Arc::make_mut(&mut doc.masters[0]); + m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped(); + } else { + std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped(); + } + let want = first_run_colour(&s, title); + assert_ne!(want, plain, "the override changes how the title draws"); + + s.execute("selection.select", &json!({"id": title.0})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_ne!(copy, title); + assert_eq!(first_run_colour(&s, copy), want, "override on the layout: {on_layout}"); + } +} + /// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where /// it lands; only a paste on a slide turns it into a regular shape with the look it had. #[test] From 070f12cc9c8d38dd1f8934a29a5ec2a67882fe0b Mon Sep 17 00:00:00 2001 From: Stephan Schott-Verdugo Date: Wed, 4 Nov 2026 22:23:35 +0100 Subject: [PATCH 7/7] Keep Source Formatting resolves colours as the renderer does Review follow-up on #1658 (CodeRabbit), both from storing the slide's mapped scheme in 30ccc38e: - Copying in Slide Master view used the current slide's colour map. Use `ctx_for`, so the theme is that of what is being edited. - `ColorScheme` shares an entry between `dk1` and `tx1`, so a mapped scheme changed direct `dk1` colours too: a dark fill pasted white. `SourceTheme` keeps the theme's colours and the map apart, and `bake_theme` maps only `tx1`, `bg1`, `tx2` and `bg2`, as `Ctx::color` does. The browser image test now checks that non-picture pastes keep their text. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 37 +++++++-------- crates/engine/src/lib.rs | 16 +++++-- crates/engine/src/tests.rs | 84 +++++++++++++++++++++++++++++++++-- crates/ui-egui/src/tests.rs | 4 ++ 4 files changed, 117 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 20874d5dc..743aa8362 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -127,11 +127,12 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { let mids = crate::media_refs::shapes_media(&shapes); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); - // The scheme the slide draws with: its or its layout's colour map, else the master's. - let source_theme = st - .current_slide() - .and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl)) - .map(|ctx| (ctx.scheme.clone(), ctx.master.theme.fonts.clone())); + // The theme of what is being edited: a slide's or layout's colour map, else the master's. + let source_theme = super::text::ctx_for(&st.doc, &st.selection).map(|ctx| crate::SourceTheme { + colors: ctx.master.theme.colors.clone(), + color_map: ctx.color_map.clone(), + fonts: ctx.master.theme.fonts.clone(), + }); s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() }; Ok(plain) } @@ -430,9 +431,9 @@ fn paste_special(s: &mut Session, p: &Value) -> Result { "text" | "keepTextOnly" => paste_text(s, &json!({})), "keepSource" | "keepSourceFormatting" => { let saved = s.clipboard.clone(); - if let Some((scheme, fonts)) = &saved.source_theme { - bake_theme(&mut s.clipboard.shapes, scheme, fonts); - bake_theme(&mut s.clipboard.placeholders, scheme, fonts); + if let Some(theme) = &saved.source_theme { + bake_theme(&mut s.clipboard.shapes, theme); + bake_theme(&mut s.clipboard.placeholders, theme); } let r = paste(s, &json!({})); let pastes = s.clipboard.pastes; @@ -447,8 +448,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result { /// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the /// shapes keep their look under another theme. -pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) { - fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) { +pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) { + fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) { if depth > 256 { return; } @@ -457,7 +458,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc if o.get("kind").and_then(Value::as_str) == Some("scheme") && let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::(x.clone()).ok()) && slot != deckcraft_color::SchemeSlot::PhClr - && let Ok(rgb) = serde_json::to_value(scheme.get(slot)) + && let Ok(rgb) = serde_json::to_value(theme.colors.get(deckcraft_model::map_slot(&theme.color_map, slot))) { o.clear(); o.insert("kind".into(), json!("rgb")); @@ -465,14 +466,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc return; } for x in o.values_mut() { - rec(x, scheme, fonts, depth + 1); + rec(x, theme, depth + 1); } } - Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)), + Value::Array(a) => a.iter_mut().for_each(|x| rec(x, theme, depth + 1)), Value::String(t) if t.starts_with('+') => { let set = match t.get(1..3) { - Some("mj") => &fonts.major, - Some("mn") => &fonts.minor, + Some("mj") => &theme.fonts.major, + Some("mn") => &theme.fonts.minor, _ => return, }; let face = match t.get(3..) { @@ -490,7 +491,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc } for sh in shapes.iter_mut() { let Ok(mut v) = serde_json::to_value(&*sh) else { continue }; - rec(&mut v, scheme, fonts, 0); + rec(&mut v, theme, 0); if let Ok(baked) = serde_json::from_value::(v) { *sh = baked; } @@ -503,8 +504,8 @@ fn paste_picture(s: &mut Session) -> Result { if clip.shapes.is_empty() { return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture")); } - if let Some((scheme, fonts)) = &clip.source_theme { - bake_theme(&mut clip.shapes, scheme, fonts); + if let Some(theme) = &clip.source_theme { + bake_theme(&mut clip.shapes, theme); } let st = s.doc()?; let mut doc = (*st.doc).clone(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dbd2b68b6..d8ebb5a23 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -454,6 +454,17 @@ pub struct MediaStatus { } /// Clipboard contents copied within the app. +/// The theme a copy drew with. The colours and the map stay apart, as in +/// `deckcraft_model::resolve::Ctx::color`: the map renames only `tx1`, `bg1`, `tx2` and `bg2`, and +/// a direct `dk1` keeps the theme's colour. +#[derive(Clone, Debug)] +pub struct SourceTheme { + /// The theme's own colours, no colour map applied. + pub colors: deckcraft_color::ColorScheme, + pub color_map: Vec<(String, String)>, + pub fonts: deckcraft_model::theme::FontScheme, +} + #[derive(Clone, Debug, Default)] pub struct Clipboard { pub shapes: Vec, @@ -465,9 +476,8 @@ pub struct Clipboard { pub media: Vec, pub text: Option, pub plain: String, - /// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special › - /// Keep Source Formatting. - pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>, + /// The theme the shapes were copied under, for Paste Special › Keep Source Formatting. + pub source_theme: Option, /// How many times the shapes were pasted (each paste offsets further). pub pastes: u32, } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2c3ecedee..5f3eb5c0b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -307,7 +307,6 @@ fn keep_source_formatting_uses_the_slides_colour_map() { let p = &sh.text.as_ref().unwrap().paragraphs[0]; resolve::text_color(&ctx, &resolve::run(&ctx, sh, p, &p.runs[0].props)) }; - let swapped = || Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec()); for on_layout in [false, true] { let mut s = session(); s.execute("slide.new", &json!({})).unwrap(); @@ -320,9 +319,9 @@ fn keep_source_formatting_uses_the_slides_colour_map() { if on_layout { let layout = doc.slides[at].layout; let m = std::sync::Arc::make_mut(&mut doc.masters[0]); - m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped(); + m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped_colour_map(); } else { - std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped(); + std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped_colour_map(); } let want = first_run_colour(&s, title); assert_ne!(want, plain, "the override changes how the title draws"); @@ -336,6 +335,85 @@ fn keep_source_formatting_uses_the_slides_colour_map() { } } +/// A colour map that swaps text and background, as a dark slide or layout has. +fn swapped_colour_map() -> Option> { + Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec()) +} + +/// A new rectangle in the edited tree, filled with theme colour `slot`. +fn rect_filled_with(s: &mut Session, slot: deckcraft_color::SchemeSlot) -> ShapeId { + use deckcraft_model::{ColorRef, Fill}; + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let id = ShapeId(u32::try_from(id).unwrap()); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).unwrap(); + list.iter_mut().find(|x| x.id == id).unwrap().fill = Some(Fill::solid(ColorRef::scheme(slot))); + Ok(()) + }) + .unwrap(); + id +} + +/// How shape `id`'s fill draws in the edited tree. +fn drawn_fill(s: &Session, id: ShapeId) -> deckcraft_color::Rgba { + use deckcraft_model::{Fill, resolve}; + let st = s.doc().unwrap(); + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap(); + match resolve::fill(&ctx, st.shape(id).unwrap()).0 { + Some(Fill::Solid { color }) => ctx.color(&color, None), + other => panic!("not a solid fill: {other:?}"), + } +} + +/// Copy the selected shape and paste it with Keep Source Formatting; the copy's id. +fn keep_source_copy(s: &mut Session, id: ShapeId) -> ShapeId { + s.execute("selection.select", &json!({"id": id.0})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_ne!(copy, id); + copy +} + +/// A colour map renames only `tx1`, `bg1`, `tx2` and `bg2`. Keep Source Formatting fixes every slot +/// as it draws: `tx1` follows the map, while a direct `dk1` stays the theme's dark colour. +#[test] +fn keep_source_formatting_maps_only_the_alias_colours() { + use deckcraft_color::SchemeSlot::{self, *}; + let mut s = session(); + let st = s.doc_mut().unwrap(); + let at = st.selection.slide; + std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map(); + let slots: [SchemeSlot; 9] = [Dk1, Lt1, Dk2, Lt2, Tx1, Bg1, Tx2, Bg2, Accent1]; + let mut drawn = vec![]; + for slot in slots { + let id = rect_filled_with(&mut s, slot); + let want = drawn_fill(&s, id); + let copy = keep_source_copy(&mut s, id); + assert_eq!(drawn_fill(&s, copy), want, "{slot:?}"); + drawn.push(want); + } + assert_ne!(drawn[0], drawn[4], "the map sets `tx1` apart from `dk1`"); +} + +/// In Slide Master view a copy draws through the edited master's map, not an override on the slide +/// that happens to be current. +#[test] +fn keep_source_formatting_in_slide_master_view_ignores_the_slides_colour_map() { + use deckcraft_color::SchemeSlot::Tx1; + for master in [false, true] { + let mut s = session(); + let st = s.doc_mut().unwrap(); + let at = st.selection.slide; + std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map(); + s.execute("view.slideMaster", &if master { json!({"master": 0}) } else { json!({}) }).unwrap(); + let id = rect_filled_with(&mut s, Tx1); + let want = drawn_fill(&s, id); + let copy = keep_source_copy(&mut s, id); + assert_eq!(drawn_fill(&s, copy), want, "editing the master: {master}"); + } +} + /// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where /// it lands; only a paste on a slide turns it into a regular shape with the look it had. #[test] diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 2eca813cf..f8fe10aab 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -419,6 +419,10 @@ fn ctrl_v_pastes_the_picture_a_browser_copied_not_its_link() { let pictures = shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count(); assert_eq!(pictures, pictures_after, "{text:?}"); assert_eq!(shapes.len(), shapes_before + 1, "one shape, picture or text, for {text:?}"); + if pictures_after == 0 { + let pasted = shapes.last().and_then(|s| s.text.as_ref()).map(deckcraft_model::TextBody::text); + assert_eq!(pasted.as_deref(), Some(text.as_str()), "the text survives"); + } } }