diff --git a/Cargo.lock b/Cargo.lock index d742e69..9c720b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1121,6 +1122,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1132,6 +1139,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1289,7 +1302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1394,6 +1407,7 @@ dependencies = [ name = "gridcraft" version = "0.4.0" dependencies = [ + "arboard", "eframe", "egui", "gridcraft-engine", @@ -1594,6 +1608,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1602,7 +1625,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1611,7 +1634,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2039,6 +2062,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2427,6 +2459,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -2475,6 +2517,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -3356,6 +3409,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4223,6 +4287,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 991c633..356c63b 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 479efcf..e5136c6 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -93,6 +93,7 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { + copy_html: Some(Box::new(copy_html)), open_async: Some(Box::new(move || { let inbox = open_inbox.clone(); let ctx = ctx.clone(); @@ -124,6 +125,39 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { } } +/// Start inside the copy gesture; deferring `write` can lose browser user activation. +fn copy_html(html: &str, text: &str) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let window = web_sys::window().ok_or("no window")?; + let clipboard = window.navigator().clipboard(); + if clipboard.is_undefined() || clipboard.is_null() { + return Err("browser clipboard is unavailable (a secure context is required)".into()); + } + if !js_sys::Reflect::get(&clipboard, &"write".into()).map_err(js)?.is_function() { + return Err("browser does not support rich clipboard writes".into()); + } + let data = js_sys::Object::new(); + for (mime, value) in [("text/html", html), ("text/plain", text)] { + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(mime); + let parts = js_sys::Array::of1(&wasm_bindgen::JsValue::from_str(value)); + let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &opts).map_err(js)?; + js_sys::Reflect::set(&data, &mime.into(), &blob).map_err(js)?; + } + let item = web_sys::ClipboardItem::new_with_record_from_str_to_blob_promise(&data).map_err(js)?; + let promise = clipboard.write(&js_sys::Array::of1(&item)); + let text = text.to_owned(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await { + log::warn!("Rich clipboard write failed; trying plain text: {e:?}"); + if let Err(e) = wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await { + log::error!("Plain-text clipboard fallback also failed: {e:?}"); + } + } + }); + Ok(()) +} + /// Triggers a browser download of `bytes`. fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 0d3e34c..66711fa 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -25,6 +25,10 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +arboard = { workspace = true } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +arboard = { workspace = true, features = ["wayland-data-control"] } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 556725a..1c7233a 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -107,7 +107,15 @@ fn save_prefs(app: &SheetApp) { } fn services() -> Services { + // Keep the owner alive: X11/Wayland serve clipboard data from this handle. + let mut clipboard: Option = None; Services { + copy_html: Some(Box::new(move |html, text| { + if clipboard.is_none() { + clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?); + } + clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string()) + })), pick_open: Some(Box::new(|| { rfd::FileDialog::new() .add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"]) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 7910ad7..621b27a 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -294,11 +294,7 @@ impl NativeMenu { let name = if name == "note" { "comment" } else { name }; app.open_dialog(name, params); } else if id == "edit.copy" || id == "edit.cut" { - if let Ok(r) = app.run(&id, json!({})) - && let Some(t) = r.get("text").and_then(Value::as_str) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, &id); } else { app.run_or_alert(&id, json!({})); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9b7db40..1c5e54a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -40,8 +40,8 @@ pub fn specs() -> Vec { cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special), cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo), - cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy), - cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut), + cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy), + cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut), cmd!( "edit.paste", "Paste", @@ -611,9 +611,16 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { range }; let text = crate::display::range_text(&d.wb, sheet, range); + // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and + // scripts get it only when they ask for it. + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None }; let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; s.clipboard = Some(clip); - Ok(json!({"range": range.a1(), "text": text})) + let mut result = json!({"range": range.a1(), "text": text}); + if let Some(html) = html { + result["html"] = Json::String(html); + } + Ok(result) } fn copy(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index e32422a..a604f0b 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -74,7 +74,7 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; let mut out = String::new(); - let r = if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }; + let r = clipboard_range(r); for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; @@ -99,6 +99,11 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { out } +/// The existing plain-text clipboard limit, shared with its HTML representation. +pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef { + if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r } +} + /// Status-bar statistics for a selection: average, count (non-empty), numerical count, min, /// max, sum. #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 83defa5..820b2e6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -2,8 +2,8 @@ use std::fmt::Write as _; -use gridcraft_core::CellRef; -use gridcraft_model::Workbook; +use gridcraft_core::{CellRef, RangeRef}; +use gridcraft_model::{Sheet, Workbook}; use crate::{EngineError, Result}; @@ -110,6 +110,109 @@ fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } +fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String { + let st = wb.styles.get(sh.style_id(c)); + let mut css = String::new(); + if st.font.bold { + css.push_str("font-weight:bold;"); + } + if st.font.italic { + css.push_str("font-style:italic;"); + } + if let Some(h) = st.font.color.hex(&wb.theme) { + let _ = write!(css, "color:{h};"); + } + if st.fill.pattern != gridcraft_model::PatternType::None + && let Some(h) = st.fill.fg.hex(&wb.theme) + { + let _ = write!(css, "background:{h};"); + } + let v = sh.value(c); + let align = match st.align.h { + gridcraft_model::HAlign::Center => "center", + gridcraft_model::HAlign::Right => "right", + gridcraft_model::HAlign::Left => "left", + _ if v.is_number() => "right", + _ => "left", + }; + let _ = write!(css, "text-align:{align};"); + css +} + +const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024; +const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000; + +/// An HTML fragment for the same range as `plain_text`, previously produced by +/// `display::range_text`. Large copies keep plain text only; never return a partial table. +pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option { + let r = crate::display::clipboard_range(r); + if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES { + return None; + } + let sh = wb.sheet(sheet)?; + let mut out = ClipboardHtml(String::new()); + out.write_str("").ok()?; + for row in r.start.row..=r.end.row { + if sh.is_row_hidden(row) { + continue; + } + out.write_str("").ok()?; + for col in r.start.col..=r.end.col { + let c = CellRef::new(row, col); + let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) { + let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?; + if c != CellRef::new(first_row, merged.start.col) { + continue; + } + let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count(); + format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width()) + } else { + String::new() + }; + let css = html_cell_css(wb, sh, c); + write!(out, "").ok()?; + } + out.write_str("").ok()?; + } + out.write_str("
").ok()?; + // Use the visible selected cell, even when the merge's original anchor + // is outside the range or hidden. This matches the plain-text copy. + let text = crate::display::cell_text(wb, sh, c); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '&' => out.write_str("&"), + '<' => out.write_str("<"), + '>' => out.write_str(">"), + '"' => out.write_str("""), + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + out.write_str("
") + } + '\n' => out.write_str("
"), + _ => out.write_char(ch), + } + .ok()?; + } + out.write_str("
").ok()?; + Some(out.0) +} + +/// Enforce the budget while escaping, before HTML expansion can allocate a large string. +struct ClipboardHtml(String); + +impl std::fmt::Write for ClipboardHtml { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) { + return Err(std::fmt::Error); + } + self.0.push_str(s); + Ok(()) + } +} + /// A sheet as a standalone HTML table (Save as Web Page). pub fn to_html(wb: &Workbook, sheet: usize) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; @@ -130,31 +233,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String { if sh.merges.iter().any(|m| m.contains(c) && m.start != c) { continue; } - let st = wb.styles.get(sh.style_id(c)); - let mut css = String::new(); - if st.font.bold { - css.push_str("font-weight:bold;"); - } - if st.font.italic { - css.push_str("font-style:italic;"); - } - if let Some(h) = st.font.color.hex(&wb.theme) { - let _ = write!(css, "color:{h};"); - } - if st.fill.pattern != gridcraft_model::PatternType::None - && let Some(h) = st.fill.fg.hex(&wb.theme) - { - let _ = write!(css, "background:{h};"); - } - let v = sh.value(c); - let align = match st.align.h { - gridcraft_model::HAlign::Center => "center", - gridcraft_model::HAlign::Right => "right", - gridcraft_model::HAlign::Left => "left", - _ if v.is_number() => "right", - _ => "left", - }; - let _ = write!(css, "text-align:{align};"); + let css = html_cell_css(wb, sh, c); let span = sh .merges .iter() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5af6567..b6caa09 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -306,6 +306,103 @@ fn copy_paste_shifts_formulas() { assert_eq!(f["formula"], "=E5+B1"); } +#[test] +fn rich_clipboard_copies_selected_displayed_cells_and_styles() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "outside selection"})).unwrap(); + s.execute("range.setValues", json!({"range": "B2", "values": [[0.125, "&\"\nnext"], [0.25, "last"]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0%"})).unwrap(); + s.execute("home.bold", json!({"range": "B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B2", "on": true})).unwrap(); + s.execute("home.fontColor", json!({"range": "B2", "color": "#123456"})).unwrap(); + s.execute("home.fillColor", json!({"range": "B2", "color": "#FEDCBA"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "B2:C3", "html": true})).unwrap(); + let html = copied["html"].as_str().expect("copy publishes HTML as well as plain text"); + assert_eq!(copied["text"], "12.5%\t\"&\"\"\nnext\"\n25.0%\tlast\n"); + assert!(html.starts_with("")); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches(""))); + } +} + +#[test] +fn rich_clipboard_clips_merges_and_skips_hidden_rows() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "merged"})).unwrap(); + s.execute("home.mergeCenter", json!({"range": "B2:D5"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("cell.set", json!({"cell": "E4", "input": "side"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "B2:C4", "html": true})).unwrap(); + let html = copied["html"].as_str().expect("merged copy publishes HTML"); + assert_eq!(copied["text"], "merged\t\n\t\n"); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches("")); + + // A selection beginning inside a merge must keep its shape without copying + // the original anchor's value from outside the selected rectangle. + let copied = s.execute("edit.copy", json!({"range": "C3:E5", "html": true})).unwrap(); + let html = copied["html"].as_str().expect("partially selected merge publishes HTML"); + assert_eq!(html.matches("").count(), 2); + assert_eq!(html.matches("")); +} + +#[test] +fn rich_clipboard_omits_html_for_large_ranges_without_truncating_text() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A10001", "input": "last cell"})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "A1:A10001", "html": true})).unwrap(); + assert!(copied.get("html").is_none(), "oversized HTML must be omitted, not partially copied"); + let text = copied["text"].as_str().unwrap(); + assert_eq!(text.lines().count(), 10_001); + assert!(text.ends_with("last cell\n")); + let copied = s.execute("edit.copy", json!({"range": "A10001", "html": true})).unwrap(); + assert!(copied["html"].as_str().is_some_and(|html| html.contains("last cell"))); +} + +#[test] +fn rich_clipboard_omits_html_when_escaping_exceeds_byte_budget() { + let mut s = s(); + let text = "&".repeat(1_000_000); + s.execute("range.setValues", json!({"range": "A1", "values": [[text]]})).unwrap(); + let copied = s.execute("edit.copy", json!({"range": "A1", "html": true})).unwrap(); + assert!(copied.get("html").is_none(), "an oversized escaped value must not produce partial HTML"); + assert_eq!(copied["text"].as_str().unwrap().len(), 1_000_001); + assert!(copied["text"].as_str().unwrap().ends_with("&\n")); +} + #[test] fn insert_delete_rows_adjust() { let mut s = s(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 8399f8c..4bd50dd 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1644,18 +1644,10 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: for ev in events { match ev { egui::Event::Copy => { - if let Ok(r) = app.run("edit.copy", json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, "edit.copy"); } egui::Event::Cut => { - if let Ok(r) = app.run("edit.cut", json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ctx.copy_text(t.to_string()); - } + app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})), egui::Event::Text(text) => { @@ -1916,11 +1908,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } "edit.copy" | "edit.cut" => { - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ui.ctx().copy_text(t.to_string()); - } + app.copy_to_clipboard(ui.ctx(), id); } other => app.run_or_alert(other, json!({})), } @@ -2147,11 +2135,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ui.ctx().copy_text(t.to_string()); - } + app.copy_to_clipboard(ui.ctx(), id); } other => app.run_or_alert(other, json!({})), } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4def93..c3efe3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,9 @@ pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, pub open_url: Option>, + /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the + /// write (including any asynchronous fallback); an immediate error uses egui's text path. + pub copy_html: Option Result<(), String>>>, /// Web: deliver a file to the user (name, bytes). pub download: Option>, /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. @@ -214,6 +217,23 @@ impl SheetApp { r } + /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep + /// their existing internal clipboard behavior and do not write to the host clipboard. + pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { + // Only ask the engine for HTML when the host can publish it. + let Ok(result) = self.run(command, json!({"html": self.services.copy_html.is_some()})) else { return }; + let Some(text) = result.get("text").and_then(Json::as_str) else { return }; + if let Some(html) = result.get("html").and_then(Json::as_str) + && let Some(copy_html) = &mut self.services.copy_html + { + match copy_html(html, text) { + Ok(()) => return, + Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), + } + } + ctx.copy_text(text.to_string()); + } + /// Runs a command and shows its error in a message box (for menu/ribbon clicks). pub fn run_or_alert(&mut self, id: &str, params: Json) { if let Err(e) = self.run(id, params) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d556d7e..750d87d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -365,17 +365,11 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - if icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)).clicked() - && let Ok(r) = app.run("edit.cut", json!({})) - && let Some(s) = r.get("text").and_then(|x| x.as_str()) - { - ui.ctx().copy_text(s.to_string()); + if icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)).clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.cut"); } - if icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)).clicked() - && let Ok(r) = app.run("edit.copy", json!({})) - && let Some(s) = r.get("text").and_then(|x| x.as_str()) - { - ui.ctx().copy_text(s.to_string()); + if icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)).clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.copy"); } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs new file mode 100644 index 0000000..188dbdc --- /dev/null +++ b/crates/ui-egui/tests/clipboard.rs @@ -0,0 +1,85 @@ +//! Copy/cut route rich tables to the host without a later egui text write replacing them. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, OutputCommand}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{Services, SheetApp}; +use serde_json::json; + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["Ada", 42]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, services), + ); + h.run_steps(4); + h +} + +#[test] +fn copy_and_cut_publish_html_and_text_without_overwriting_rich_clipboard() { + for event in [Event::Copy, Event::Cut] { + let calls = Rc::new(RefCell::new(Vec::new())); + let received = calls.clone(); + let mut h = harness(Services { + copy_html: Some(Box::new(move |html, text| { + received.borrow_mut().push((html.to_string(), text.to_string())); + Ok(()) + })), + ..Default::default() + }); + h.input_mut().events.push(event.clone()); + h.step(); + let calls = calls.borrow(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.contains("
next")); + for content in ["12.5%", "25.0%", "font-weight:bold;", "font-style:italic;", "color:#123456;", "background:#FEDCBA;", "text-align:right;"] { + assert!(html.contains(content), "missing {content}"); + } + assert!(!html.contains("outside selection")); + assert!(!html.contains("x
merged
side