mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 08:51:30 -06:00
Merge pull request #36 from harrisonoconnorhover/fix/rich-table-clipboard
Fix copying selected cells as rich HTML tables
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+106
-27
@@ -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<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("&"),
|
||||
'<' => out.write_str("<"),
|
||||
'>' => out.write_str(">"),
|
||||
'"' => out.write_str("""),
|
||||
'\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()
|
||||
|
||||
@@ -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("<tag>&"<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();
|
||||
|
||||
@@ -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!({})),
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
Reference in New Issue
Block a user