Clipboard: paste a picture from the system clipboard at the active cell (#699)

Cmd/Ctrl+V dropped egui's PasteImage event, and the ribbon/menu Paste only read
text, so screenshots and images copied in a browser reported "Nothing to paste".
Both now encode the pixels as PNG (bounded to 16384 px per side, 64 Mpx) and run
insert.picture as a floating picture anchored at the active cell, as Excel does.
Text on the clipboard still wins. New host service: Services::paste_image (arboard).

Fixes #699, #3018, #3182.
This commit is contained in:
adifiedio committed 2026-11-03 14:14:34 +05:30
1 parent 178f3eb745
commit ac27137cd8
8 files changed
+134 -15

No files matched your search

+2 -2
View File
@@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
gridcraft-mcp = { workspace = true }
# The control channel's token (OS random numbers); already in the tree.
getrandom = "0.4"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["wayland-data-control"] }
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
+5 -1
View File
@@ -154,7 +154,7 @@ fn read_clipboard<T>(
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone());
Services {
copy_html: Some(Box::new(move |html, text| {
let mut clipboard = clipboard.borrow_mut();
@@ -165,6 +165,10 @@ fn services() -> Services {
})),
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
paste_image: Some(Box::new(move || {
let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?;
image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned())
})),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
+47 -3
View File
@@ -48,12 +48,36 @@ impl SheetApp {
}
/// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no
/// text there, no host clipboard (web), or the text that was there when GridCraft last copied
/// without publishing, GridCraft's own clipboard is pasted.
/// text there but a picture, the picture is pasted. With neither, no host clipboard (web), or
/// the text that was there when GridCraft last copied without publishing, GridCraft's own
/// clipboard is pasted.
pub fn paste_from_clipboard(&mut self) {
match self.system_clipboard_text() {
Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text),
_ => self.run_or_alert("edit.paste", json!({})),
Some(_) => self.run_or_alert("edit.paste", json!({})),
None => match self.services.paste_image.as_mut().and_then(|read| read()) {
Some(image) => self.paste_picture(image),
None => self.run_or_alert("edit.paste", json!({})),
},
}
}
/// Paste a picture from the system clipboard (a screenshot, an image copied in a browser) as
/// a floating picture whose top-left corner is the active cell, as Excel does.
pub fn paste_picture(&mut self, image: image::RgbaImage) {
match encode_png(&image) {
Ok(png) => self.run_or_alert("insert.picture", json!({"base64": gridcraft_engine::io::base64_encode(&png), "mime": "image/png"})),
Err(e) => self.alert(e),
}
}
/// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`).
pub fn paste_color_image(&mut self, image: &egui::ColorImage) {
let [w, h] = image.size;
let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
match u32::try_from(w).ok().zip(u32::try_from(h).ok()).and_then(|(w, h)| image::RgbaImage::from_raw(w, h, rgba)) {
Some(image) => self.paste_picture(image),
None => self.alert("The picture on the clipboard can't be read.".into()),
}
}
@@ -75,3 +99,23 @@ impl SheetApp {
}
}
}
/// Largest clipboard picture pasted, per side and in total (an 8K screenshot fits).
const MAX_PICTURE_SIDE: u32 = 16_384;
const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024;
/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory.
fn encode_png(image: &image::RgbaImage) -> Result<Vec<u8>, String> {
let (w, h) = image.dimensions();
if w == 0 || h == 0 {
return Err("The picture on the clipboard is empty.".into());
}
if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS {
return Err("The picture on the clipboard is too large to paste.".into());
}
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("The picture on the clipboard can't be pasted: {e}"))?;
Ok(png)
}
+3 -5
View File
@@ -51,11 +51,9 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.paste_external(text),
// GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard
// command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a
// floating picture without a chosen anchor/size would be a new feature, not parity.
// A text representation, when the clipboard has one, still arrives as `Paste` above.
egui::Event::PasteImage(_) => {}
// A picture with no text on the clipboard (a screenshot, a browser image) is pasted as
// a floating picture at the active cell, as Excel does.
egui::Event::PasteImage(image) => app.paste_color_image(&image),
// PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
// insert text at, so a middle click on a cell is deliberately not a paste.
egui::Event::MiddleClickPaste { .. } => {}
+3
View File
@@ -32,6 +32,9 @@ pub struct Services {
/// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes
/// GridCraft's own clipboard.
pub paste_text: Option<Box<dyn FnMut() -> Option<String>>>,
/// Read the picture on the system clipboard for the ribbon and menu Paste commands when it
/// holds no text (Cmd/Ctrl+V gets it from egui's `Event::PasteImage`).
pub paste_image: Option<Box<dyn FnMut() -> Option<image::RgbaImage>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Download>,
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
+68
View File
@@ -482,3 +482,71 @@ fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() {
h.run_steps(2);
assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes");
}
/// The floating pictures on the active sheet: (anchor cell, width, height, mime).
fn pictures(h: &Harness) -> Vec<(String, f32, f32, String)> {
let sh = h.state().session.active().unwrap().wb.active().unwrap();
sh.images.iter().map(|i| (i.anchor.cell.a1(), i.anchor.width, i.anchor.height, i.mime.clone())).collect()
}
#[test]
fn cmd_v_with_a_picture_on_the_clipboard_inserts_it_at_the_active_cell() {
// A screenshot or a browser image: the clipboard has pixels and no text (#699, #3018, #3182).
let mut h = harness(Services::default());
h.state_mut().run("selection.set", json!({"cell": "C5"})).unwrap();
let image = egui::ColorImage::new([4, 3], vec![egui::Color32::from_rgb(200, 30, 30); 12]);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
h.run_steps(2);
assert!(h.state().message.is_none(), "{:?}", h.state().message);
assert_eq!(pictures(&h), vec![("C5".to_string(), 4.0, 3.0, "image/png".to_string())]);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
let png = image::load_from_memory(&sh.images[0].data).unwrap().to_rgba8();
assert_eq!(png.get_pixel(3, 2).0, [200, 30, 30, 255], "the pixels survive the PNG round trip");
// One undo step removes it.
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(pictures(&h).is_empty());
}
#[test]
fn a_malformed_clipboard_picture_is_refused_without_inserting() {
let mut h = harness(Services::default());
let mut image = egui::ColorImage::new([4, 3], vec![egui::Color32::WHITE; 12]);
image.pixels.truncate(5);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
h.run_steps(2);
assert!(pictures(&h).is_empty());
assert!(h.state().message.is_some(), "the user is told the paste failed");
dismiss(&mut h);
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new()))));
h.run_steps(2);
assert!(pictures(&h).is_empty());
}
#[test]
fn ribbon_paste_pastes_a_clipboard_picture_when_there_is_no_text() {
let reads = Rc::new(RefCell::new(0));
let count = reads.clone();
let (_, mut services) = system_clipboard("");
services.paste_image = Some(Box::new(move || {
*count.borrow_mut() += 1;
Some(image::RgbaImage::from_pixel(5, 2, image::Rgba([0, 0, 255, 255])))
}));
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "B7"})).unwrap();
click_paste(&mut h);
assert_eq!(*reads.borrow(), 1);
assert_eq!(pictures(&h), vec![("B7".to_string(), 5.0, 2.0, "image/png".to_string())]);
}
#[test]
fn ribbon_paste_prefers_clipboard_text_over_a_picture() {
let (_, mut services) = system_clipboard("words");
services.paste_image = Some(Box::new(|| Some(image::RgbaImage::new(1, 1))));
let mut h = harness(services);
h.state_mut().run("selection.set", json!({"cell": "D2"})).unwrap();
click_paste(&mut h);
assert_eq!(value_at(&h, "D2"), Some(Value::from("words")));
assert!(pictures(&h).is_empty());
}
+4 -3
View File
@@ -17,9 +17,10 @@ The command API accepts the same placement:
{"command":"insert.picture","params":{"path":"picture.png","placement":"cell","at":"B2","alt":"Product photo"}}
```
This initial feature handles static pictures. Pasting an image from the operating system's
clipboard, converting between floating and cell pictures, and the `IMAGE()` function remain
separate work. Formula references and arithmetic do not carry pictures: the calculation
This initial feature handles static pictures. A picture on the operating system's clipboard (a
screenshot, an image copied in a browser) pastes with Cmd/Ctrl+V or Paste as a floating picture
at the active cell, as in Excel. Pasting it into a cell, converting between floating and cell
pictures, and the `IMAGE()` function remain separate work. Formula references and arithmetic do not carry pictures: the calculation
engine uses a `#VALUE!` fallback, including when a picture is referenced from another cell.
Text-only exports also use that fallback. The picture cell still counts as occupied and
blocks a formula's spill range.
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -308,6 +308,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) |
| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) |
| 2026-11-03 | minor | A picture on the system clipboard (a screenshot, an image copied in a browser or an image editor) pastes with Cmd/Ctrl+V, the ribbon's Paste and the Paste menu items as a floating picture whose top-left corner is the active cell, as Excel does; text on the clipboard still wins. Oversized or malformed pictures are refused with a message (#699, #3018, #3182) |
| 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers |
| 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off |
| 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) |