diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c054e06..4eec726c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -132,6 +132,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, "{}"), @@ -900,17 +911,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 { @@ -1467,4 +1480,33 @@ mod tests { assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Cut does too"); assert!(harness.state().session.active().unwrap().doc.slides[0].shapes.iter().all(|s| s.id.0 != title), "and removes the shape"); } + + /// #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"); + } }