diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index aaa012934..a7472821f 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -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. diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3aab1ccfc..e99ef15de 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -154,7 +154,7 @@ fn read_clipboard( fn services() -> Services { // Keep the owner alive: X11/Wayland serve clipboard data from this handle. let clipboard: Rc>> = 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"]) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index da75d8b73..3ef59a8af 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -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, 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) +} diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index b8823d570..d70296588 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -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 { .. } => {} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 748e23ab3..20b8da851 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -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 Option>>, + /// 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 Option>>, /// Web: deliver a file to the user (name, bytes). pub download: Option, /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. diff --git a/crates/ui-egui/tests/it/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs index be1983b61..ee52dc162 100644 --- a/crates/ui-egui/tests/it/clipboard.rs +++ b/crates/ui-egui/tests/it/clipboard.rs @@ -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()); +} diff --git a/docs/cell-pictures.md b/docs/cell-pictures.md index 586615422..1257476c5 100644 --- a/docs/cell-pictures.md +++ b/docs/cell-pictures.md @@ -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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..76ecc39e0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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) |