From d5c62b640acb3a1861b57e79cb6c9dd50a702916 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 11 Oct 2026 19:54:21 -0600 Subject: [PATCH] Ctrl+C then Ctrl+V pastes objects that carry no text (Fixes #182) egui-winit drops Ctrl+V when the system clipboard is empty. Copying a rectangle or picture puts no text on it, so the shortcut never reached the session and only the context menu could paste objects. Copy and cut now leave an invisible word joiner on the system clipboard in that case, and paste maps it back to the session's own clipboard. --- crates/ui-egui/src/lib.rs | 46 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..edf4ca7c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -127,6 +127,17 @@ impl Default for UiState { } } +/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) +/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the +/// shortcut never reached the session and only the context menu could paste objects (#182). An +/// invisible word joiner, so a paste into another app adds nothing visible. +const OBJECTS_MARKER: &str = "\u{2060}"; + +/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. +fn system_clipboard_text(t: &str) -> String { + if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } +} + /// UI-only commands (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.normal", "Normal", None, "{}"), @@ -640,17 +651,19 @@ impl SlideApp { if let Ok(v) = self.session.execute("edit.copy", &json!({})) && let Some(t) = v.get("text").and_then(Value::as_str) { - ctx.copy_text(t.to_string()); + ctx.copy_text(system_clipboard_text(t)); } } egui::Event::Cut => { if let Ok(v) = self.session.execute("edit.cut", &json!({})) && let Some(t) = v.get("text").and_then(Value::as_str) { - ctx.copy_text(t.to_string()); + ctx.copy_text(system_clipboard_text(t)); } } 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 }; // An image on the clipboard (screenshots, copied pictures) becomes a picture. let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let r = match img { @@ -938,4 +951,33 @@ mod tests { app.run("app.home", json!({})).unwrap(); assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway"); } + + /// #182: Ctrl+C then Ctrl+V did nothing for objects without text (a rectangle), because + /// egui-winit drops Ctrl+V when the system clipboard is empty; the context menu worked. + #[test] + fn copy_then_paste_shortcut_duplicates_an_object_without_text() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); + let count = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.doc().unwrap().current_slide().unwrap().shapes.len(); + let before = count(&harness); + + // What the copy leaves on the system clipboard is what egui-winit hands back on Ctrl+V. + assert_eq!(system_clipboard_text(""), OBJECTS_MARKER); + assert_eq!(system_clipboard_text("hello"), "hello"); + harness.event(egui::Event::Copy); + harness.run_steps(2); + harness.event(egui::Event::Paste(OBJECTS_MARKER.to_string())); + harness.run_steps(2); + assert_eq!(count(&harness), before + 1, "the shortcut pastes a copy of the rectangle"); + let d = harness.state().session.active().unwrap(); + let pasted = d.doc.slides[0].shapes.last().unwrap(); + 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"); + } }