Merge pull request #36 from harrisonoconnorhover/fix/rich-table-clipboard

Fix copying selected cells as rich HTML tables
This commit is contained in:
Brandon Thomas 2026-10-10 12:58:38 -04:00
commit 19094e2d62
14 changed files with 465 additions and 70 deletions

88
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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:?}");

View File

@ -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 }

View File

@ -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<arboard::Clipboard> = 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"])

View File

@ -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!({}));
}

View File

@ -40,8 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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<Json> {

View File

@ -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)]

View File

@ -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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
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<String> {
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("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
out.write_str("<tr>").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, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").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("&amp;"),
'<' => out.write_str("&lt;"),
'>' => out.write_str("&gt;"),
'"' => out.write_str("&quot;"),
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.write_str("<br>")
}
'\n' => out.write_str("<br>"),
_ => out.write_char(ch),
}
.ok()?;
}
out.write_str("</td>").ok()?;
}
out.write_str("</tr>").ok()?;
}
out.write_str("</table>").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()

View File

@ -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, "<tag>&\"\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\"<tag>&\"\"\nnext\"\n25.0%\tlast\n");
assert!(html.starts_with("<table "));
assert!(html.ends_with("</table>"));
assert_eq!(html.matches("<tr>").count(), 2);
assert_eq!(html.matches("<td ").count(), 4);
assert!(html.contains("&lt;tag&gt;&amp;&quot;<br>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("<html"));
let cut = s.execute("edit.cut", json!({"range": "B2:C3", "html": true})).unwrap();
assert_eq!(cut["html"], copied["html"]);
assert_eq!(cut["text"], copied["text"]);
s.execute("edit.paste", json!({"at": "E5", "text": cut["text"]})).unwrap();
assert_eq!(v(&s, "E5"), Value::Number(0.125));
assert_eq!(v(&s, "B2"), Value::Empty);
}
#[test]
fn copy_and_cut_return_html_only_when_requested() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
for cmd in ["edit.copy", "edit.cut"] {
for params in [json!({"range": "A1"}), json!({"range": "A1", "html": false})] {
let r = s.execute(cmd, params).unwrap();
assert_eq!(r["text"], "x\n");
assert!(r.get("html").is_none(), "{cmd} must not bloat programmatic responses with HTML");
}
let r = s.execute(cmd, json!({"range": "A1", "html": true})).unwrap();
assert!(r["html"].as_str().is_some_and(|h| h.contains(">x</td>")));
}
}
#[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("<tr>").count(), 2);
assert_eq!(html.matches("<td ").count(), 1);
assert!(html.contains("rowspan=\"2\" colspan=\"2\""));
assert!(html.contains(">merged</td>"));
// 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("<tr>").count(), 2);
assert_eq!(html.matches("<td ").count(), 3);
assert!(html.contains("rowspan=\"2\" colspan=\"2\""));
assert!(!html.contains("merged"));
assert!(html.contains(">side</td>"));
}
#[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();

View File

@ -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!({})),
}

View File

@ -97,6 +97,9 @@ pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
pub open_url: Option<Box<dyn Fn(&str)>>,
/// 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<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
/// 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) {

View File

@ -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)");

View File

@ -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("<table"));
assert!(calls[0].0.contains("Ada"));
assert_eq!(calls[0].1, "Name\tScore\nAda\t42\n");
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.cut), Some(matches!(event, Event::Cut)));
}
}
#[test]
fn missing_or_failed_rich_clipboard_falls_back_to_plain_text() {
for fail in [false, true] {
let mut services = Services::default();
if fail {
services.copy_html = Some(Box::new(|_, _| Err("clipboard unavailable".into())));
}
let mut h = harness(services);
h.input_mut().events.push(Event::Copy);
h.step();
assert!(
h.output().platform_output.commands.iter().any(|c| { matches!(c, OutputCommand::CopyText(text) if text == "Name\tScore\nAda\t42\n") })
);
}
}
#[test]
fn programmatic_copy_does_not_write_to_the_system_clipboard() {
let calls = Rc::new(RefCell::new(0));
let received = calls.clone();
let mut h = harness(Services {
copy_html: Some(Box::new(move |_, _| {
*received.borrow_mut() += 1;
Ok(())
})),
..Default::default()
});
let copied = h.state_mut().run("edit.copy", json!({})).unwrap();
assert!(copied.get("html").is_none(), "HTML is only built when requested");
let copied = h.state_mut().run("edit.copy", json!({"html": true})).unwrap();
assert!(copied["html"].is_string());
assert_eq!(*calls.borrow(), 0);
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
}