Merge PR #88 from MarekSpodBiedry: Paste: Ctrl+V pastes an image-only clipboard as a picture (#53)

# Conflicts:
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:00:34 -04:00
+58 -1
View File
@@ -288,6 +288,8 @@ pub struct SlideApp {
pub sorter: sorter::State,
/// The slide thumbnail pane was the last thing clicked, so ⌘A selects slides, not shapes.
pub(crate) slides_focused: bool,
/// A V key press or a Paste event arrived since the last V release (see `keyboard`).
v_seen: bool,
}
impl SlideApp {
@@ -335,6 +337,7 @@ impl SlideApp {
doc_window: None,
sorter: sorter::State::default(),
slides_focused: false,
v_seen: false,
}
}
@@ -1007,10 +1010,21 @@ impl SlideApp {
/// Keyboard and clipboard for the slide (when no text field has focus).
fn keyboard(&mut self, ctx: &egui::Context) {
let events = ctx.input(|i| i.events.clone());
// Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the
// key press, so the release of a V press never seen is the paste. Tracked even while a text
// field has focus, so that a V typed there is not taken for one.
let mut image_paste = false;
for e in &events {
match e {
egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true,
egui::Event::Key { key: egui::Key::V, pressed: false, .. } => image_paste = !std::mem::take(&mut self.v_seen),
_ => {}
}
}
if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() {
return;
}
let events = ctx.input(|i| i.events.clone());
if self.backstage.is_some() {
// The File tab: its own keys (Esc goes back, arrows walk the side menu) and app
// shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the
@@ -1033,6 +1047,13 @@ impl SlideApp {
self.drain_requests();
return;
}
let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
if let Some(png) = img {
let data = deckcraft_engine::cmd::base64_encode(&png);
if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) {
self.set_status(e.to_string());
}
}
for e in events {
match e {
egui::Event::Text(t) => {
@@ -1582,6 +1603,42 @@ mod tests {
assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export"));
}
/// #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]
fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() {
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
};
assert!(!png.is_empty());
let services = Services { clipboard_image: Some(Box::new(move || Some(png.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 pictures = |app: &SlideApp| {
let d = app.session.active().unwrap();
d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count()
};
let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
// A text paste (Paste event on the press) does not also paste the image on release.
harness.event(egui::Event::Paste("hello".into()));
harness.run_steps(2);
harness.event(v(false));
harness.run_steps(2);
assert_eq!(pictures(harness.state()), 0);
// What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone.
harness.event(v(false));
harness.run_steps(2);
assert_eq!(pictures(harness.state()), 1);
}
#[test]
fn home_needs_an_open_presentation() {
let mut app = SlideApp::new(Session::new(), Services::default());