Merge pull request #461 from neilcawse:fix/ribbon-paste
This commit is contained in:
commit
d43eefe522
|
|
@ -135,10 +135,22 @@ fn save_prefs(app: &SheetApp) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read the system clipboard through the shared handle, creating it on first use.
|
||||
fn read_clipboard<T>(
|
||||
handle: &RefCell<Option<arboard::Clipboard>>,
|
||||
read: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
|
||||
) -> Option<T> {
|
||||
let mut clipboard = handle.try_borrow_mut().ok()?;
|
||||
if clipboard.is_none() {
|
||||
*clipboard = arboard::Clipboard::new().ok();
|
||||
}
|
||||
read(clipboard.as_mut()?).ok()
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
|
||||
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let paste_clipboard = clipboard.clone();
|
||||
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
|
||||
Services {
|
||||
copy_html: Some(Box::new(move |html, text| {
|
||||
let mut clipboard = clipboard.borrow_mut();
|
||||
|
|
@ -147,13 +159,8 @@ fn services() -> Services {
|
|||
}
|
||||
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
|
||||
})),
|
||||
paste_html: Some(Box::new(move || {
|
||||
let mut clipboard = paste_clipboard.try_borrow_mut().ok()?;
|
||||
if clipboard.is_none() {
|
||||
*clipboard = arboard::Clipboard::new().ok();
|
||||
}
|
||||
clipboard.as_mut()?.get().html().ok()
|
||||
})),
|
||||
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()))),
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@ impl NativeMenu {
|
|||
app.open_dialog(name, params);
|
||||
} else if id == "edit.copy" || id == "edit.cut" {
|
||||
app.copy_to_clipboard(ctx, &id);
|
||||
} else if id == "edit.paste" {
|
||||
app.paste_from_clipboard();
|
||||
} else {
|
||||
app.run_or_alert(&id, json!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ pub struct GridState {
|
|||
pub last_border: String,
|
||||
pub last_fill: String,
|
||||
pub last_font_color: String,
|
||||
pub system_clipboard: Option<String>,
|
||||
pub trace: Option<serde_json::Value>,
|
||||
pub renaming_tab: Option<usize>,
|
||||
pub rename_text: String,
|
||||
|
|
@ -2653,6 +2652,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
"edit.copy" | "edit.cut" => {
|
||||
app.copy_to_clipboard(ui.ctx(), id);
|
||||
}
|
||||
"edit.paste" => app.paste_from_clipboard(),
|
||||
other => app.run_or_alert(other, json!({})),
|
||||
}
|
||||
}
|
||||
|
|
@ -2895,6 +2895,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
|||
"edit.copy" | "edit.cut" => {
|
||||
app.copy_to_clipboard(ui.ctx(), id);
|
||||
}
|
||||
"edit.paste" => app.paste_from_clipboard(),
|
||||
other => app.run_or_alert(other, json!({})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ mod tests {
|
|||
#[test]
|
||||
fn item_letter_is_one_level_deep() {
|
||||
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
|
||||
assert_eq!(item_letter("HV", "edit.pasteSpecial"), Some("HVP"));
|
||||
assert_eq!(item_letter("HV", "edit.paste"), Some("HVP"));
|
||||
assert_eq!(item_letter("H", "dialog:pasteSpecial"), None, "the item badge waits for its group letter");
|
||||
assert_eq!(item_letter("HO", "home.autofitColumnWidth"), Some("HOI"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
|||
("HUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Min; AutoSum: Min (variant TBD)
|
||||
("HUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Max; AutoSum: Max (variant TBD)
|
||||
("HV", Entry::Continue), // Excel: Paste; group level:
|
||||
("HVP", Entry::Finish(Action::Command("edit.pasteSpecial"))), // Excel: Paste
|
||||
("HVP", Entry::Finish(Action::Command("edit.paste"))), // Excel: Paste
|
||||
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
|
||||
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
|
||||
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@
|
|||
{
|
||||
"seq": "HVP",
|
||||
"kind": "command",
|
||||
"target": "edit.pasteSpecial",
|
||||
"target": "edit.paste",
|
||||
"excel": "Paste",
|
||||
"note": ""
|
||||
},
|
||||
|
|
|
|||
|
|
@ -179,6 +179,10 @@ pub struct Services {
|
|||
/// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from
|
||||
/// the host pastes the plain text alone.
|
||||
pub paste_html: Option<Box<dyn FnMut() -> Option<String>>>,
|
||||
/// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V
|
||||
/// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes
|
||||
/// GridCraft's own clipboard.
|
||||
pub paste_text: Option<Box<dyn FnMut() -> Option<String>>>,
|
||||
/// 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`.
|
||||
|
|
@ -246,6 +250,14 @@ pub struct SheetApp {
|
|||
pub(crate) shots: control::Shots,
|
||||
/// Chart selected on the sheet (id).
|
||||
pub selected_chart: Option<u32>,
|
||||
/// The system clipboard's text when GridCraft last copied without publishing to it (a
|
||||
/// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this
|
||||
/// text, GridCraft's copy is the newer one and Paste uses it.
|
||||
clipboard_text_at_copy: Option<String>,
|
||||
/// The system clipboard's text right after GridCraft's last cut (the published text, or the
|
||||
/// text noted for an unpublished cut); `None` after a copy, or once the clipboard is seen
|
||||
/// holding other text. Once the cut has been pasted, Paste leaves this text alone.
|
||||
cut_text: Option<String>,
|
||||
pub started: f64,
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +299,8 @@ impl SheetApp {
|
|||
keytips: keytips::KeyTips::default(),
|
||||
shots: control::Shots::default(),
|
||||
selected_chart: None,
|
||||
clipboard_text_at_copy: None,
|
||||
cut_text: None,
|
||||
started: now_ms(),
|
||||
}
|
||||
}
|
||||
|
|
@ -322,6 +336,17 @@ impl SheetApp {
|
|||
|
||||
/// Runs an engine command (or a UI command) and handles UI requests it makes.
|
||||
pub fn run(&mut self, id: &str, params: Json) -> Result<Json, String> {
|
||||
let r = self.run_command(id, params);
|
||||
// This copy leaves the system clipboard alone: note what it holds, so Paste can tell
|
||||
// that GridCraft's copy is newer while that text is still there.
|
||||
if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() {
|
||||
self.clipboard_text_at_copy = self.system_clipboard_text();
|
||||
self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None };
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run_command(&mut self, id: &str, params: Json) -> Result<Json, String> {
|
||||
if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") {
|
||||
self.store_view_state();
|
||||
}
|
||||
|
|
@ -365,11 +390,14 @@ impl SheetApp {
|
|||
/// 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 result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) {
|
||||
let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) {
|
||||
Ok(result) => result,
|
||||
Err(e) => return self.alert(e),
|
||||
};
|
||||
let Some(text) = result.get("text").and_then(Json::as_str) else { return };
|
||||
// The system clipboard gets this copy, so its text decides what Paste pastes.
|
||||
self.clipboard_text_at_copy = None;
|
||||
self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n"));
|
||||
if let Some(html) = result.get("html").and_then(Json::as_str)
|
||||
&& let Some(copy_html) = &mut self.services.copy_html
|
||||
{
|
||||
|
|
@ -381,9 +409,21 @@ impl SheetApp {
|
|||
ctx.copy_text(text.to_string());
|
||||
}
|
||||
|
||||
/// Paste text that came from the system clipboard, with the clipboard's HTML flavour when the
|
||||
/// host can read it.
|
||||
/// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste),
|
||||
/// with the clipboard's HTML flavour when the host can read it.
|
||||
///
|
||||
/// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text
|
||||
/// that came with it is not pasted again over the moved block; GridCraft's own (empty)
|
||||
/// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the
|
||||
/// clipboard has moved on, so the cut's text is forgotten and pastes normally later.
|
||||
pub fn paste_external(&mut self, text: String) {
|
||||
if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) {
|
||||
if self.session.clipboard.is_none() {
|
||||
return self.run_or_alert("edit.paste", json!({}));
|
||||
}
|
||||
} else {
|
||||
self.cut_text = None;
|
||||
}
|
||||
let html = self.services.paste_html.as_mut().and_then(|read| read());
|
||||
let params = match html {
|
||||
Some(html) => json!({"text": text, "html": html}),
|
||||
|
|
@ -392,6 +432,34 @@ impl SheetApp {
|
|||
self.run_or_alert("edit.paste", params);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text.
|
||||
/// `None` when it holds no text or the host can't read it.
|
||||
fn system_clipboard_text(&mut self) -> Option<String> {
|
||||
self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n"))
|
||||
}
|
||||
|
||||
/// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the
|
||||
/// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's).
|
||||
fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool {
|
||||
fn chars(t: &str) -> impl Iterator<Item = char> + '_ {
|
||||
t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r')
|
||||
}
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => chars(a).eq(chars(b)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download.
|
||||
fn deliver_pdf(&self, result: &Json) {
|
||||
if result.get("path").is_some() {
|
||||
|
|
@ -1079,6 +1147,8 @@ impl SheetApp {
|
|||
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
|
||||
self.ui.ribbon_collapsed = false;
|
||||
}
|
||||
keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id),
|
||||
keytips::Action::Command("edit.paste") => self.paste_from_clipboard(),
|
||||
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
|
||||
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
|
||||
// Reserved sequence: fires silently while its command is missing. When the
|
||||
|
|
|
|||
|
|
@ -489,6 +489,8 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json:
|
|||
if response.clicked() {
|
||||
if let Some(d) = id.strip_prefix("dialog:") {
|
||||
app.open_dialog(d, p.clone());
|
||||
} else if *id == "edit.paste" {
|
||||
app.paste_from_clipboard();
|
||||
} else {
|
||||
act(app, id, p.clone());
|
||||
}
|
||||
|
|
@ -501,17 +503,14 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
let st = active_style(app);
|
||||
// Clipboard
|
||||
let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true);
|
||||
paste.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Paste"));
|
||||
for sequence in
|
||||
keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"])
|
||||
{
|
||||
keytip(app, ui, sequence, paste.rect.center_bottom());
|
||||
}
|
||||
if paste.clicked() {
|
||||
if let Some(text) = app.grid.system_clipboard.take() {
|
||||
app.paste_external(text);
|
||||
} else {
|
||||
act(app, "edit.paste", json!({}));
|
||||
}
|
||||
app.paste_from_clipboard();
|
||||
}
|
||||
let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E"));
|
||||
// Keep a keyboard-opened menu anchored to the button when a pointer click
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui::{Event, OutputCommand};
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Modifiers, OutputCommand, PointerButton};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::{Services, SheetApp, grid};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
|
|
@ -116,3 +119,345 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() {
|
|||
assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections."));
|
||||
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_ribbon_paste_button_or_its_menu_does_not_freeze() {
|
||||
// #273: a click on Paste deadlocked the UI thread (a click-state query inside the egui context
|
||||
// lock), so the frame never returned. Run the clicks on a worker and fail on a timeout instead of
|
||||
// hanging the test run if that comes back.
|
||||
let (done, finished) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let cell = value_at;
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("edit.copy", json!({"range": "A1:B2"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
// No host clipboard here (as on the web): GridCraft's own copy is pasted.
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(cell(&h, "D4"), Some(Value::from("Name")));
|
||||
assert_eq!(cell(&h, "E5"), Some(Value::number(42.0)));
|
||||
|
||||
// The secondary click opens the paste options menu; its items paste too.
|
||||
h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click_secondary();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Paste Values").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(cell(&h, "G1"), Some(Value::from("Name")));
|
||||
assert_eq!(cell(&h, "H2"), Some(Value::number(42.0)));
|
||||
let _ = done.send(());
|
||||
});
|
||||
match finished.recv_timeout(std::time::Duration::from_secs(60)) {
|
||||
Ok(()) => assert!(worker.join().is_ok()),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if let Err(panic) = worker.join() {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("clicking Paste froze the UI thread"),
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn value_at(h: &Harness, a1: &str) -> Option<Value> {
|
||||
let at = gridcraft_engine::core::CellRef::parse(a1).unwrap();
|
||||
h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(at)))
|
||||
}
|
||||
|
||||
/// A host clipboard holding `text`, and how many times the app read it.
|
||||
fn system_clipboard(text: &str) -> (Rc<RefCell<(String, usize)>>, Services) {
|
||||
let clipboard = Rc::new(RefCell::new((text.to_string(), 0)));
|
||||
let read = clipboard.clone();
|
||||
let services = Services {
|
||||
paste_text: Some(Box::new(move || {
|
||||
let mut clipboard = read.borrow_mut();
|
||||
clipboard.1 += 1;
|
||||
Some(clipboard.0.clone())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(clipboard, services)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_paste_pastes_the_system_clipboard_like_cmd_v() {
|
||||
// Text copied in another app: the Paste button pastes it, as Cmd/Ctrl+V does.
|
||||
let (clipboard, services) = system_clipboard("x\t1\r\ny\t2\r\n");
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(clipboard.borrow().1, 1, "one clipboard read per click");
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("x")));
|
||||
assert_eq!(value_at(&h, "E4"), Some(Value::number(1.0)));
|
||||
assert_eq!(value_at(&h, "D5"), Some(Value::from("y")));
|
||||
assert_eq!(value_at(&h, "E5"), Some(Value::number(2.0)));
|
||||
assert!(h.state().message.is_none(), "{:?}", h.state().message);
|
||||
|
||||
// The clipboard text is GridCraft's own copy: the copy is pasted with its formulas.
|
||||
h.state_mut().run("cell.set", json!({"cell": "A3", "input": "=B2*2"})).unwrap();
|
||||
let copied = h.state_mut().run("edit.copy", json!({"range": "A3"})).unwrap();
|
||||
clipboard.borrow_mut().0 = copied["text"].as_str().unwrap().to_string();
|
||||
h.state_mut().run("selection.set", json!({"cell": "B9"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.cell(CellRef::parse("B9").unwrap()).unwrap().formula.as_ref().unwrap().text, "C8*2");
|
||||
|
||||
// Nothing on the system clipboard (an image, say): GridCraft's own copy is pasted.
|
||||
clipboard.borrow_mut().0.clear();
|
||||
h.state_mut().run("selection.set", json!({"cell": "B12"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.cell(CellRef::parse("B12").unwrap()).unwrap().formula.as_ref().unwrap().text, "C11*2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_menu_items_paste_the_system_clipboard() {
|
||||
let (clipboard, services) = system_clipboard("from menu");
|
||||
let mut h = harness(services);
|
||||
let ribbon = h.get_by_role_and_label(Role::Button, "Paste").rect();
|
||||
let click_menu_paste = |h: &Harness| h.get_all_by_role_and_label(Role::Button, "Paste").find(|n| n.rect() != ribbon).unwrap().click();
|
||||
|
||||
// Home › Paste options menu › Paste.
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click_secondary();
|
||||
h.run_steps(2);
|
||||
click_menu_paste(&h);
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("from menu")));
|
||||
|
||||
// The cell's context menu › Paste.
|
||||
clipboard.borrow_mut().0 = "from cell menu".into();
|
||||
h.state_mut().run("selection.set", json!({"cell": "F6"})).unwrap();
|
||||
let app = h.state();
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let pos = geo.cell_rect(sh, CellRef::parse("F6").unwrap()).center();
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(h.state().grid.context_menu.is_some());
|
||||
click_menu_paste(&h);
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "F6"), Some(Value::from("from cell menu")));
|
||||
assert_eq!(clipboard.borrow().1, 2);
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, key: egui::Key) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
|
||||
if pressed && key != egui::Key::F10 {
|
||||
h.input_mut().events.push(Event::Text(key.name().to_ascii_lowercase()));
|
||||
}
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_copy_and_cut_reach_the_system_clipboard_and_ribbon_paste_pastes_them() {
|
||||
// Home › Copy / Cut by keytip (Alt or F10, H, C / X) is the ribbon button: the host clipboard
|
||||
// gets the copy, and Paste pastes it rather than older text from another app.
|
||||
for (letter, cut) in [(egui::Key::C, false), (egui::Key::X, true)] {
|
||||
let (clipboard, mut services) = system_clipboard("stale text from earlier");
|
||||
let host = clipboard.clone();
|
||||
services.copy_html = Some(Box::new(move |_, text| {
|
||||
host.borrow_mut().0 = text.to_string();
|
||||
Ok(())
|
||||
}));
|
||||
let mut h = harness(services);
|
||||
for key in [egui::Key::F10, egui::Key::H, letter] {
|
||||
press(&mut h, key);
|
||||
}
|
||||
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.cut), Some(cut), "{letter:?}");
|
||||
assert_eq!(clipboard.borrow().0, "Name\tScore\nAda\t42\n", "{letter:?} publishes to the system clipboard");
|
||||
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "{letter:?}");
|
||||
assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)), "{letter:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_paste_prefers_a_programmatic_copy_until_other_text_is_copied() {
|
||||
// A control-channel or MCP copy leaves the system clipboard alone. Its older text must not
|
||||
// win over the copy; text copied in another app afterwards must.
|
||||
let (clipboard, services) = system_clipboard("stale text from earlier");
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("edit.copy", json!({"range": "A1:B2"})).unwrap();
|
||||
assert_eq!(clipboard.borrow().0, "stale text from earlier", "the system clipboard is untouched");
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
|
||||
assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)));
|
||||
|
||||
clipboard.borrow_mut().0 = "copied elsewhere\t7".into();
|
||||
h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap();
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "G1"), Some(Value::from("copied elsewhere")));
|
||||
assert_eq!(value_at(&h, "H1"), Some(Value::number(7.0)));
|
||||
}
|
||||
|
||||
fn has_formula(h: &Harness, a1: &str) -> bool {
|
||||
let at = CellRef::parse(a1).unwrap();
|
||||
h.state().session.active().and_then(|d| d.wb.active().and_then(|s| s.cell(at)).map(|c| c.formula.is_some())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Close the "Nothing to paste." alert so the ribbon takes clicks again.
|
||||
fn dismiss(h: &mut Harness) {
|
||||
h.state_mut().message = None;
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn click_paste(h: &mut Harness) {
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_ribbon_cut_is_not_pasted_again_from_the_system_clipboard() {
|
||||
// Excel pastes a cut once. After the move, the cut's text is still on the system clipboard:
|
||||
// a second Paste must not write it over the moved block (formulas would become values).
|
||||
let (clipboard, mut services) = system_clipboard("stale text from earlier");
|
||||
let host = clipboard.clone();
|
||||
services.copy_html = Some(Box::new(move |_, text| {
|
||||
host.borrow_mut().0 = text.to_string();
|
||||
Ok(())
|
||||
}));
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("cell.set", json!({"cell": "B3", "input": "=B2*2"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "A1:B3"})).unwrap();
|
||||
h.run_steps(2);
|
||||
for key in [egui::Key::F10, egui::Key::H, egui::Key::X] {
|
||||
press(&mut h, key);
|
||||
}
|
||||
assert_eq!(clipboard.borrow().0, "Name\tScore\nAda\t42\n\t84\n", "the cut is published");
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "E6"), Some(Value::number(84.0)));
|
||||
assert!(has_formula(&h, "E6"), "the cut moves the formula");
|
||||
|
||||
click_paste(&mut h);
|
||||
assert!(has_formula(&h, "E6"), "a second Paste leaves the moved formula alone");
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
|
||||
assert!(h.state().toast.is_some() || h.state().message.is_some(), "Paste reports there is nothing to paste");
|
||||
|
||||
// Text copied in another app afterwards still pastes.
|
||||
dismiss(&mut h);
|
||||
clipboard.borrow_mut().0 = "copied elsewhere\t7".into();
|
||||
h.state_mut().run("selection.set", json!({"cell": "H1"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "H1"), Some(Value::from("copied elsewhere")));
|
||||
assert_eq!(value_at(&h, "I1"), Some(Value::number(7.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_programmatic_cut_does_not_paste_the_older_system_clipboard_text() {
|
||||
// A control-channel or MCP cut leaves the system clipboard alone; once the cut has been
|
||||
// pasted, the text that was there before it must not land on the moved data.
|
||||
let (clipboard, services) = system_clipboard("stale");
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("edit.cut", json!({"range": "A1:B2"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
|
||||
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "the moved data stays");
|
||||
assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)));
|
||||
|
||||
dismiss(&mut h);
|
||||
clipboard.borrow_mut().0 = "copied elsewhere".into();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("copied elsewhere")), "newer external text pastes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paste_keytip_pastes_the_system_clipboard_like_the_paste_button() {
|
||||
// Alt/F10, H, V, P is Home › Paste: it pastes text copied in another app.
|
||||
let (_clipboard, services) = system_clipboard("external\t7");
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
for key in [egui::Key::F10, egui::Key::H, egui::Key::V, egui::Key::P] {
|
||||
press(&mut h, key);
|
||||
}
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("external")));
|
||||
assert_eq!(value_at(&h, "E4"), Some(Value::number(7.0)));
|
||||
assert!(h.state().toast.is_none() && h.state().message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_v_after_a_pasted_cut_does_not_paste_the_cut_text_again() {
|
||||
// Cmd/Ctrl+V hands the app the system clipboard's text, which still holds the cut after the
|
||||
// move. Excel pastes a cut once: the second Cmd/Ctrl+V must leave the moved formulas alone.
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("cell.set", json!({"cell": "B3", "input": "=B2*2"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "A1:B3"})).unwrap();
|
||||
h.input_mut().events.push(Event::Cut);
|
||||
h.step();
|
||||
let text = h
|
||||
.output()
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
OutputCommand::CopyText(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the cut is published");
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
h.input_mut().events.push(Event::Paste(text.clone()));
|
||||
h.run_steps(2);
|
||||
assert!(has_formula(&h, "E6"), "the cut moves the formula");
|
||||
|
||||
// The host may hand the text back with CRLF line endings.
|
||||
h.input_mut().events.push(Event::Paste(text.replace('\n', "\r\n")));
|
||||
h.run_steps(2);
|
||||
assert!(has_formula(&h, "E6"), "a second Cmd/Ctrl+V leaves the moved formula alone");
|
||||
assert_eq!(value_at(&h, "E6"), Some(Value::number(84.0)));
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
|
||||
assert!(h.state().toast.is_some() || h.state().message.is_some(), "Paste reports there is nothing to paste");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() {
|
||||
// Once other text has been pasted, the clipboard has moved on from the cut: the same text
|
||||
// copied again later (from another app, or a cell editor) is a new copy and pastes.
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
h.input_mut().events.push(Event::Cut);
|
||||
h.step();
|
||||
let text = h
|
||||
.output()
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
OutputCommand::CopyText(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the cut is published");
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
h.input_mut().events.push(Event::Paste(text.clone()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "the cut moves A1");
|
||||
h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap();
|
||||
h.input_mut().events.push(Event::Paste("other".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "G1"), Some(Value::from("other")));
|
||||
h.state_mut().run("selection.set", json!({"cell": "H1"})).unwrap();
|
||||
h.input_mut().events.push(Event::Paste(text.replace('\n', "\r\n")));
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1062,7 +1062,7 @@ fn keytip_hv_() {
|
|||
fn keytip_hvp_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "HVP", false);
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "HVP must invoke edit.pasteSpecial and surface its missing-params error");
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "HVP must invoke edit.paste and surface its missing-params error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (ribbon and menu Paste paste the system clipboard like Cmd/Ctrl+V; regression test for the Paste button freeze, #273; Paste button gets an accessible name) · **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
|
||||
|
|
@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area |
|
||||
| 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) |
|
||||
| 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue