mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 04:19:48 -06:00
* UI: an unsaved macro-editor draft counts as unsaved work when its workbook closes Closing a workbook (File > Close, Cmd+W, the Backstage Close, a window's close button) while the macro editor holds unsaved changes now asks about the draft first, with the editor's own Save changes to macro question, even if the workbook itself is saved. Before, the workbook closed and the draft was discarded without a question. Save or Don't Save goes on to close the workbook, which then asks about its own unsaved changes (a saved macro is one); Cancel keeps both open. A draft that cannot be saved keeps its workbook open with the reason shown. A new macro that has not been edited does not ask, and a close that leaves the workbook open (one of several windows) or a forced close goes ahead as before. * UI: a new macro's name change alone counts as an unsaved macro-editor draft The check compared the draft with an untouched new macro that took the draft's current name, so a new macro whose only change was its name compared equal, and closing its workbook discarded the name without asking. It now compares with the name the editor opened with. * UI: Quit asks about an unsaved macro-editor draft before quitting Quit (Cmd+Q, the macOS Quit menu, a quit from the Dock) went straight to the unsaved workbooks and never consulted the macro step editor, so with every workbook saved the app quit and discarded the editor's unsaved steps. It now lets the editor ask first, as the window's close button already does: Save or Don't Save goes on with the quit, Cancel stops it. With one native window per workbook, the editor goes on with the app's quit directly once it has closed: a close sent to the window it is drawn in would close only that workbook's window. * UI: a refused macro save while quitting stops the quit When the macro editor's Save changes question was answered Save during a quit (the window's close button or Quit) and the save was refused (a new macro with a name already in use, a limit), the editor stayed open with the reason shown but kept its quit intent. Closing the editor later on its own then quit the app. A refused save now stops the quit, as Cancel does. * UI: a quit takes over a workbook close waiting on the macro editor When File > Close was waiting on the macro editor's question and Quit came in (or the other way round), closing the editor went on with the quit and then replayed the close. With one native window per workbook, the quit had already moved to the next unsaved workbook's window, so the replayed close acted on that workbook and replaced the quit's Save Changes question with an ordinary close question. The quit now takes over: it asks about every unsaved workbook itself, and the waiting close is dropped. * UI: an untouched new macro closes without asking, however the editor closes Closing a workbook already left an untouched new macro alone, but the editor's own close checks still read its dirty flag, which a new macro starts with: its close button, a quit, the control channel's cancel and the close after a stopped debug run asked Save changes to macro (or refused) for a macro nobody had edited. Every close check now asks whether the draft has unsaved changes, so all of them agree. * UI: closing the window that shows the macro editor asks about its draft The editor's unsaved draft was consulted only when its workbook would close. Closing the window that shows the editor while the workbook stayed open in another window (window.close, or File > Close in that window) went ahead: the window's dialog went with it, and the editor's state was left behind with no window to show it, so the next macro opened in the editor replaced the draft without asking. That close now asks first, like a workbook close. An editor left with no window to show it (a saved one, or a forced close) is now dropped, as when its workbook closes. A window that has closed no longer counts as showing a dialog while its parked state is still kept. * UI: a close waiting on the macro editor runs in the window it was asked in A workbook or window close that waited on the editor's Save changes question was replayed with the command and its parameters alone, so file.close acted on whichever window was the session's active one when the editor closed. The editor is drawn with its own window active, but nothing tied the replay to it. The pending close now records the editor's window and enters it before running again; if that window has closed meanwhile, nothing else is closed and a message says so. * UI: a close asked while the macro editor is asking waits for the answer A second file.close or window.close for the editor's workbook or window while its Save changes question stood replaced the pending close with the new one: the answer then closed whatever the last request asked for, not what the question had been opened for. A further request for the same close now waits on the open question (no second question, and the close runs once); a request for a different close (the workbook while only the editor's window was closing, or the reverse) is refused with the reason until the question is answered. A quit keeps taking over as before. * UI: test the quit with a macro draft in one window and unsaved cells in another The quit asks about the editor's draft first, in its window, then about each unsaved workbook in its window; Cancel at either question stops it with every window and workbook open, and a draft discarded with Don't Save before the quit was cancelled stays discarded. Covered in the single-window harness, which presses the real buttons, and in the native-windows harness, which now takes a key press in the root window. --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>
578 lines
28 KiB
Rust
578 lines
28 KiB
Rust
//! One native window per workbook window: each window keeps its own interface state, and closing
|
||
//! or quitting asks about unsaved workbooks only when a workbook would close.
|
||
//!
|
||
//! The windows are drawn through a test backend that, like eframe, runs each immediate viewport
|
||
//! as a nested pass with its own input, so a close button can be pressed on one window.
|
||
#![cfg(not(target_arch = "wasm32"))]
|
||
|
||
use std::cell::RefCell;
|
||
|
||
use egui::{Context, RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportInfo};
|
||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||
use gridcraft_ui_egui::doc_windows::DocWindows;
|
||
use gridcraft_ui_egui::{SaveAnswer, SheetApp};
|
||
use serde_json::json;
|
||
|
||
thread_local! {
|
||
/// Each viewport's input for the next frame (focus, close button).
|
||
static INFOS: RefCell<egui::ViewportIdMap<ViewportInfo>> = RefCell::new(egui::ViewportIdMap::default());
|
||
/// The viewport commands the app sent this frame.
|
||
static SENT: RefCell<Vec<(ViewportId, ViewportCommand)>> = const { RefCell::new(Vec::new()) };
|
||
/// Input events for the root window's next frame (a key pressed in it).
|
||
static EVENTS: RefCell<Vec<egui::Event>> = const { RefCell::new(Vec::new()) };
|
||
}
|
||
|
||
fn record(mut out: egui::FullOutput) {
|
||
out.textures_delta.clear(); // not painted
|
||
SENT.with(|s| {
|
||
let mut s = s.borrow_mut();
|
||
for (id, v) in &out.viewport_output {
|
||
s.extend(v.commands.iter().map(|c| (*id, c.clone())));
|
||
}
|
||
});
|
||
}
|
||
|
||
struct Desk {
|
||
ctx: Context,
|
||
app: SheetApp,
|
||
windows: DocWindows,
|
||
}
|
||
|
||
impl Desk {
|
||
fn new(quit_with_last_window: bool) -> Desk {
|
||
INFOS.with(|i| i.borrow_mut().clear());
|
||
let ctx = Context::default();
|
||
ctx.set_embed_viewports(false);
|
||
Context::set_immediate_viewport_renderer(|ctx, viewport| {
|
||
let id = viewport.ids.this;
|
||
let mut input = RawInput { viewport_id: id, ..RawInput::default() };
|
||
input.viewports = INFOS.with(|i| i.borrow().clone());
|
||
input.viewports.entry(id).or_default().parent = Some(viewport.ids.parent);
|
||
let mut cb = viewport.viewport_ui_cb;
|
||
record(ctx.run_ui(input, |ui| cb(ui)));
|
||
});
|
||
let mut session = Session::new();
|
||
session.new_workbook();
|
||
let mut app = SheetApp::new(session, Default::default());
|
||
let windows = DocWindows::new(&mut app, egui::ViewportBuilder::default(), quit_with_last_window);
|
||
let mut desk = Desk { ctx, app, windows };
|
||
desk.frames(3);
|
||
desk
|
||
}
|
||
|
||
/// One frame as eframe runs it; `act` runs first, inside the frame. Returns the viewport
|
||
/// commands sent.
|
||
fn frame_with(&mut self, act: impl FnOnce(&mut SheetApp, &Context)) -> Vec<(ViewportId, ViewportCommand)> {
|
||
SENT.with(|s| s.borrow_mut().clear());
|
||
let mut input = RawInput { viewport_id: ViewportId::ROOT, ..RawInput::default() };
|
||
input.viewports = INFOS.with(|i| i.borrow().clone());
|
||
input.viewports.entry(ViewportId::ROOT).or_default();
|
||
input.events = EVENTS.with(|e| std::mem::take(&mut *e.borrow_mut()));
|
||
let Desk { ctx, app, windows } = self;
|
||
let mut act = Some(act);
|
||
let out = ctx.clone().run_ui(input, |ui| {
|
||
let ctx = ui.ctx().clone();
|
||
if let Some(act) = act.take() {
|
||
act(app, &ctx);
|
||
}
|
||
app.logic(&ctx);
|
||
windows.logic(app, &ctx);
|
||
windows.show(app, ui);
|
||
});
|
||
record(out);
|
||
// Close buttons are one-shot events.
|
||
INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear()));
|
||
SENT.with(|s| s.borrow().clone())
|
||
}
|
||
|
||
fn frames(&mut self, n: usize) -> Vec<(ViewportId, ViewportCommand)> {
|
||
(0..n).flat_map(|_| self.frame_with(|_, _| {})).collect()
|
||
}
|
||
|
||
fn viewport(&self, window: u64) -> ViewportId {
|
||
self.windows.viewport_of(window).unwrap_or_else(|| panic!("window {window} has no native window"))
|
||
}
|
||
|
||
/// The user presses window `window`'s close button.
|
||
fn close(&mut self, window: u64) -> Vec<(ViewportId, ViewportCommand)> {
|
||
let vp = self.viewport(window);
|
||
INFOS.with(|i| i.borrow_mut().entry(vp).or_default().events.push(ViewportEvent::Close));
|
||
let mut sent = self.frame_with(|_, _| {});
|
||
sent.extend(self.frames(2));
|
||
sent
|
||
}
|
||
|
||
/// Answers the Save Changes question showing in the active window, as its button does.
|
||
fn answer(&mut self, answer: SaveAnswer) -> Vec<(ViewportId, ViewportCommand)> {
|
||
let mut sent = self.frame_with(|app, ctx| {
|
||
let d = app.dialog.take().unwrap_or_else(|| panic!("no question is showing"));
|
||
assert_eq!(d.name(), "saveChanges");
|
||
let quitting = d.values.get("quit").and_then(serde_json::Value::as_bool) == Some(true);
|
||
app.answer_save_changes(answer, quitting, ctx);
|
||
});
|
||
sent.extend(self.frames(2));
|
||
sent
|
||
}
|
||
|
||
fn window_of(&self, doc: usize) -> u64 {
|
||
let uid = self.app.session.documents()[doc].uid;
|
||
self.app.session.doc_window(uid).unwrap_or_default()
|
||
}
|
||
|
||
/// The user presses `key` in the root window.
|
||
fn key(&mut self, key: egui::Key) -> Vec<(ViewportId, ViewportCommand)> {
|
||
EVENTS.with(|e| {
|
||
e.borrow_mut().push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE })
|
||
});
|
||
let mut sent = self.frame_with(|_, _| {});
|
||
sent.extend(self.frames(2));
|
||
sent
|
||
}
|
||
|
||
/// Opens the step editor on a new macro in the active window and edits it, so the draft has
|
||
/// unsaved changes.
|
||
fn edited_new_macro(&mut self) {
|
||
self.app.open_dialog("macroEditor", json!({}));
|
||
self.frames(3);
|
||
if let Some(ed) = self.app.macros.editor.as_mut() {
|
||
ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "7"})));
|
||
ed.dirty = true;
|
||
}
|
||
self.frames(2);
|
||
}
|
||
}
|
||
|
||
fn sent_to(sent: &[(ViewportId, ViewportCommand)], vp: ViewportId, cmd: &ViewportCommand) -> bool {
|
||
sent.iter().any(|(id, c)| *id == vp && c == cmd)
|
||
}
|
||
|
||
#[test]
|
||
fn each_window_keeps_its_own_selection_and_draft() {
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("file.new", json!({})).unwrap();
|
||
desk.frames(3);
|
||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||
assert_eq!(desk.windows.shown_windows(), [w1, w2], "a native window per workbook");
|
||
assert_ne!(desk.viewport(w1), desk.viewport(w2));
|
||
|
||
desk.app.enter_window(w1);
|
||
desk.app.run("selection.set", json!({"range": "B2"})).unwrap();
|
||
desk.app.begin_edit(Some("one".into()), false);
|
||
desk.frames(2);
|
||
desk.app.enter_window(w2);
|
||
desk.app.run("selection.set", json!({"range": "D4"})).unwrap();
|
||
desk.app.begin_edit(Some("two".into()), false);
|
||
desk.frames(3);
|
||
|
||
for (w, cell, text) in [(w1, "B2", "one"), (w2, "D4", "two")] {
|
||
desk.app.enter_window(w);
|
||
assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some(text), "window {w} keeps its draft");
|
||
assert!(desk.app.grid.rect.is_some(), "window {w} drew its own grid");
|
||
assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse(cell).unwrap(), "window {w} keeps its selection");
|
||
}
|
||
assert!(desk.app.session.documents().iter().all(|d| !d.is_dirty()), "drawing the other window commits no draft");
|
||
}
|
||
|
||
#[test]
|
||
fn closing_one_of_two_windows_on_a_workbook_does_not_ask() {
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||
desk.app.run("view.newWindow", json!({})).unwrap();
|
||
desk.frames(3);
|
||
let windows = desk.windows.shown_windows();
|
||
assert_eq!(windows.len(), 2, "New Window opens a native window");
|
||
let child = windows.iter().copied().find(|w| Some(*w) != desk.windows.root_window()).unwrap();
|
||
|
||
let sent = desk.close(child);
|
||
assert!(desk.app.dialog.is_none(), "the workbook stays open in its other window: nothing to ask");
|
||
assert_eq!(desk.app.session.documents().len(), 1);
|
||
assert_eq!(desk.app.session.windows().len(), 1);
|
||
assert!(desk.app.session.documents()[0].is_dirty(), "its changes are kept");
|
||
assert_eq!(desk.windows.shown_windows().len(), 1);
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_last_window_of_an_unsaved_workbook_asks_and_the_app_quits_after() {
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||
desk.frames(2);
|
||
let w = desk.window_of(0);
|
||
let sent = desk.close(w);
|
||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "the window stays open to ask: {sent:?}");
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||
assert_eq!(desk.app.session.documents().len(), 1);
|
||
|
||
let sent = desk.answer(SaveAnswer::Cancel);
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel keeps it open");
|
||
assert_eq!(desk.app.session.documents().len(), 1);
|
||
|
||
desk.close(w);
|
||
let sent = desk.answer(SaveAnswer::DontSave);
|
||
assert!(desk.app.session.documents().is_empty());
|
||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the last window closed: the app quits: {sent:?}");
|
||
assert!(desk.app.allow_close);
|
||
}
|
||
|
||
#[test]
|
||
fn without_quitting_on_the_last_window_the_app_hides_and_new_shows_it_again() {
|
||
let mut desk = Desk::new(false);
|
||
let w = desk.window_of(0);
|
||
let sent = desk.close(w);
|
||
assert!(desk.app.session.documents().is_empty(), "a clean workbook closes without asking");
|
||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(false)), "{sent:?}");
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the app keeps running: {sent:?}");
|
||
assert!(desk.app.session.documents().is_empty(), "and doesn't make a workbook of its own");
|
||
|
||
let sent = desk.frame_with(|app, _| app.run_or_alert("file.new", json!({})));
|
||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(true)), "{sent:?}");
|
||
assert_eq!(desk.windows.root_window(), Some(desk.window_of(0)));
|
||
}
|
||
|
||
#[test]
|
||
fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() {
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||
desk.app.run("file.new", json!({})).unwrap();
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||
desk.frames(3);
|
||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||
assert_eq!(desk.app.session.active_window(), Some(w2));
|
||
|
||
let sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||
assert_eq!(desk.app.session.active_window(), Some(w1), "Book1's window comes forward to ask");
|
||
assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::Focus), "{sent:?}");
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||
let sent = desk.answer(SaveAnswer::Cancel);
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel stops the quit: {sent:?}");
|
||
assert_eq!(desk.app.session.documents().len(), 2);
|
||
assert!(desk.app.dialog.is_none());
|
||
|
||
desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||
let sent = desk.answer(SaveAnswer::DontSave);
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Book2 is still unsaved: {sent:?}");
|
||
assert_eq!(desk.app.session.active_window(), Some(w2), "then Book2's window asks");
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||
let sent = desk.answer(SaveAnswer::DontSave);
|
||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_quit_question_brings_its_window_to_the_front_from_the_background() {
|
||
// A Dock quit reaches the app while it is behind other apps, hidden or minimized.
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||
desk.frames(3);
|
||
let w = desk.window_of(0);
|
||
|
||
let sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||
for cmd in [ViewportCommand::Visible(true), ViewportCommand::Minimized(false), ViewportCommand::Focus] {
|
||
assert!(sent_to(&sent, desk.viewport(w), &cmd), "{cmd:?} missing: {sent:?}");
|
||
}
|
||
let sent = desk.frames(2);
|
||
assert!(!sent_to(&sent, desk.viewport(w), &ViewportCommand::Focus), "only once: {sent:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() {
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("file.new", json!({})).unwrap();
|
||
desk.frames(3);
|
||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||
assert_eq!(desk.windows.root_window(), Some(w1));
|
||
let book2 = desk.app.session.documents()[1].uid;
|
||
desk.app.enter_window(w2);
|
||
desk.app.run("selection.set", json!({"range": "C3"})).unwrap();
|
||
desk.app.begin_edit(Some("kept".into()), false);
|
||
desk.frames(2);
|
||
|
||
let sent = desk.close(w1);
|
||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "closing the root's window doesn't end the app: {sent:?}");
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}");
|
||
assert_eq!(desk.windows.root_window(), Some(w2), "Book2's window moves into the root");
|
||
assert_eq!(desk.windows.shown_windows(), [w2]);
|
||
assert_eq!(desk.app.session.documents().len(), 1);
|
||
assert_eq!(desk.app.session.doc().unwrap().uid, book2);
|
||
assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some("kept"), "its draft is still open");
|
||
assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse("C3").unwrap());
|
||
assert_eq!(desk.app.session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("C3").unwrap()), Value::Empty);
|
||
}
|
||
|
||
#[test]
|
||
fn menu_commands_follow_the_focused_window_and_leaving_a_window_enters_its_draft() {
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("file.new", json!({})).unwrap();
|
||
desk.frames(3);
|
||
let (w1, w2) = (desk.window_of(0), desk.window_of(1));
|
||
desk.app.begin_edit(Some("typed".into()), false);
|
||
desk.frames(2);
|
||
assert_eq!(desk.app.session.active_window(), Some(w2));
|
||
|
||
// The user clicks into Book1's window.
|
||
let (v1, v2) = (desk.viewport(w1), desk.viewport(w2));
|
||
INFOS.with(|i| {
|
||
let mut i = i.borrow_mut();
|
||
i.entry(v1).or_default().focused = Some(true);
|
||
i.entry(v2).or_default().focused = Some(false);
|
||
});
|
||
desk.frames(2);
|
||
assert_eq!(desk.app.session.active_window(), Some(w1), "commands from the menu bar go to the window focused last");
|
||
assert!(desk.app.editor.is_none(), "Book1's window has no draft");
|
||
let book2 = &desk.app.session.documents()[1];
|
||
assert_eq!(book2.wb.active().unwrap().value(CellRef::new(0, 0)), Value::text("typed"), "the draft was entered in its own window");
|
||
desk.app.run("cell.set", json!({"cell": "B1", "input": "menu"})).unwrap();
|
||
assert_eq!(desk.app.session.documents()[0].wb.active().unwrap().value(CellRef::new(0, 1)), Value::text("menu"));
|
||
}
|
||
|
||
#[test]
|
||
fn feedback_from_opening_a_workbook_shows_in_its_new_window() {
|
||
let mut desk = Desk::new(true);
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "kept"})).unwrap();
|
||
desk.frames(2);
|
||
let book1 = desk.window_of(0);
|
||
let mut wb = gridcraft_engine::model::Workbook::new();
|
||
wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec().into());
|
||
let b64 = gridcraft_engine::io::base64_encode(&gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap());
|
||
desk.frame_with(|app, _| {
|
||
// A script opening the file on the session, then the interface reporting on it.
|
||
let r = app.session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap();
|
||
app.show_open_warnings(&r);
|
||
app.run_or_alert("no.such.command", json!({}));
|
||
});
|
||
desk.frames(2);
|
||
let opened = desk.window_of(1);
|
||
assert_eq!(desk.windows.shown_windows(), [book1, opened]);
|
||
assert_eq!(desk.app.session.active_window(), Some(opened));
|
||
assert_eq!(desk.app.notice.as_ref().map(|n| n.kind), Some(gridcraft_ui_egui::NoticeKind::Warning), "the macro notice shows in the opened window");
|
||
assert!(desk.app.message.is_some(), "and so does the alert");
|
||
desk.app.enter_window(book1);
|
||
assert!(desk.app.notice.is_none() && desk.app.message.is_none(), "Book1's window shows neither");
|
||
}
|
||
|
||
/// Book1 with two sheets, saved, shown in two windows: window 1 on Sheet1 at B2, window 2 on
|
||
/// Sheet2 at D4. Returns the window ids.
|
||
fn two_windows_on_one_workbook(desk: &mut Desk) -> (u64, u64) {
|
||
desk.app.run("home.insertSheet", json!({})).unwrap();
|
||
let path = std::env::temp_dir().join(format!("gridcraft-two-windows-{}-{:?}.xlsx", std::process::id(), std::thread::current().id()));
|
||
desk.app.run("file.save", json!({"path": path})).unwrap();
|
||
let _ = std::fs::remove_file(&path);
|
||
desk.app.run("sheet.activate", json!({"sheet": 0})).unwrap();
|
||
desk.app.run("selection.set", json!({"range": "B2"})).unwrap();
|
||
desk.frames(2);
|
||
let w1 = desk.app.session.active_window().unwrap();
|
||
desk.app.run("view.newWindow", json!({})).unwrap();
|
||
desk.frames(3);
|
||
let w2 = desk.app.session.active_window().unwrap();
|
||
desk.app.run("sheet.activate", json!({"sheet": 1})).unwrap();
|
||
desk.app.run("selection.set", json!({"range": "D4"})).unwrap();
|
||
desk.frames(3);
|
||
(w1, w2)
|
||
}
|
||
|
||
#[test]
|
||
fn two_windows_on_one_workbook_show_their_own_sheet_and_each_others_edits() {
|
||
let mut desk = Desk::new(true);
|
||
let (w1, w2) = two_windows_on_one_workbook(&mut desk);
|
||
assert_eq!(desk.windows.shown_windows(), [w1, w2], "New Window opens a native window");
|
||
assert_ne!(desk.viewport(w1), desk.viewport(w2));
|
||
let titles: Vec<String> = [w1, w2].iter().map(|w| desk.app.session.window_title(*w).unwrap()).collect();
|
||
assert!(titles[0].ends_with(".xlsx:1") && titles[1].ends_with(".xlsx:2"), "{titles:?}");
|
||
|
||
// Drawing both windows every frame swaps their views without copying the workbook.
|
||
let wb = desk.app.session.doc().unwrap().wb.clone();
|
||
desk.frames(3);
|
||
assert!(std::sync::Arc::ptr_eq(&wb, &desk.app.session.doc().unwrap().wb), "a window swap copied the workbook");
|
||
assert!(!desk.app.session.doc().unwrap().is_dirty());
|
||
for (w, sheet, cell) in [(w1, 0, "B2"), (w2, 1, "D4")] {
|
||
desk.app.enter_window(w);
|
||
let d = desk.app.session.doc().unwrap();
|
||
assert_eq!((d.wb.active_sheet(), d.selection.active), (sheet, CellRef::parse(cell).unwrap()), "window {w}'s sheet and selection");
|
||
assert!(desk.app.grid.rect.is_some(), "window {w} drew its grid");
|
||
}
|
||
|
||
// Window 1 refers to Sheet2!D4; window 2 types there; window 1 shows it on the next frame.
|
||
desk.app.enter_window(w1);
|
||
desk.app.run("cell.set", json!({"cell": "B2", "input": "=Sheet2!D4*2"})).unwrap();
|
||
desk.frames(1);
|
||
desk.app.enter_window(w2);
|
||
desk.app.run("cell.set", json!({"input": "21"})).unwrap();
|
||
desk.frames(1);
|
||
desk.app.enter_window(w1);
|
||
let d = desk.app.session.doc().unwrap();
|
||
assert_eq!(d.wb.active_sheet(), 0);
|
||
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("B2").unwrap()), Value::Number(42.0));
|
||
}
|
||
|
||
#[test]
|
||
fn a_dialog_stays_with_its_window_across_a_switch() {
|
||
let mut desk = Desk::new(true);
|
||
let (w1, w2) = two_windows_on_one_workbook(&mut desk);
|
||
desk.app.enter_window(w1);
|
||
desk.app.open_dialog("zoom", json!({}));
|
||
desk.frames(2);
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom"));
|
||
|
||
// From the menu bar.
|
||
desk.app.run("view.switchWindows", json!({"window": w2})).unwrap();
|
||
desk.frames(3);
|
||
assert_eq!(desk.app.session.active_window(), Some(w2));
|
||
assert!(desk.app.dialog.is_none(), "window 2 shows no dialog");
|
||
|
||
// The user clicks into window 1, then back into window 2.
|
||
for (w, other) in [(w1, w2), (w2, w1)] {
|
||
let (v, o) = (desk.viewport(w), desk.viewport(other));
|
||
INFOS.with(|i| {
|
||
let mut i = i.borrow_mut();
|
||
i.entry(v).or_default().focused = Some(true);
|
||
i.entry(o).or_default().focused = Some(false);
|
||
});
|
||
desk.frames(2);
|
||
assert_eq!(desk.app.session.active_window(), Some(w));
|
||
let expected = if w == w1 { Some("zoom") } else { None };
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), expected, "window {w}'s dialog");
|
||
}
|
||
|
||
// A script switching windows while the dialog is open.
|
||
desk.frame_with(|app, ctx| {
|
||
let _ = gridcraft_ui_egui::control::handle(app, ctx, "engine.execute", &json!({"command": "window.activate", "params": {"window": w1}}));
|
||
});
|
||
desk.frames(2);
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom"));
|
||
desk.app.enter_window(w2);
|
||
assert!(desk.app.dialog.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn arrange_all_lays_out_the_native_windows_active_first() {
|
||
let mut desk = Desk::new(true);
|
||
let (w1, w2) = two_windows_on_one_workbook(&mut desk);
|
||
INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().monitor_size = Some(egui::vec2(1000.0, 800.0)));
|
||
desk.app.run("view.arrangeAll", json!({"arrange": "vertical"})).unwrap();
|
||
let sent = desk.frames(1);
|
||
let at = |x: f32| ViewportCommand::OuterPosition(egui::pos2(x, 0.0));
|
||
assert!(sent_to(&sent, desk.viewport(w2), &at(0.0)), "the active window goes left: {sent:?}");
|
||
assert!(sent_to(&sent, desk.viewport(w1), &at(500.0)));
|
||
assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::InnerSize(egui::vec2(500.0, 800.0))));
|
||
|
||
// Without parameters, as a script calls it: tiled.
|
||
let r = desk.app.run("view.arrangeAll", json!({})).unwrap();
|
||
assert_eq!(r["arrange"], "tiled");
|
||
let tiled = gridcraft_ui_egui::doc_windows::arrange_rects("tiled", 3, craft_window::Rect::new(0.0, 0.0, 1000.0, 800.0));
|
||
let r = |x, y, w, h| craft_window::Rect::new(x, y, w, h);
|
||
assert_eq!(tiled, [r(0.0, 0.0, 500.0, 400.0), r(500.0, 0.0, 500.0, 400.0), r(0.0, 400.0, 1000.0, 400.0)]);
|
||
}
|
||
|
||
#[test]
|
||
fn quit_with_the_macro_editor_open_goes_on_once_the_editor_has_closed() {
|
||
let mut desk = Desk::new(false);
|
||
desk.app.run("macro.save", json!({"macro": {"name": "Kept", "steps": []}})).unwrap();
|
||
let d = desk.app.session.active_mut().unwrap();
|
||
d.saved = d.wb.clone();
|
||
desk.app.open_dialog("macroEditor", json!({"macro": "Kept"}));
|
||
desk.frames(3);
|
||
assert!(desk.app.macros.editor.is_some());
|
||
// The editor has nothing unsaved, so it closes; then the quit goes on as the app's quit, not
|
||
// as the editor window's close button (which would close only that workbook's window).
|
||
let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||
sent.extend(desk.frames(2));
|
||
assert!(desk.app.macros.editor.is_none());
|
||
assert!(desk.app.allow_close, "the quit goes on: {sent:?}");
|
||
assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}");
|
||
assert_eq!(desk.app.session.documents().len(), 1, "the workbook was not closed on its own");
|
||
}
|
||
|
||
#[test]
|
||
fn quit_takes_over_a_workbook_close_waiting_on_the_macro_editor() {
|
||
let mut desk = Desk::new(false);
|
||
let w1 = desk.window_of(0);
|
||
desk.app.open_dialog("macroEditor", json!({}));
|
||
desk.frames(3);
|
||
if let Some(ed) = desk.app.macros.editor.as_mut() {
|
||
ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "7"})));
|
||
ed.dirty = true;
|
||
}
|
||
desk.frames(2);
|
||
// Book2 has unsaved changes; Book1, with the editor, is saved.
|
||
desk.app.run("file.new", json!({})).unwrap();
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||
desk.frames(3);
|
||
let w2 = desk.window_of(1);
|
||
desk.app.enter_window(w1);
|
||
desk.frames(2);
|
||
// File › Close on Book1 waits on the editor's question; then Quit takes over.
|
||
let r = desk.app.run("file.close", json!({})).unwrap();
|
||
assert_eq!(r["closed"], false);
|
||
desk.frames(3);
|
||
desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||
// Don't Save (the question's answer closes the editor without saving).
|
||
if let Some(ed) = desk.app.macros.editor.as_mut() {
|
||
ed.ask_close = false;
|
||
ed.dirty = false;
|
||
ed.close_requested = true;
|
||
}
|
||
desk.frames(3);
|
||
assert!(desk.app.macros.editor.is_none());
|
||
// The quit asks about Book2 in its window, as the quit's question, not as a File › Close.
|
||
assert_eq!(desk.app.session.active_window(), Some(w2));
|
||
let d = desk.app.dialog.as_ref().expect("Book2 is asked about");
|
||
assert_eq!(d.name(), "saveChanges");
|
||
assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values);
|
||
assert_eq!(desk.app.session.documents().len(), 2, "nothing closed before the answer");
|
||
}
|
||
|
||
#[test]
|
||
fn quit_with_a_draft_in_one_window_and_unsaved_cells_in_another_asks_in_order_and_cancel_stops_it() {
|
||
let mut desk = Desk::new(false);
|
||
// Book1 (the root window), saved, with an unsaved draft; Book2 with unsaved cells.
|
||
let w1 = desk.window_of(0);
|
||
desk.edited_new_macro();
|
||
desk.app.run("file.new", json!({})).unwrap();
|
||
desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||
desk.frames(3);
|
||
let w2 = desk.window_of(1);
|
||
assert_eq!(desk.app.session.active_window(), Some(w2));
|
||
// Quit from Book2's window: the editor's question comes first, in Book1's window.
|
||
let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||
sent.extend(desk.frames(2));
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}");
|
||
assert_eq!(desk.app.session.active_window(), Some(w1), "the editor's window comes forward");
|
||
assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| ed.ask_close && ed.quit), "the editor asks");
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no workbook is asked about yet");
|
||
// Don't Save (the question's answer closes the editor without saving).
|
||
if let Some(ed) = desk.app.macros.editor.as_mut() {
|
||
ed.ask_close = false;
|
||
ed.dirty = false;
|
||
ed.close_requested = true;
|
||
}
|
||
desk.frames(3);
|
||
assert!(desk.app.macros.editor.is_none());
|
||
// Book2 is asked about next, in its window, as the quit's question.
|
||
assert_eq!(desk.app.session.active_window(), Some(w2), "the unsaved workbook's window comes forward");
|
||
let d = desk.app.dialog.as_ref().expect("Book2 is asked about");
|
||
assert_eq!(d.name(), "saveChanges");
|
||
assert_eq!(d.values.get("title").and_then(serde_json::Value::as_str), Some("Book2"));
|
||
assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values);
|
||
assert_eq!(desk.app.session.documents().len(), 2, "nothing closed before the answer");
|
||
// Cancel: the quit stops with both windows and workbooks open; the discarded draft stays
|
||
// discarded.
|
||
let sent = desk.answer(SaveAnswer::Cancel);
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}");
|
||
assert_eq!(desk.app.session.documents().len(), 2);
|
||
assert_eq!(desk.windows.shown_windows(), [w1, w2]);
|
||
assert!(desk.app.dialog.is_none() && desk.app.macros.editor.is_none());
|
||
assert!(desk.app.session.documents()[1].is_dirty(), "Book2 keeps its changes");
|
||
// A quit cancelled at the editor's question (Escape) stops before any workbook is asked about.
|
||
desk.app.enter_window(w1);
|
||
desk.edited_new_macro();
|
||
let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx));
|
||
sent.extend(desk.frames(2));
|
||
assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| ed.ask_close && ed.quit), "the editor asks");
|
||
sent.extend(desk.key(egui::Key::Escape));
|
||
assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}");
|
||
assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| !ed.ask_close && !ed.quit && ed.draft.steps.len() == 1), "the draft is kept");
|
||
assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no Save Changes question followed");
|
||
assert_eq!(desk.app.session.documents().len(), 2);
|
||
assert_eq!(desk.windows.shown_windows(), [w1, w2]);
|
||
}
|